Jest로 Unit test

선다혜·2024년 1월 10일
0

AAA 패턴

💡 테스트 코드를 아래 3단계 순서로 구분하는 것을 말한다.
Arrange(준비), Act(실행), Assert(단언)

Arrange

  • 테스트를 실행하기 전에 필요한 것들을 준비한다.
  • 객체를 생성하거나 Mock 객체를 만들거나 테스트 전에 호출되어야 할 API들을 호출한다.

Act

  • 테스트 코드를 실행한다.

Assert

  • 실행한 코드가 예상대로 동작했는지 확인한다.

사용자 시나리오

사용자 시나리오 중 일부이다. 테이블이 처음 렌더링되었을 때 보여져야 하는 상태와 테이블 안에서의 동작으로 나누어 적어보았다.

ListTable
1. 렌더링 완료 시
	- 테이블의 행은 10개이다.
2. 테이블 cell 안의 버튼을 눌렀을 떄
	2-1. 숫자 밑에 밑줄이 그려진 버튼을 눌렀을 때
		- 특정 모달이 열린다.
	2-2. 삭제 버튼을 눌렀을 때
		- 삭제 확인 모달이 뜬다.
		2-2-1. 삭제 확인 모달이 뜬 후 취소 버튼을 눌렀을 때
			- 모달이 닫힌다.
		2-2-2. 삭제 확인 모달이 뜬 후 삭제 버튼을 눌렀을 때
			- 데이터 삭제 API 호출하고 모달이 닫힌다.

기술 스택

  • jest
  • react testing library
  • jest-plugin-context
  • axios-mock-adapter

VSCode extension

  • Jest
  • Jest Snippets

코드 작성한 방법

  1. 작성한 시나리오를 바탕으로 테스트를 작성
  2. if문 조건을 기준으로 context를 나눔
  3. it으로 나타나야 할 결과를 작성, assert

상태 중앙 관리로 recoil을 사용하고, api 관련으로는 react-query가 사용된다. 따라서 해당 의존을 wrapper로 감싸 페이지를 render했다.
그리고 API 통신은 jest.spyOn을 통해 useQuery 중 해당 쿼리 키를 사용하는 쿼리에 대해서 모의 응답 데이터를 리턴해주도록 했다.

import api from '@/hooks/common/api'
import ProgramMockData from '@/mocks/data/programManage/list/programList'
import AfterDelProgramMockData from '@/mocks/data/programManage/list/programListAfterDel'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import MockAdapter from 'axios-mock-adapter'
import context from 'jest-plugin-context'
import { ReactNode } from 'react'
import * as ReactQuery from 'react-query'
import { QueryClient, QueryClientProvider, UseQueryResult } from 'react-query'
import { RecoilRoot } from 'recoil'
import ProgramList from './index'
import { ProgramListDataType } from './list.type'

const queryClient = new QueryClient()
const wrapper = ({ children }: { children: ReactNode }) => (
  <RecoilRoot>
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  </RecoilRoot>
)

beforeEach(() => {
  jest.spyOn(ReactQuery, 'useQuery').mockImplementation((queryKey) => {
    // 여기서 queryKey를 검사하여 useProgramList에 해당하는 부분인지 확인 가능
    if (queryKey.includes('ProgramListData')) {
      // queryKey 확인
      return {
        data: { ...ProgramMockData }, // mockData
        isLoading: false,
        isSuccess: true,
      } as UseQueryResult<ProgramListDataType, unknown>
    }
    if (queryKey === 'diseaseType') {
      return {
        data: DiseaseTypeMockData,
        isLoading: false,
        isSuccess: true,
      } as UseQueryResult<DiseaseType, unknown>
    }
    return {
      data: null,
      isLoading: false,
      isSuccess: false,
    } as UseQueryResult<unknown, unknown>
  })
  render(<ProgramList />, { wrapper })
})

describe('ListTable', () => {
  context('1. 렌더링 완료 시', () => {
    it('테이블의 행은 10개이다.', () => {
      const table = screen.getByRole('table')
      const rows = table.querySelectorAll('tbody > tr')
      expect(rows.length).toBe(10)
    })
  })
  context('2. 테이블 cell 안의 버튼을 눌렀을 때', () => {
    context('2-1. 숫자 밑에 밑줄이 그려진 버튼을 눌렀을 때', () => {
      it('특정 모달이 열린다', () => {
        const table = screen.getByRole('table')
        const rows = table.querySelectorAll('tbody > tr')

        // 등록 상품 모달
        rows.forEach((row) => {
          const tdList = row.querySelectorAll('td')
          const talkBtn = tdList[2].querySelector('button')
          if (talkBtn) {
            userEvent.click(talkBtn)
          }
          waitFor(() => {
            expect(screen.getByText('상품 목록')).toBeInTheDocument()
          })
        })

        // 기본버튼 모달
        rows.forEach((row) => {
          const tdList = row.querySelectorAll('td')
          const talkBtn = tdList[3].querySelector('button')
          if (talkBtn) {
            userEvent.click(talkBtn)
          }
          waitFor(() => {
            expect(screen.getByText('기본 버튼 조회')).toBeInTheDocument()
          })
        })

        // 등록 대화 모달
        rows.forEach((row) => {
          const tdList = row.querySelectorAll('td')
          const talkBtn = tdList[4].querySelector('button')
          if (talkBtn) {
            userEvent.click(talkBtn)
          }
          waitFor(() => {
            expect(screen.getByText('등록 대화 조회')).toBeInTheDocument()
            expect(screen.getByTestId('talk-cnt-modal')).toBeInTheDocument()
          })
        })

        // 참여자 이력 모달
        rows.forEach((row) => {
          const tdList = row.querySelectorAll('td')
          const talkBtn = tdList[5].querySelector('button')
          if (talkBtn) {
            userEvent.click(talkBtn)
          }
          waitFor(() => {
            expect(screen.getByText('참여자 이력')).toBeInTheDocument()
          })
        })

        // 사용 그룹 모달
        rows.forEach((row) => {
          const tdList = row.querySelectorAll('td')
          const talkBtn = tdList[7].querySelector('button')
          if (talkBtn) {
            userEvent.click(talkBtn)
          }
          waitFor(() => {
            expect(screen.getByText('그룹 목록')).toBeInTheDocument()
          })
        })
      })
    })
    context('2-2. 삭제 버튼을 눌렀을 때', () => {
      it('삭제 확인 모달이 뜬다.', () => {
        const deleteBtns = screen.getAllByTestId('delete-btn')

        deleteBtns.forEach((btn) => {
          userEvent.click(btn)
          waitFor(() => {
            const deleteConfirmModal = screen.getByTestId(
              'delete-confirm-modal',
            )
            expect(deleteConfirmModal).toBeInTheDocument()
          })
        })
      })
      context('2-2-1. 삭제 확인 모달이 뜬 후 취소 버튼을 눌렀을 때', () => {
        it('모달이 닫힌다.', () => {
          const deleteBtns = screen.getAllByTestId('delete-btn')

          deleteBtns.forEach((btn) => {
            userEvent.click(btn)
            waitFor(() => {
              const deleteConfirmModal = screen.getByTestId(
                'delete-confirm-modal',
              )
              const cancelBtn = screen.getByRole('button', { name: '취소' })

              userEvent.click(cancelBtn)
              waitFor(() => {
                expect(deleteConfirmModal).not.toBeInTheDocument()
              })
            })
          })
        })
      })
      context('2-2-2. 삭제 확인 모달이 뜬 후 삭제 버튼을 눌렀을 때', () => {
        it('데이터가 삭제 API 호출하고 모달이 닫힌다.', () => {
          const deleteBtns = screen.getAllByTestId('delete-btn')

          deleteBtns.forEach((btn) => {
            userEvent.click(btn)

            waitFor(() => {
              const deleteConfirmModal = screen.getByTestId(
                'delete-confirm-modal',
              )
              const deleteBtn = screen.getByRole('button', { name: '삭제' })

              userEvent.click(deleteBtn)

              waitFor(() => {
                expect(deleteConfirmModal).not.toBeInTheDocument()

                const mock = new MockAdapter(api)
                mock
                  .onDelete('/coaching/programs/{3}')
                  .reply(200, { AfterDelProgramMockData })

                const foundItem = AfterDelProgramMockData.items.find(
                  (item) => item.programSno === 3,
                )
                expect(foundItem).toBeUndefined()
              })
            })
          })
        })
      })})
  })
})

테스트 실행 결과

TODO

지금은 컴포넌트화가 많이 되어 있지 않아 인덱스 파일에 테스트를 몰아 넣은 느낌이다.

그래서 컴포넌트화를 시켜 테스트를 잘게 쪼개면 병렬적으로 테스트를 실행해 더 빠른 속도로 테스트를 수행할 수 있을 것이라 생각한다. 또 테이블의 각 cell마다 테스트를 진행하고 있는데, 모든 cell을 확인해야 하는 것인지 조금 의문이 든다. 같은 기능을 수행하는 cell들에 한해서는 대표로 하나만 테스트해도 괜찮을 것 같다.

그리고 Jest로 단위, 기능 테스트를 마치고 e2e 테스트로 playwright를 적용해볼 생각이다.

참고
Unit test의 AAA 패턴(Arrange/Act/Assert)
단위 테스트로 복잡한 도메인의 프론트 프로젝트 정복하기(feat. Jest) | 우아한형제들 기술블로그

0개의 댓글