[FE 테스트] 유틸 함수 단위 테스트 작성 시 고민들 (Vitest 기반)
개요
사내에서 사용 중인 프론트엔드 utils 패키지에, 테스트 코드를 작성하면서 들었던 고민들을 정리한 내용이다.
참고로 테스트 라이브러리는 Vitest를 활용했다. Jest와 고민하다가, 속도 측면과 개발 환경(Vite기반, Typescript 중심) 측면에서 Vitest가 더 적합하다고 판단했기 때문이다.
1. 타입스크립트를 쓰는데 타입 테스트가 필요할까?
1-1. 함수에 타입 방어 로직 작성하기
배열을 받아서 주어진 길이만큼 나누는 chunk 함수가 있다.
const chunk = <T>(arr: Array<T>, size: number) => {
const result: Array<Array<T>> = []
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size))
}
return result
}
여기서 arr과 size에 유효하지 않은 타입(각각 배열과 숫자가 아닌 값)이 들어올 수 있는 상황을 고려해야 할까?
YES!
타입스크립트를 사용하고 있더라도 런타임 환경에서는 null이나 undefined같은 의도하지 않은 값들이 들어올 수 있기 때문이다. 실제로 API 응답에서 배열을 내려주기로 했지만 데이터가 없어서 null을 보낸 경우, 위 코드에서는 TypeError가 발생하며 화면이 터질 것이다.
따라서 잘못된 입력이 들어왔을 때 에러를 던지거나 빈 배열을 반환함으로써 안정성을 높여야 한다.
const chunk = <T>(arr: Array<T>, size: number) => {
if(!Array.isArray(arr) || arr.length === 0) return []
if(!Number.isInteger(size) || size <= 0) return []
const result: Array<Array<T>> = []
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size))
}
return result
}
1-2. 잘못된 타입이 들어오는 경우에 대한 테스트 코드도 작성해야 할까?
무조건 YES!는 아니지만, 작성하는 편이 좋다.
특히 프로젝트 전반에서 쓰이는 공통 유틸리티에서는 방어적인 테스트 코드 작성을 추천한다.
//chunk.test.ts
it("유효하지 않은 타입의 list인 경우 빈 배열을 반환한다", () => {
const invalidInputs = [null, undefined, 0, "", {}]
invalidInputs.forEach((input) => {
// @ts-expect-error: 잘못된 타입 입력 테스트
expect(chunk(input, 2)).toEqual([])
})
})
it("유효하지 않은 타입의 size인 경우 빈 배열을 반환한다", () => {
const invalidInputs = [null, undefined, "", [], {}]
invalidInputs.forEach((input) => {
// @ts-expect-error: 잘못된 타입 입력 테스트
expect(chunk([1,2,3], input)).toEqual([])
})
})
위 테스트 코드로 인해 chunk 함수가 예외 상황에서도 안전하게 돌아간다는 것을 보장할 수 있게 된다.
다만, 프로젝트 내부에서만 사용되고 호출하는 모든 곳에서 타입 시스템이 엄격하게 적용되고 있는 경우라면 위와 같은 테스트가 불필요할 수도 있다. 잘못된 타입이 들어올 확률이 극히 낮은데, 테스트 코드만 한가득 작성해서 가독성만 낮아질 수 있다. 프로젝트 성격과 함수의 사용 범위에 따라 잘 판단하자.
(참고) @ts-expect-error
- 타입스크립트 설정에 따라 잘못된 타입을 인자로 넣을 때 경고가 발생할 것이다.
- 이때 @ts-expect-error 주석을 추가해서 잘못된 타입 입력 테스트임을 알리면 된다.
2. 값이 변하는 데이터는 어떻게 테스트할까? (ft. Fake Timers)
2-1. 날짜 검증
오늘 날짜를 지정된 포맷으로 반환하는 함수가 있다.
import dayjs from "dayjs"
const getToday = (format: string = "YYYY-MM-DD") => {
if (!format) return ""
return dayjs().format(format)
}
getToday는 실행 시점마다 값이 바뀌는 문제가 있다.
보통 이런 경우, 값을 고정(Mocking)하고 테스트를 진행한다.
테스트는 언제 어디서 실행하던 항상 같은 결과가 나와야 하기 때문이다.
Vitest에서는 Fake Timer를 활용하면 된다.
- useFakeTimers: 실제 시스템 타이머 대신 Vitest가 제공하는 가짜 타이머 사용하겠다는 선언
- setSystemTime: 테스트 기준일 설정 (시간 고정)
- useRealTimers: 가짜 타이머 해제하고 실제 시간으로 되돌리기
//getToday.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { getToday } from "./getToday"
describe("getToday", () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date("2026-01-10"))
})
afterEach(() => {
vi.useRealTimers()
})
it("기본 포맷으로 오늘 날짜를 반환해야 한다", () => {
expect(getToday()).toBe("2026-01-10")
})
...
})
(참고) 테스트를 실행하는 순간 dayjs() 값과 함수가 반환한 값을 비교하면 안 될까?
it('기본 포맷으로 오늘 날짜를 반환해야 한다', () => {
const expected = dayjs().format('YYYY-MM-DD');
expect(getToday()).toBe(expected);
});- 아주 드물게 expected를 구할 때와 getToday를 실행할 때의 날짜가 달라져 실패할 가능성이 0.0001%라도 존재한다.
- 따라서 환경 설정 문제로 FakeTimers를 쓰기 어려운 경우가 아니라면 값을 고정하고 테스트하자.
⇒ 시간이나 랜덤 값처럼 제어할 수 없는 요소를 테스트할 때는 Mocking 하자!
2-2. 비동기 함수 테스트
비동기 함수를 테스트할 때도 마찬가지다.
Promise를 반환하는 delay함수를 예시로 살펴보자.
const delay = (time = 1000) => {
return new Promise<void>((resolve) => {
setTimeout(resolve, time)
})
}
모킹 없이 delay 테스트 코드를 작성한다면?
it("1초 뒤에 Promise가 resolve되어야 한다", async () => {
const startTime = Date.now()
await delay(1000)
const endTime = Date.now()
const duration = endTime - startTime
expect(duration).toBe(1000)
})
위 코드는 다음과 같은 문제가 있다.
- 테스트 실행 속도의 저하
- delay가 호출되는 물리적인 시간을 그대로 기다려야 한다.
- await delay(1000) → 실제로 1초 동안 테스트 실행이 여기서 멈춤
- 불확실한 결과
- 네트워크 상태나 CPU 부하에 따라 실제 setTimeout은 미세한 오차가 발생할 수 있다.
- 컴퓨터 환경에 따라 setTimeout(1000)이 어떤 날은 1005ms, 어떤 날은 1010ms에 끝날 수 있다.
- expect(duration).toBe(1000) → 로직은 정상인데 환경 때문에 테스트 실패하는 상황 발생
따라서 이 경우에도 Fake Timer를 활용해야 한다.
- useFakeTimers: 실제 시스템 타이머 대신 Vitest가 제공하는 가짜 타이머 사용하겠다는 선언
- advanceTimersByTimeAsync: 타이머 호출해서 시간 이동
- useRealTimers: 가짜 타이머 해제하고 실제 시간으로 되돌리기
// dealy.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { delay } from "./delay"
describe("delay", () => {
beforeEach(() => {
**vi.useFakeTimers()**
})
afterEach(() => {
**vi.useRealTimers()**
})
it("1초 뒤에 Promise가 resolve되어야 한다", async () => {
const promise = delay()
**await vi.advanceTimersByTimeAsync(1000)**
await expect(promise).resolves.toBeUndefined()
})
...
})
(참고) advanceTimersByTime vs advanceTimersByTimeAsync
- vi.advanceTimersByTime(1000)
- 시간을 1초 뒤로 이동
- setTimeout에 있던 콜백 실행 → resolve() 호출 → Promise가 fulfilled 상태로 변경
- Promise.then 이후 작업들이 마이크로태스크 큐에 들어감
- advanceTimersByTime는 동기적 함수라 마이크로태스크 큐에 들어간 작업들을 안 기다리고 바로 다음 줄인 expect 실행 → 테스트 실패 가능성 존재
⇒ 시간만 이동
- vi.advanceTimersByTimeAsync(1000)
- 시간을 1초 뒤로 이동
- setTimeout에 있던 콜백 실행 → resolve() 호출 → Promise가 fulfilled 상태로 변경
- Promise.then 이후 작업들이 마이크로태스크 큐에 들어감
- advanceTimersByTimeAsync는 비동기적 함수라 마이크로태스크 큐에 들어간 작업들이 실행되도록 기다려 줌
- 모든 Promise 이후 로직 처리가 완료된 이후에 다음 코드인 expect 실행 → 비동기 테스트 안정적으로 성공
⇒ 시간 이동 후 잠시 기다림
결론: 비동기 유틸리티 테스트에서는 advanceTimersByTimeAsync를 쓰면 된다.
3. 다른 함수와 엮여있는 함수는 어떻게 테스트할까?
3-1. mocking 활용하기
값을 포맷팅 하는 formatValue 함수가 있다.
여기서 다른 유틸 함수인 decode를 활용하고 있다.
const formatValue = <T>({ value = null, formatter, useDecoding = true }: FormatValueParams<T>) => {
if (value === null || value === undefined) return ""
const formattedValue = formatter(value)
if (typeof formattedValue !== "string") return formattedValue ?? ""
return useDecoding ? **decode**(formattedValue) : formattedValue
}
만약 formatValue 로직에 집중하고자 한다면 decode를 mocking 해서 테스트 코드를 작성하는 편이 좋다. decode가 실행되는지만 확인하고 실제 구현은 신경 쓰지 않는 것이다. 여기서 궁금한 것은 formatValue의 로직이 잘 돌아가는지? 분기를 잘 처리하는지? 이기 때문이다.
// formatValue.test.ts
import { describe, it, expect, vi } from "vitest"
import { formatValue, EMPTY_TEXT } from "./formatValue"
vi.mock("./decode", () => ({
decode: (str: string) => `$mocked_{str}`,
}))
describe("formatValue", () => {
const params = {value: "test", formatter: () => 'test'}
it("useDecoding이 true이면 decode 함수를 거친 값을 반환해야 한다", () => {
expect(formatValue({ ...params, useDecoding: true })).toBe("mocked_test")
})
it("useDecoding이 false이면 원본 그대로 반환해야 한다", () => {
expect(formatValue({ ...params, useDecoding: false })).toBe("test")
})
...
})
(참고) vi.fn()
- 함수를 모킹 하는 메서드이다.
- 함수의 실행 결과뿐만 아니라, 호출 인자와 반환 값, 함수가 몇 번 호출되었는지 등을 저장한다.
- 내부적으로 이 함수가 호출되었는가를 검증하고 싶을 때 사용하면 된다.
위의 formatValue.test.ts에서는 decode를 실행 결과만 확인할 수 있도록 단순하게 처리했지만, decode함수가 어떻게 호출되는지 검증이 필요하다면 vi.fn을 활용하면 된다.
3-2. 실제 로직 함께 검증하기
이번엔 문자열을 인코딩/디코딩하는 함수를 보자.
import { encode as heEncode } from "he"
const encode = (str: string) => {
if (typeof str !== "string" || !str) return ""
return heEncode(str)
}
import { decode as heDecode } from "he"
const decode = (str: string): string => {
if (typeof str !== "string" || !str) return ""
return heDecode(str)
}
encode와 decode는 하나의 논리적 단위로 상호보완적인 관계에 있다.
이런 경우에는 신뢰성 측면에서 테스트 작성 시 모킹 하지 않고 서로를 검증하는 편이 좋다.
//decode.test.ts
it("인코딩된 결과물을 다시 디코딩하면 원본과 일치해야 한다", () => {
const input = "<div>Tom & 'Jerry'</div>"
const encoded = encode(input)
expect(encoded).not.toBe(input)
expect(decode(encoded)).toBe(input)
})
// encode.test.ts
it("디코딩된 결과물을 다시 인코딩하면 원본과 일치해야 한다", () => {
const input = "<div>Tom & 'Jerry'</div>"
const decoded = decode(input)
expect(decoded).not.toBe(input)
expect(encode(decoded)).toBe(input)
})
3-1의 formatValue에서도 decode의 실제 결과를 검증하고 싶다면 mocking을 활용하지 않아도 된다. 실제 로직이 어떻게 돌아가는지 가장 정확하게 검증할 수 있는 방법이다. 상황에 따라 두 방법 중 한 가지를 선택해 테스트하자.
마무리
확실히 AI로 인해 테스트 코드 작성에 부담이 덜해졌다. 하지만 냅다 '이 함수 테스트 코드 작성해줘'라고 할 수는 없으니, 명령의 기준을 잡는 과정에서 테스트 코드에 대해 공부할 수 있었다.
작성한 테스트 코드의 효용(?)은 앞으로 유틸 함수를 수정을 하면서 체감하겠지만, 테스트 코드 작성 과정 자체에서 기존의 유틸 함수를 보강했다는 점은 만족스럽다. 알지 못했던 오류를 잡았고, 예외 처리 로직을 추가하며 안정성이 높아졌다.
결론은.. 클로드 만세! 제미나이 만세! 🙌
'Frontend > Test' 카테고리의 다른 글
| [Cypress] Cypress 도입기 (feat. MSW) (0) | 2023.02.22 |
|---|