Hello & Intro
안녕하세요,
볼드나인 프론트엔드 개발자 김용재입니다.
오늘은 react-testing-library 를 사용하여 클라이언트 테스트코드를 작성하며 깨달았던 것들과 특히 통합 테스트코드로 무엇을 확인할 수 있는지 코드 예제와 함께 설명드리고자 합니다.
Why?
테스트코드는 왜 작성하는 것일까요?
사람의 반복적인 테스트 작업을 자동화하면 그만큼 인적 자원의 비용을 줄일 수 있고,
무엇보다, 잠재적인 에러 발생을 방지할 수 있기 때문입니다.
개발 단계에서는, 특정 기능의 추가 또는 수정 작업을 할 때 기존의 비즈니스 의도대로 작성되었던 로직을 테스트코드가 테스트하기 때문에 변경점을 파악해 낼 수 있습니다. 의도치 않은 기능 변경을 방지할 수 있어 도움이 됩니다.
새로운 버전 발행을 위한 서비스 전체 페이지를 확인할 때엔 테스트 담당자가 놓친 부분을 테스트코드가 테스트함으로써 일정 범위 이상의 안전성을 보장받을 수 있습니다. 여기에서 일정 범위라 말한 이유는 모든 비즈니스 코드를 위한 테스트를 작성할 수는 없기 때문입니다. (물리적으로는 가능할지라도, 비즈니스 코드가 바뀔 때마다 테스트코드도 변경해야 하기 때문에 안정성을 보장받는 이익 그 이상의 비용이 들 수 있습니다.)
What?
클라이언트(유저와 상호작용하는 프론트엔드 코드)에는 어떤 테스트를 작성해야 할까요?
크게 보면, 사용자 입장에선 시각적인 요소(UI) 와 사용자 이벤트(UX) 일 것입니다.
그리고 개발자 입장에선 추가적으로 API 통신을 포함할 수 있습니다. 이는 특정 기능에 사용되는 API 와 서버 API를 말합니다.
규모별로 테스트 전략이 다른데,
보통 E2E Test, Integration test, Unit Test 총 3가지로 나뉩니다.
피라미드의 상단으로 갈수록 더 넓은 영역의 테스트를 하기 때문에 느리고 더 많은 비용이 듭니다.
E2E 테스트는 End To End 테스트의 약자로 애플리케이션의 흐름을 처음부터 끝까지 테스트하는 것입니다.
여기엔 실제 브라우저를 실행하여 테스트하는 것이 포함됩니다. 그만큼 유저의 실제 시나리오에 가깝게 테스트가 가능하다는 것을 뜻하는데 그만큼 테스트가 길고 복잡해질 수 있습니다.
통합 테스트(Integration Test)는 UI와 API 간 그리고 state에 따른 UI의 변경 등 서로 다른 모듈들 간의 상호작용을 테스트하는 과정입니다. 실제 API를 호출하여 넓은 테스트가 가능하지만 가상의 mocking API를 통해 좁은 테스트를 하는 것이 일반적입니다. 이를 통해 시스템의 신뢰성, 안정성을 높일 수 있습니다.
단위 테스트(Unit Test)는 개별적인 코드 단위인 함수, 메서드 등 의도한 대로 작동하는지 확인합니다. 작성하기 쉽고 테스트가 빠르나, 유저의 실제 시나리오와는 거리가 멀다고 할 수 있습니다.
How to write test? Let’s do Integration Test
본격적으로 통합 테스트코드를 작성해 볼 텐데요, 테스트코드 작성 방법도 중요하지만 어떤 목적으로 무엇을 테스트하는지 주목하시면 더 도움이 될 것이라 생각합니다.
저는 페이지(컴포넌트)와 페이지(컴포넌트) 사이의 상호작용, API 요청의 정상 동작 유무 그리고 유저의 이벤트가 발생시킨 상태 변화의 UI 반영 여부 등에 집중하여 통합 테스트를 작성했습니다.
그중 몇 가지 코드 사례들을 통해 설명드리겠습니다.
1. 페이지 간 상호작용 확인하기
에러 데이터를 반환하기 위한 mock api (mockHandlers.ts)를 작성하고 A 페이지에서 특정 동작을 수행할 때, 반환된 에러 데이터가 정상적으로 오류 페이지에 렌더링 되는지를 확인하는 테스트입니다. 부모 컴포넌트를 렌더링 하기 때문에 두 페이지(컴포넌트) 간의 상호작용이 정상적으로 동작합니다.
// .../__tests__/errorPageCheck.test.tsx
it('A 페이지의 확인 버튼 클릭 후 재고 부족인 경우 오류 페이지에 목록이 렌더링된다.', async () => {
render(
<ParentComponent
...
/>
);
const confirmBtn = await screen.findByText('확인')
await waitFor(() => expect(confirmBtn).toBeEnabled());
await userEvent.click(confirmBtn);
const errorIconInErrorTab = screen.getByLabelText('error icon'); // 에러 아이콘 존재 여부 확인
expect(errorIconInErrorTab).toBeInTheDocument();
const errorTab = screen.getByText('오류');
await userEvent.click(errorTab);
const errorPage = screen.getByLabelText('errorlist tab page');
const renderedErrorRowsCount =
within(errorPage).getByRole('rowgroup').childElementCount;
const mockErrors = result.errorData;
expect(renderedErrorRowsCount).toEqual(mockErrors.length); // 렌더링된 rows 와 mockErrors 데이터의 수를 비교
});
TypeScript
복사
// mockHandlers.ts
export const handlers: GraphQLHandler[] = [
...
const isErrorCase = !!variables.isError;
return HttpResponse.json({
data: {
result: isErrorCase
? mockData.errorCase
: mockData.normalCase
},
});
}),
TypeScript
복사
2. API 요청 및 데이터 렌더링 정상 동작 확인하기
이는 조회 버튼 클릭으로 조회 mock API 요청 및 결괏값으로 받은 데이터가 정상적으로 화면에 렌더링 되는지 확인합니다. 이렇게 유저의 액션에 따른 API 테스트를 위해서는 3가지 파일이 필요합니다.
test file (CheckData.test.tsx), mock server handler file (mockHandlers.ts) 그리고 mock data file.
쉽게 말하면 조회 버튼을 누르는 경우, 이미 구현해 놓은 mock server가 유저의 요청을 받아 설정된 mockData를 결괏값으로 전달하는 과정입니다.
CheckData.test.tsx에서 확인하실 수 있듯이, searchBtn (조회 버튼) 클릭 후 화면에 렌더링 된 데이터의 로우 수와 mockData의 개수의 동일 여부를 체크하는 방식으로 테스트합니다.
// .../__tests__/CheckData.test.tsx
import { server } from '.../mocks/server';
import { handlers } from './mockHandlers';
...
it('조회 버튼을 누르면 데이터가 정상적으로 렌더링된다.', async () => {
server.use(...handlers);
const searchBtn = screen.getByRole('button', {
name: '조회',
});
await userEvent.click(searchBtn);// 조회 버튼 클릭!
const rowsCount = screen.getByRole('rowgroup').childElementCount; // 렌더링된 데이터의 로우수
expect(rowsCount).toBe(mockData.totalCount); // 화면의 rows 와 mock data 개수 비교
});
TypeScript
복사
// mockHandlers.ts
export const handlers: GraphQLHandler[] = [
linkedGrahpql.query<
getMockDataQuery,
getMockDataQueryVariables
>(namedOperations.Query.getMockData, () => {
return HttpResponse.json({
data: {
mockData,
},
});
}),
]
TypeScript
복사
// mockData.ts
export const mockData = {
...
}
TypeScript
복사
3. 권한에 따른 특정 페이지의 접근 가능 여부 확인하기
특정 계정의 경우, 일부 탭만 접근 권한이 있기 때문에 UI에서 이를 제외하고 정상적인 렌더링을 하는지 테스트합니다. 테스트 라이브러리가 제공하는 renderHook 을 사용하여 로컬 상태를 변경할 수 있습니다.
it('셀러 계정인 경우 특정탭만 존재한다.', () => {
const { result } = renderHook(() => useLocalStore());
const prevState = result.current.data;
act(() => {
stateVar({
...prevState,
user: {
...prevState.user,
type: 'SELLER', // 셀러 권한 설정해주기
},
});
});
// 셀러 권한인 상태에서 테스트할 컴포넌트 렌더링하기
render(
<Invoices
...
/>
);
const tablist = screen.getByRole('tablist');
expect(tablist.children).toHaveLength(1);
expect(
// 예상되는 탭이 렌더링되고 있는지 확인하기
within(tablist.children[0] as HTMLElement).getByText('특정탭')
).toBeInTheDocument();
});
TypeScript
복사
4. 셀렉트 인풋 필터 클릭 후 목록에서 아이템 선택하기
리스트에서 특정 아이템을 키보드 방향 키로 이동하여 선택하여 값을 변경하는 동작을 확인하는 테스트로,
아래와 같이 아래 방향 키(arrowdown) 두 번 누르고 엔터키(enter)를 누르는 유저의 행동을 테스트코드가 동일하게 수행할 수 있습니다.
const selectInput: HTMLInputElement = await screen.findByRole(
'textbox',
{
name: '상품명',
}
);
expect(selectInput.value).toBe('처음 선택된 상품');
userEvent.click(selectInput);
userEvent.type(selectInput, '{arrowdown}{arrowdown}{enter}');
expect(productNameInput.value).toBe('변경된 상품');
TypeScript
복사
5. 로컬 스토리지 데이터 저장 확인하기
버튼 클릭 시 특정 정보가 localStorage에 정상적으로 저장되는지 테스트할 수 있습니다.
it('테스트 버튼 클릭시 특정 값을 로컬스토리지에 저장한다.', async () => {
const testBtn = screen.getByText('테스트 버튼');
await userEvent.click(testBtn);
const storedValue = JSON.parse(
window.localStorage.getItem('specific value') ?? ''
);
expect(storedValue[mockData.id]).toBeTruthy();
expect(testBtn).not.toBeInTheDocument();
});
TypeScript
복사
6. 파일 드롭 테스트
특정 엑셀 파일을 만들어 이를 인풋에 드롭하여 의도한 에러 얼럿 메시지가 출력되는지 확인할 수 있습니다.
describe('파일 드롭 첨부 테스트.', () => {
it('파일이 최대 크기를 초과한 경우, 알럿 메세지를 보여준다.', async () => {
const largeMockFile = createMockFile({
sizeInMB: 20,
fileName: 'large-mock-file.xlsx',
fileType: excelType,
});
const dropInput = screen.getByLabelText('drop-box');
fireEvent.drop(dropInput, {
dataTransfer: {
files: [largeMockFile],
types: ['Files'],
},
});
await waitFor(() =>
expect(
screen.getByText(
`파일 사이즈 10MB까지 업로드 가능합니다.`
)
).toBeInTheDocument()
);
});
TypeScript
복사
7. 특정 디바이스에 맞는 UI 렌더링 여부 확인
모바일 화면 너비와 사용 장비를 특정하여 의도한 UI 가 렌더링 되는지 확인할 수 있습니다.
it('모바일이면서 아이폰인 경우, 테스트 다운로드 버튼이 정상 렌더링 된다.', () => {
Object.defineProperty(window, 'innerWidth', {
writable: true,
configurable: true,
value: MOBILE_WIDTH,
});
Object.defineProperty(global.navigator, 'userAgent', {
writable: true,
configurable: true,
value: 'iPhone',
});
render(<MockDownloadButton />);
const downloadButton = screen.getByText('테스트 다운로드');
expect(downloadButton.getAttribute('href')).toBe(testUrl.iphone);
});
TypeScript
복사
Conclusion & Bye
지금까지 테스트를 작성하는 목적과 방법을 알아보았습니다.
그 목적을 다시 한번 요약하자면, 반복적인 테스트에 소비되는 인적 자원의 낭비를 줄이고, 주요 기능을 테스트 함에 있어 피할 수 없는 휴먼 에러를 테스트 코드를 이용하여 보완하기 위함입니다.
그리고 구체적으로 어떤 테스트를 할 수 있는지를 아래의 통합 테스트코드 예제 7가지를 통해 알아보았는데요,
1.
API 요청 및 데이터 렌더링 정상 동작 확인하기
2.
페이지 간 상호작용 확인하기
3.
권한에 따른 특정 페이지의 접근 가능 여부 확인하기
4.
셀렉트 인풋 필터 클릭 후 목록에서 아이템 선택하기
5.
로컬 스토리지 데이터 저장 확인하기
6.
파일 드롭 테스트
7.
특정 디바이스에 맞는 UI 렌더링 여부 확인
이 예시들을 통해, 유저의 사용 환경 및 플로우와 유사한 조건에서 기능의 정상 동작 여부를 확인할 수 있는 테스트코드를 작성하는 것이 중요하다는 이야기를 하고자 했습니다.
저의 글이 테스트코드를 조금 더 이해할 수 있는 계기가 되었길 바랍니다.
감사합니다