2023. 11. 5. 20:31ㆍ🔴 ETC/Tools
계기
개발하고 있는 화면에서 유저의 이벤트에 따라 체크를 해야 하는 케이스들이 점점 늘어남에 따라 테스트 코드에 대한 필요성을 느꼈다.
화면 내부는 위처럼 크게 4개 컴포넌트로 구성되어 있다.
그리고 컴포넌트 내부에서 발생하는 유저의 이벤트에 따라 다른 컴포넌트에 영향이 간다.
예를 들어, component 1에 있는 내용에 따라 component 3에 있는 셀렉박스 선택지가 바뀌는 등..
유저의 시나리오가 많아지면서 코드를 수정할 때마다 직접 일일이 테스트해야하는 케이스들이 늘어났다.
🥲 (하나하나 눌러보느라 아픈 내 손가락..)
테스트 코드 작성에 대한 필요성을 느껴질 때쯤, 아래의 글을 보게 되었다.
https://techblog.woowahan.com/8942/
- 이미 만들어진 코드를 기반으로 테스트 코드를 작성하는 점
- 사용자 ↔️ 어플리케이션 간의 상호작용에 대한 테스트를 한다는 점 (e2e test)
- 테스트 코드를 활용하여 컴포넌트 내 코드 리팩토링을 한다는 점
위 세가지가 나의 상황에 딱 맞아떨어졌고.. 글을 참고하여 테스트 코드를 구축하였다!
test 종류
먼저 테스트 종류에 대해 간단하게 알아보자.
1. Unit = 단위테스트
- 코드의 작은 부분을 테스트 하는 것 (ex. 함수)
- 일반적으로는 Class나 Method 범위를 테스트
2. Integration = 통합테스트
- 서로 다른 시스템들의 상호작용이 잘 이뤄지는지 테스트하는 것
- Unit test와 달리 개발자가 변경할 수 없는 부분까지 묶어서 검증할 때 사용되는 테스트 (ex. 외부 라이브러리, db)
3. e2e = 종단 간 테스트
- 사용자와 어플리케이션의 상호작용이 잘 이뤄지는지 테스트하는 것
- 소프트웨어의 내부 구조 보다는 비즈니스 쪽에 초점을 두어 실제 시나리오대로 잘 동작하는지 테스트
4. TDD
- Test-driven development
- 테스트가 주가 되어 개발하는 방법
적용 방식
보통은 개발하기 전 테스트 코드 작성 후 테스트가 동작하는 코드를 작성한다.(=TDD 방식)
하지만 나는 이미 개발된 코드를 바탕으로 테스트를 추가해야 하므로, e2e 테스트 방식을 채택하였다.
- 기존 코드 & 기획서를 참고하여 사용자 시나리오를 작성한다.
- 테스트 코드를 작성한다.
- 테스트를 돌려보며 안전하게 코드 리팩토링를 한다.
1. 시나리오 작성
사용자 이벤트를 if문처럼 구분을 하여 각 이벤트에 발생하는 동작들을 작성하였다.
시나리오 내용은 크게 2가지로 나누어 볼 수 있다 :
이벤트가 발생한 컴포넌트 내부에서 일어나는 동작들 + 이로 인해 다른 컴포넌트에서 일어나는 동작들
앞서 말한 것처럼, 4개의 구분되어있는 컴포넌트끼리 사용자의 이벤트 동작에 따라 엮어있어서 시나리오가 꽤 복잡하게 나왔다.
초록색으로 하이라이트된 부분이 다른 컴포넌트에 끼치는 영향들이며, 이 동작들에 대해 테스트 코드를 작성할 예정이다.
2. 테스트 코드 작성
개발 환경
1. react-testing-library
- create-react-app 하면 기본으로 설치됨
- 테스트를 위해 실제 dom을 랜더링 하고 wrapper에 감싸여 있음
- DOM API 사용 가능
- 컴포넌트 내부에 있는 상태변경 로직에 따라 랜더링이 여러 번 되기 때문에 waitFor() 사용하여 화면이 랜더링 될 때까지 기다려야 함
- 랜더링 된 결과를 테스트하고 싶을 때 유용함
설치
npm install -d @testing-library/[원하는 패키지]
"devDependencies": {
"@testing-library/dom": "^9.3.3",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10"
}
2. jest
- react-testing-library는 jest를 기본세팅으로 사용함
- 다양한 api 제공 (https://jestjs.io/docs/api)
- @testing-library/jest-dom 으로 dom 객체를 활용 가능
설치
"devDependencies": {
"@testing-library/jest-dom": "^5.11.4",
"jest-environment-jsdom": "^27.0.6",
"ts-jest": "^29.0.0"
}
Jest config 설정
프로젝트 루트 > jest.config.js
module.exports = {
verbose: true,
testPathIgnorePatterns: ['<rootDir>/node_modules/(?!@custom-lib-name)'],
rootDir: '/Users/me/Documents/my-project',
testEnvironment: 'jsdom',
coveragePathIgnorePatterns: ['src/components/index.ts']
};
- verbose : 개별 테스트 결과를 hierarchy로 보여줌
- testPathIgnorePatterns : 변환하지 않는 경로 (regex 가능)
- rootDir : 루트 디렉토리
- testEnvironment : 테스트 환경
- coveragePathIgnorePatterns : skip 할 테스트 적용 범위 (regex 가능)
Babel config 설정
yarn add --dev @babel/preset-typescript @types/jest
프로젝트 루트 > babel.config.js
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
['@babel/preset-react', { runtime: 'automatic' }],
'@babel/preset-typescript'
]
};
테스트 파일 기본 구조
TestComponent.tsx
import React from "react";
const TestComponent = () => {
return (
<div>TestComponent</div>
);
};
export { TestComponent };
TestComponent.test.js
1. 테스트하고자 하는 컴포넌트 랜더링
const RenderTestComponet = () => {
return (<TestComponent />);
};
let container = null;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
render(<RenderTestComponet />, container);
});
});
afterEach(() => {
unmountComponentAtNode(container);
container.remove();
container = null;
});
1) react-dom/test-utils : act()
state가 변경되는 비동기 작업을 외부에서 하는 경우 act()를 사용하여 re-render 횟수를 줄인다.
2) jest lifecycle : beforeEach(), afterEach()
jest lifecycle 함수를 사용하여 여러 개 테스트 함수에서 같은 컴포넌트를 사용해야 할 때 같은 환경을 render 하여 테스트할 수 있다.
예시에서는 beforeEach & afterEach를 사용하여 매 테스트마다 같은 환경을 세팅하여 테스트하도록 하고 있다.
한 컴포넌트를 렌더링 하고 여러 테스트를 해당 컴포넌트 내에서 순차적으로 실행해야 한다면 beforeAll(), afterAll()를 사용하면 된다.
나는 아래와 같이 동일한 컴포넌트 내에서 실행되어야 하는 테스트 함수들을 describe()으로 묶어주었고,
테스트 실행 전 beforeAll(), afterAll()를 사용하여 컴포넌트를 render 해주었다.
describe('동일한 컴포넌트를 사용하는 테스트들 묶음', () => {
let container = null;
beforeAll(() => {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
render(<RenderTestComponet />, container);
});
});
afterAll(() => {
unmountComponentAtNode(container);
container.remove();
container = null;
});
describe('조건 1일 때,', () => {
it('~~해야한다.', () => { ... 테스트 내용 })
});
describe('조건 2일 때,', () => {
it('~~해야한다.', () => { ... 테스트 내용 })
});
});
조건 1,2에 대한 테스트가 총 2가지가 있다.
먼저 TestComponent를 render 한 후, 조건 1 테스트 진행, 그리고 조건 2 테스트가 진행된다.
2. mocking
1) 모듈 mocking
jest.mock('react-router-dom', () => ({
useLocation: jest.fn().mockReturnValue({
pathname: '/another-route',
search: '',
hash: '',
state: null,
key: 'abcdefghi',
}),
}));
React Router의 useLocation hook을 사용할 때 아래와 같은 에러 발생하였다.
"TypeError: Cannot read property 'location' of undefined"
이럴 땐, jest의 mock() 함수를 사용하여 모듈을 통째로 mock 할 수 있다.
이렇게 하면 useLocation()을 호출 시 내가 미리 세팅한 값이 반환된다.
2) 함수 mocking
import fetchUserData from '@remotes/fetchUserData';
const { data: userData = {} as IUserData } = fetchUserData();
위처럼, 테스트에서 render 된 컴포넌트 내부에서 fetchUserData()라는 함수로 외부 데이터를 불러오고 있다.
하지만 실제 데이터가 아닌 mock 데이터를 사용하도록 하고 싶다.
jest.mock('@remotes/fetchUserData', () => {
return jest.fn(() => ({
data: {
name: 'test-user',
id: 'test-user-id',
age: 0
}
}));
});
TestComponent.test.js 에서 jest의 mock()를 사용해서 fetch 함수의 위치를 명시해 준 다음,
해당 함수를 jest.fn()으로 mocking 하여 원하는 값을 사용하게 할 수 있다.
3. 테스트 함수
describe('테스트 1', () => {
describe('1. ~~ 했을 때', () => {
describe('1-1. ~~ 인 경우', () => {
it('~~~ 해준다.', async () => {
await act(async () => {
const element = await screen.getByTestId('info-div');
const input = element.querySelector('input');
await userEvent.clear(input);
await userEvent.type(input, '입력 값');
await waitFor(() => {
expect(screen.getByDisplayValue('입력 값')).toBeInTheDocument();
});
});
});
});
});
});
1) describe(), it()
describe에서는 조건문을, it에서는 테스트 내용을 작성한다.
참고로 describe 대신 xdescribe를 사용하면 해당 범위 내부의 테스트는 실행되지 않는다.
2) @testing-library/dom : getByTestId()
되도록이면 테스트할 때 필요한 element들에는 test-id를 주어 좀 더 쉽고 정확하게 해당 element를 찾을 수 있도록 한다.
3) @testing-library/user-event : userEvent
clear, type 등 화면 동작을 발생시킬 수 있다.
type 하기 전에 먼저 자동입력된 값이 있다면 이 값을 clear 한 후 type 해야 한다.
3) @testing-library/dom : waitFor()
컴포넌트 내부에서 발생하는 이벤트에 따라 랜더링이 바뀌는 경우,
waitFor()를 사용하여 화면이 랜더링 된 후 다음 동작을 실행해야 한다.
4) jest : expect()
조건에 만족하는지 최종적으로 확인하는 함수이다.
4. script 설정
package.json
{
"scripts": {
"test" : "jest",
"coverage": "jest --coverage"
}
}
그 외
1. tsconfig에서 설정한 path alias에 대한 경로 세팅
import 경로가 길 경우, '@path' 형식으로 alias를 설정해 주는 경우가 많은데,
테스트 설정 파일에서 경로에 alias로 설정한 경로가 어느 경로를 가리키는 건지 설정해줘야 한다.
tsconfig
"paths": {
"@vip-app/*": ["./vip/app/*"],
"@vip-pages/*": ["./vip/pages/*"]
}
jest.config.js
moduleNameMapper: {
'^@vip-app/(.*)$': '<rootDir>/src/base/vip/app/$1',
'^@vip-pages/(.*)$': '<rootDir>/src/base/vip/pages/$1'
}
moduleNameMapper에서 path 경로를 지정해 주었다.
2. 특정 라이브러리 global로 설정
테스트하고자 하는 컴포넌트 내에서 uuid를 계산하는 로직에서 crypto 라이브러리를 사용하고 있었다.
그러나 jest 테스트 중에서는 해당 라이브러리를 찾지 못해 아래와 같은 에러가 났다.
"ReferenceError: crypto is not defined"
이를 해결하기 위해선 jest.config.js에서 해당 라이브러리를 global로 설정해 주면 된다.
globals: {
crypto: require('crypto')
}
3. jest test
1) jest --clearCache : 테스트 실행 전 캐쉬를 삭제한다.
2) jest --slient : 각종 warning 메세지를 skip하여 테스트 결과에 표시되지 않도록 한다.
3) jest test/{특정 파일 이름} : 테스트 파일이 여러개 있을 때 특정 파일만 테스트 돌릴 수 있다.
3. 테스트 코드를 활용하여 코드 리팩토링
힘들게 짠 테스트코드가 제일로 기특하게 느껴졌던 코드 리팩토링 과정..👇🏼👇🏼👇🏼
'🔴 ETC > Tools' 카테고리의 다른 글
[test tool 2-2] Jest+React+TypeScript - 이미 개발된 코드에 e2e test를 적용해보자 (+코드 리팩토링은 덤) (0) | 2023.11.07 |
---|---|
[test tool 1] Puppeteer란? (0) | 2022.01.27 |