💡 테스트 코드를 아래 3단계 순서로 구분하는 것을 말한다.
Arrange(준비), Act(실행), Assert(단언)
사용자 시나리오 중 일부이다. 테이블이 처음 렌더링되었을 때 보여져야 하는 상태와 테이블 안에서의 동작으로 나누어 적어보았다.
ListTable
1. 렌더링 완료 시
- 테이블의 행은 10개이다.
2. 테이블 cell 안의 버튼을 눌렀을 떄
2-1. 숫자 밑에 밑줄이 그려진 버튼을 눌렀을 때
- 특정 모달이 열린다.
2-2. 삭제 버튼을 눌렀을 때
- 삭제 확인 모달이 뜬다.
2-2-1. 삭제 확인 모달이 뜬 후 취소 버튼을 눌렀을 때
- 모달이 닫힌다.
2-2-2. 삭제 확인 모달이 뜬 후 삭제 버튼을 눌렀을 때
- 데이터 삭제 API 호출하고 모달이 닫힌다.
상태 중앙 관리로 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()
})
})
})
})
})ㅓ
})
})
})

지금은 컴포넌트화가 많이 되어 있지 않아 인덱스 파일에 테스트를 몰아 넣은 느낌이다.
그래서 컴포넌트화를 시켜 테스트를 잘게 쪼개면 병렬적으로 테스트를 실행해 더 빠른 속도로 테스트를 수행할 수 있을 것이라 생각한다. 또 테이블의 각 cell마다 테스트를 진행하고 있는데, 모든 cell을 확인해야 하는 것인지 조금 의문이 든다. 같은 기능을 수행하는 cell들에 한해서는 대표로 하나만 테스트해도 괜찮을 것 같다.
그리고 Jest로 단위, 기능 테스트를 마치고 e2e 테스트로 playwright를 적용해볼 생각이다.
참고
Unit test의 AAA 패턴(Arrange/Act/Assert)
단위 테스트로 복잡한 도메인의 프론트 프로젝트 정복하기(feat. Jest) | 우아한형제들 기술블로그