왜 테스트를 해야하나?
- 반복되는 같은 에러, 복잡한 코드
- 장기간의 유지보수 : 수정 시 에러 확인에 용이
왜 Vitest 를 사용하기로 했는지?
- vitest
- vite 설정을 그대로 사용할 수 있어 간편
- jest 보다 상대적으로 빠름
- ESM
- jest와 호환되는 api 를 제공한다
- Jest
- bable, typescript, node, react, angular, vue 지원
- bable 세팅해야함 - PWA 프로젝트의 안정성 제공
- commonJS
- 공식문서와 사용예시 등 참고문서가 많다.
이외에도 많은 테스트 모듈이 있지만(@testing-library 등) 기본 테스트의 집합체가 Jest 이고, 가장많이 사용되고 있다.
그런데 Jest 를 선택하지 않은 이유는, 클라이언트 프로젝트가 Vite, ESM 으로 빌드되었기 때문이다. 타입스크립트도 사용중
위 환경에서도 Jest 를 사용할 수 있지만,
vite는 가볍고 빠르다는 장점으로 bable을 미설치하는데, Jest 를 사용하려면 전체설치해야한다. 즉 라이브러리 의존성이 늘어나기 때문에 속도도 조금 저하되는 단점이 있다. Vite 에서 Jest api 를 호환되고, vite 세팅을 그대로 사용해도 되는 vitest 를 사용하기로 결정했다.
많은 기업이 Jest 를 쓰기 때문에 Jest 를 사용해보고 싶었다. 그러나 프로젝트의 적합도를 고려해보았을때 Vitest 가 맞고, Jest 와 테스트코드 작성법은 매우 유사하기 때문에 경험도에서도 문제는 없다고 판단했다.
설치 및 설정 방법
vite/5.0.0, node/18.0.0 이상이 필요하다. : 현재 vite/5.4.11 node/20.10.0 을 사용중이므로 문제 없음
1. 개발 의존성 라이브러리 설치
yarn add -D @types/jest@^29.5.14 @types/react@^18.3.1 @types/react-dom@18.3.1 @testing-library/dom@^10.4.0 @testing-library/jest-dom@^6.6.3 @testing-library/react@^16.1.0 @testing-library/user-event@^14.5.2 jsdom@^25.0.1 vitest@^2.1.8
한번에 설치하는 명령어!!
- vitset, jsdom (ReactDOM 환경 활성화 하기) : `yarn add vitest` `yarn add jsdom`
- React 테스팅 라이브러리 : `yarn add @testing-library/react`
- 테스트 실행을 위한 DOM 환경 라이브러리 : `yarn add @testing-library/jest-dom` `yarn add @testing-library/dom`
- 유저가 발생시키는 이벤트 테스트를 위한 라이브러리 : `yarn add @testing-library/user-event`
- 테스트 타입 설치 : 타입스크립트를 사용하지 않는다면 미설치 `yarn add @types/jest` `yarn add @types/react` `yarn add @types/react-dom`
문제상황
1. @testing-library 의존성 중목 문제로 설치를 인식하지 못함
위 명령어 yarn 으로 설치했을 경우 dependencies와 devDependencies 모두에 포함되어, 타입스크립트가 어떤 패키지를 사용할 지에 문제가 발생했다.
devDependencies 에 남기고, dependencies 에는 제거한다.
재설치하여서 해결!
2. "@testing-library/react": "^16.1.0", 16버전 이상일 경우 dom 수동으로 설치해 주어야 한다.
최종 package.json
test 스크립트 추가
- test : watch 모드로 전체 파일 테스트 계속 실행
- test:run : 일회성으로 전체 파일 테스트 실행
- 뒤에 파일명 추가하면, 해당 파일만 테스트 ex.`yarn test:run button.test.tsx`
"scripts": {
"test": "vitest",
"test:run": "vitest run",
///...
"dependencies": {
"@types/jest": "^29.5.14",
"@types/react": "^18.3.1",
"@types/react-dom": "18.3.1",
///...
"devDependencies": {
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"jsdom": "^25.0.1",
"vitest": "^2.1.8"
///...
2. vite.config.ts 의 test 추가, vitest.setup.ts 작성
vite를 사용하지 않는다면 vitest.config.ts 를 생성해야하고, 이미 사용중이라면 vite config 에 test 을 추가한다.
타입으로 vitest 유형에 대한 참조를 추가해야 한다.
공식문서 : need to add a reference to Vitest types using a triple slash directive at the top of your config file.
//vite.config.ts
/// <reference types="vitest/config" />
import { defineConfig } from 'vite'
export default defineConfig({
test: {
// ... Specify options here.
globals: true,
environment: 'jsdom',
setupFiles: './vitest.setup.ts',
},
})
dom 가져오기 위해 import 하는 셋업코드
//vitest.setup.ts
import '@testing-library/jest-dom'
이 셋업파일을 타입스크립트가 정상적으로 인식할 수 있도록 tsconfig.json의 include 에 추가한다.
"include": ["src", "vitest.setup.ts"]
3. test 코드 작성
폴더구조 : 프로덕션 코드와 테스트 코드를 동일하게 두기
팀 성향차이 일 수 있지만, 가까이 두어야 한눈에 볼 수 있고 테스트코드가 미작성된 파일을 파악하기 좋다고 한다.
- 코드 커버리지 상승 효과!
테스트 폴더로 묶는 경우도 있지만, 동일위치에 두기로 결정!
`example.test.ts`, `example.spec.ts`와 같은 형식으로 파일명을 지정하면 자동을 인식한다.
우리팀은 컴포넌트는 PascalCase, 이외의 파일은 kebap-case 를 사용한다. 테스트코드 파일명도 이 컨벤션을 따르기로 했다.
함수 테스트코드 예시
기존 예시를 참고하였다.
테스트를 돌릴 원본 함수를 작성한다. `sum.ts`
export function sum(a: number, b: number): number {
return a + b;
}
덧셈함수가 예상한대로 동작하는지 확인하기 위한 test코드 작성한다. sum.test.ts
import { expect, test } from 'vitest';
import { sum } from './sum';
test('덧셈 테스트 1 + 2 = 3', () => {
expect(sum(1, 2)).toBe(3);
});
- test() : 테스트 케이스 정의. 조건을 실행하고 예상된 결과를 검증한다
- expect() : 테스트 할 값에 대한 예상 값 설정한다.
- toBe() : expect 와 함께 사용되며 값과 데이터 타입이 일치하는 지 비교한다.
컴포넌트 테스트코드 예시
버튼 컴포넌트를 생성 후, props 렌더링, 스타일링 렌더링이르 테스트한다
//Button.tsx
import { cn } from '@/utils/cn'
import { LoadingSpinner } from '.'
type Props = {
theme?: 'outline' | 'solid'
text: string
isLoading?: boolean
} & React.ComponentProps<'button'>
const Button = ({
type,
theme = 'solid',
className,
text,
disabled,
isLoading,
...props
}: Props) => {
return (
<button
type={type || 'button'}
className={cn(
'flex items-center justify-center text-nowrap rounded-md px-3 py-2',
theme === 'outline'
? 'border-primary-base text-primary-base border bg-white'
: 'bg-primary-base text-white',
disabled &&
theme === 'outline' &&
'border border-neutral-400 bg-white text-neutral-400',
disabled && theme === 'solid' && 'bg-neutral-400 text-white',
isLoading && 'px-5',
className,
)}
disabled={disabled}
{...props}
>
{isLoading ? <LoadingSpinner className="h-4 w-4 sm:h-6 sm:w-6" /> : text}
</button>
)
}
export default Button
1. 버튼 텍스트
2. 로딩일 경우 로딩스피너
3. disabled 로 비활성화, 클릭불가능
4. 테마에 따라 스타일링 분기
등의 기능을 가지고 있음
//Button.test.tsx
import { Button } from '@/components/common'
import { describe, expect, vi, test } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
describe('Button', () => {
test('기본 type 속성 확인', () => {
render(<Button text="버튼" />)
expect(screen.getByText('버튼')).toHaveAttribute('type', 'button')
})
test('텍스트 렌더링 테스트', () => {
//렌더링 하고
render(<Button text="로그인" />)
//요소를 찾는다.
expect(screen.getByText('로그인')).toBeInTheDocument()
})
test('로딩일때, loading spinner 렌더링', () => {
render(<Button text="loading" isLoading={true} />)
const spinner = screen.getByRole('status') //LoadingSpinner.tsx 의 role 값
expect(spinner).toBeInTheDocument()
})
test('로딩중일때 텍스트 미표시', () => {
render(<Button text="loading" isLoading={true} />)
expect(screen.queryByText('loading')).not.toBeInTheDocument()
//텍스트를 loading으로 지정해도, not.toBeInTheDocument 로 문서에 요소가 포함되어있지 않은지 검증
})
test('disabled 일때 버튼 비활성화 UI', () => {
render(<Button text="cant click" disabled={true} />)
expect(screen.getByText('cant click')).toBeDisabled()
})
test('disabled 클릭 이벤트 차단', async () => {
const handleClick = vi.fn()
render(<Button text="클릭" onClick={handleClick} disabled={true} />)
//버튼 클릭 시뮬레이션
await userEvent.click(screen.getByText('클릭'))
expect(handleClick).not.toHaveBeenCalled()
//클릭횟수 not 없음
})
test('클릭 이벤트', async () => {
const handleClick = vi.fn() //모의 함수 생성
render(<Button text="클릭" onClick={handleClick} />)
await userEvent.click(screen.getByText('클릭'))
expect(handleClick).toHaveBeenCalledTimes(1)
//클릭 핸들러 한 번 호출되었는지 검증
})
test('props 전파 확인', () => {
render(<Button text="버튼" aria-label="속성" />)
expect(screen.getByLabelText('속성')).toBeInTheDocument()
})
//스타일 렌더링 테마
test('커스텀 클래스 적용 확인', () => {
render(<Button text="버튼" className="custom-class" />)
expect(screen.getByText('버튼')).toHaveClass('custom-class')
})
test('solid 테마일 때 스타일 적용', () => {
render(<Button text="버튼" theme="solid" />)
const button = screen.getByText('버튼')
expect(button).toHaveClass('bg-primary-base')
expect(button).toHaveClass('text-white')
})
test('outline 테마일 때 스타일 적용', () => {
render(<Button text="버튼" theme="outline" />)
const button = screen.getByText('버튼')
expect(button).toHaveClass('border-primary-base')
expect(button).toHaveClass('text-primary-base')
})
})
- render() : 컴포넌트 렌더링
- expect() : 동일
- screen.~ : ~의 요소가 화면에 존재하는 것
- toBeInTheDocument() : 특정 요소가 문서 안에 존재하는지 검증한다.
- toBeDisabled() :
- 이외의 machers 메소드는 jest 공식문서 참고 : https://jestjs.io/docs/using-matchers
특히 machers 에서 테스트에 적절한 machers 매칭 메소드를 찾는거에서 생각이 많았다.
대표적으로 많이쓰이는 toBe()는 `===` 처럼 값과 타입을 모두 비교하고, toEqual() 은 값만 비교한다. 이외에도 static 으로 판단하는 등 메소드가 많다.
# 참고