💯

좋은 테스트 코드, 나쁜 테스트 코드 — util편 (1)

태그
React
Javascript
Testing
최종 수정일
Last updated April 8, 2026
Slug
good-test-bad-test-1
Date
March 21, 2026
Published
Published
notion image

좋은 테스트 코드, 나쁜 테스트 코드 — util편

지난 글에서 가치 사슬의 역전에 대해 다뤘습니다. 테스트 코드가 안전망이 아니라 설계도가 되는 시대라는 이야기였습니다. 그런데 설계도가 엉망이면 아무리 좋은 건축가를 데려와도 건물이 제대로 서지 않습니다. 테스트의 가치가 올라간 만큼, 좋은 테스트와 나쁜 테스트를 구분하는 기준도 그 어느 때보다 중요해졌습니다.
이 글에서는 Vitest를 기준으로, util 함수와 순수 로직 테스트에서 자주 마주치는 나쁜 패턴과 그 개선 방법을 정리합니다. component와 Hook 테스트는 다음 글에서 별도로 다룹니다.

테스트 이름은 문서입니다

테스트 이름은 그 자체로 문서 역할을 합니다. CI에서 실패했을 때 테스트 이름만 보고 "어떤 동작이 깨졌는지" 파악할 수 있어야 합니다.
다음은 흔히 볼 수 있는 패턴입니다.
개선 전: 하나의 it 블록에 여러 케이스를 묶고, 이름에 정보가 없는 경우
describe('formatPrice', () => { it('should work correctly', () => { expect(formatPrice(1000)).toBe('1,000원'); expect(formatPrice(0)).toBe('0원'); expect(formatPrice(-500)).toBe('-500원'); }); });
"should work correctly"는 아무 정보도 전달하지 않습니다. 이 테스트가 실패하면 "formatPrice가 correctly하게 work하지 않는다"는 메시지를 받게 됩니다. 하나의 it 블록 안에 세 가지 케이스가 들어 있으므로, 어떤 케이스에서 실패했는지도 한눈에 파악하기 어렵습니다.
개선 후: 하나의 it이 하나의 동작을 설명하는 경우
describe('formatPrice', () => { it('양수 금액에 천 단위 콤마와 "원"을 붙인다', () => { expect(formatPrice(1000)).toBe('1,000원'); }); it('0원은 콤마 없이 "0원"을 반환한다', () => { expect(formatPrice(0)).toBe('0원'); }); it('음수 금액은 마이너스 부호를 유지한다', () => { expect(formatPrice(-500)).toBe('-500원'); }); });
CI 로그에서 "음수 금액은 마이너스 부호를 유지한다 — FAILED"가 뜨면, 코드를 열기도 전에 무엇이 깨졌는지 알 수 있습니다. 테스트 이름은 "이 함수는 ~할 때 ~한다"라는 문장이어야 하며, it 하나에 검증 하나가 원칙입니다.

구현이 아니라 동작을 테스트합니다

테스트가 내부 구현에 결합되면, 코드를 리팩토링할 때마다 테스트가 깨집니다. 지난 글에서 다뤘던 문제가 정확히 이 지점입니다. Agent에게 리팩토링을 맡겼는데, 기능은 그대로인데 테스트만 전부 깨진다면 그건 테스트가 잘못된 것입니다.
배열을 정렬하는 sortUsers 함수를 예로 들어 보겠습니다.
개선 전: 내부 구현에 결합된 테스트
it('Array.prototype.sort를 호출한다', () => { const spy = vi.spyOn(Array.prototype, 'sort'); sortUsers([{ name: '김철수', age: 30 }, { name: '이영희', age: 25 }]); expect(spy).toHaveBeenCalled(); spy.mockRestore(); });
Array.prototype.sort를 spy한다는 것은 "이 함수가 내부적으로 sort 메서드를 사용한다"는 구현 세부사항을 테스트하는 것입니다. 나중에 es-toolkit의 sortBy로 교체하거나, 성능상의 이유로 정렬 알고리즘을 직접 구현하면 기능은 동일한데 테스트가 깨집니다.
개선 후: 입력과 출력만 검증
it('사용자를 나이 오름차순으로 정렬한다', () => { const users = [ { name: '김철수', age: 30 }, { name: '이영희', age: 25 }, { name: '박민수', age: 35 }, ]; expect(sortUsers(users)).toEqual([ { name: '이영희', age: 25 }, { name: '김철수', age: 30 }, { name: '박민수', age: 35 }, ]); }); it('나이가 같으면 이름순으로 정렬한다', () => { const users = [ { name: '김철수', age: 30 }, { name: '강민지', age: 30 }, ]; expect(sortUsers(users)).toEqual([ { name: '강민지', age: 30 }, { name: '김철수', age: 30 }, ]); });
이 테스트는 "나이 오름차순으로 정렬되는가"라는 동작만 검증합니다. 내부에서 Array.prototype.sort를 쓰든 es-toolkit의 sortBy를 쓰든 상관없습니다. 구현이 바뀌어도 동작이 같으면 테스트는 통과합니다. 테스트가 깨졌을 때 "기능이 바뀌었구나"가 아니라 "구현이 바뀌었을 뿐인데?"라는 반응이 나온다면, 그 테스트는 잘못 작성된 것입니다.
같은 원칙이 할인 계산 같은 비즈니스 로직에도 적용됩니다. 내부에서 어떤 조건 분기를 탔는지를 검증하는 대신, 특정 입력에 대해 기대하는 출력이 나오는지만 확인하면 됩니다.
개선 전: 내부 분기 로직을 spy
it('VIP 할인 로직을 실행한다', () => { const spy = vi.spyOn(discountModule, 'applyVipDiscount'); calculateTotal({ items: [{ price: 10000 }], memberGrade: 'vip' }); expect(spy).toHaveBeenCalled(); });
개선 후: 결과값만 검증
it('VIP 등급은 10% 할인된 금액을 반환한다', () => { const result = calculateTotal({ items: [{ price: 10000 }], memberGrade: 'vip', }); expect(result).toBe(9000); }); it('일반 등급은 할인 없이 원래 금액을 반환한다', () => { const result = calculateTotal({ items: [{ price: 10000 }], memberGrade: 'normal', }); expect(result).toBe(10000); });
내부에서 applyVipDiscount라는 별도 함수를 호출하든, 할인율을 상수로 관리하든, 테이블 lookup을 하든 관계없습니다. "VIP 등급이면 10% 할인"이라는 동작이 유지되는 한 테스트는 통과합니다.

각 테스트는 독립적으로 실행 가능해야 합니다

테스트 간에 state를 공유하면, 실행 순서에 따라 결과가 달라지는 버그가 발생합니다. Vitest는 기본적으로 테스트 파일을 병렬 실행하기 때문에 이 문제가 더 쉽게 드러납니다.
개선 전: 테스트 간 state 공유
let cart: Cart; beforeAll(() => { cart = new Cart(); }); it('상품을 추가할 수 있다', () => { cart.add({ id: '1', name: '키보드', price: 50000 }); expect(cart.items).toHaveLength(1); }); it('총 금액을 계산할 수 있다', () => { // 위 테스트에서 추가한 '키보드'가 남아 있다고 가정 expect(cart.totalPrice()).toBe(50000); });
두 번째 테스트는 첫 번째 테스트가 먼저 실행되어야만 통과합니다. 단독으로 실행하면 실패합니다. 테스트가 수십, 수백 개로 늘어나면 이런 암묵적 의존이 디버깅을 극도로 어렵게 만듭니다.
개선 후: 각 테스트가 자체 context를 생성
const createCart = () => new Cart(); it('상품을 추가하면 items에 포함된다', () => { const cart = createCart(); cart.add({ id: '1', name: '키보드', price: 50000 }); expect(cart.items).toHaveLength(1); }); it('추가된 상품의 금액을 합산하여 총 금액을 반환한다', () => { const cart = createCart(); cart.add({ id: '1', name: '키보드', price: 50000 }); expect(cart.totalPrice()).toBe(50000); });
약간의 코드 중복이 생기지만, 테스트에서의 중복은 production 코드에서의 중복과 성격이 다릅니다. production 코드의 중복은 유지보수 비용을 높이지만, 테스트 코드의 중복은 각 테스트의 의도를 명확하게 만듭니다. factory 함수(createCart)로 생성 로직만 추출하면 중복을 최소화하면서 독립성도 확보할 수 있습니다.
어떤 테스트를 .only로 단독 실행해도 통과해야 합니다. 그렇지 않다면 어딘가에 숨은 의존이 있는 것입니다.
이 원칙은 외부 의존성에도 동일하게 적용됩니다. 현재 날짜에 의존하는 함수가 대표적입니다.
개선 전: 실제 시간에 의존
it('오늘 날짜 기준으로 D-day를 계산한다', () => { // 이 테스트는 실행하는 날짜에 따라 결과가 달라진다 const dday = calculateDday('2025-12-31'); expect(dday).toBe(275); });
이 테스트는 2025년 3월 31일에 실행하면 통과하지만, 다음 날이 되면 실패합니다. 실행 환경에 따라 결과가 달라지는 것은 일종의 독립성 위반입니다.
개선 후: fake timers로 시간을 통제
beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date('2025-03-31')); }); afterEach(() => { vi.useRealTimers(); }); it('기준일로부터 대상일까지 남은 일수를 반환한다', () => { expect(calculateDday('2025-12-31')).toBe(275); }); it('대상일이 지났으면 음수를 반환한다', () => { expect(calculateDday('2025-03-01')).toBe(-30); });
vi.setSystemTime()으로 "오늘"을 고정합니다. 어떤 날짜에 실행해도, 어떤 CI 환경에서 돌려도 결과가 동일합니다. 시간이라는 외부 의존성을 제거한 것입니다.

테스트는 실패할 수 있어야 의미가 있습니다

항상 통과하는 테스트는 없는 것과 같습니다. 오히려 없는 것보다 나쁩니다. "테스트가 있으니까 괜찮겠지"라는 거짓 안도감을 주기 때문입니다.
개선 전: 절대 실패하지 않는 테스트
it('입력값을 파싱한다', () => { try { const result = parseConfig('{"port": 3000}'); expect(result).toBeDefined(); } catch { // 에러가 나도 통과 } });
try-catch로 에러를 삼키면 parseConfig가 예외를 던져도 테스트는 통과합니다. toBeDefined() 역시 null이 아닌 거의 모든 값에 통과하므로, 사실상 아무것도 검증하지 않는 코드입니다.
개선 후: 구체적인 기대값으로 검증
it('JSON 문자열을 파싱하여 설정 객체를 반환한다', () => { const config = parseConfig('{"port": 3000, "host": "localhost"}'); expect(config).toEqual({ port: 3000, host: 'localhost', }); }); it('유효하지 않은 JSON이면 ConfigParseError를 던진다', () => { expect(() => parseConfig('not-json')).toThrow(ConfigParseError); }); it('필수 필드가 누락되면 MissingFieldError를 던진다', () => { expect(() => parseConfig('{"host": "localhost"}')).toThrow( MissingFieldError ); });
성공 케이스는 구체적인 값으로, 실패 케이스는 명시적인 에러 타입으로 검증합니다. 이 테스트가 통과한다는 것은 반환값의 구조와 내용이 기대와 정확히 일치한다는 뜻입니다.
"이 테스트가 실패하는 상황"을 구체적으로 떠올릴 수 없다면, 그 테스트는 아무것도 보호하고 있지 않습니다. 특히 util 함수는 입력과 출력이 명확하므로, 경계값(빈 문자열, null, 극단적으로 큰 수 등)에 대한 케이스를 빠뜨리지 않는 것이 중요합니다.

마무리

이 글에서 다룬 네 가지 원칙을 다시 짚으면, 테스트 이름이 곧 문서이고, 구현이 아니라 동작을 검증해야 하며, 각 테스트는 독립적으로 실행 가능해야 하고, 실패할 수 있어야 비로소 의미가 있다는 것입니다.
util 함수는 입력과 출력이 명확하기 때문에 이 원칙들을 적용하기가 비교적 수월합니다. 하지만 component와 Hook 테스트로 넘어가면 이야기가 달라집니다. 비동기 rendering, 외부 API mocking, 사용자 interaction 시뮬레이션 등 고려해야 할 것이 훨씬 많아집니다. 같은 네 가지 원칙이 component 테스트에서 어떻게 적용되고, 어디서 더 어려워지는지를 다음 글에서 살펴보겠습니다.