「프론트엔드 테스트 제대로 하기」 시리즈의 4편입니다. 전체 목차 보기 · 용어집

단위 테스트는 모든 테스트의 출발점입니다. 함수 하나를 앞에 두고 “이 입력이면 이 출력"을 못 박는, 가장 작고 가장 빠른 검증이죠. 이 글 하나로 단위 테스트가 무엇인지부터 순수 함수·AAA 패턴·경계값까지, 실전 코드로 기초를 완성합니다.

시리즈로 따라오고 계셨다면 — 지난 편에서 켠 첫 초록불, 아직 잘 켜져 있죠? 오늘은 그 프로젝트를 그대로 이어갑니다. 검색으로 이 글에 처음 오셨다면 그대로 읽으셔도 됩니다. 필요한 배경은 이 글 안에서 다 설명해요.

실습 코드: 이 편의 상태가 step-04 태그에 그대로 담겨 있습니다. git checkout step-04로 따라오세요. 지난 편과의 차이만 보고 싶다면 step-03과 비교도 됩니다.

오늘 다루는 것

  • 단위 테스트란 무엇인가 — 정의와 세 가지 특성
  • 순수 함수가 왜 테스트하기 좋은가
  • AAA(준비-실행-검증) 패턴
  • 경계값·엣지케이스를 놓치지 않는 법
  • 일부러 빨간불을 내보고, 실패 메시지 읽는 법

따라 하려면 Vitest가 설치된 프로젝트가 필요합니다. 없다면 환경 세팅 편을 먼저 보고 오시고요 — 참고로 Jest를 쓰고 계셔도 괜찮습니다. 오늘 나오는 코드는 vi.fn 같은 이름만 빼면 Jest에서도 거의 그대로 동작해요. 물론 환경 없이 눈으로만 따라와도 충분히 읽히게 작성했습니다.

준비됐다면 터미널 한 켠에 npm run test:watch를 켜두세요. 저장할 때마다 결과가 바로바로 보이는 리듬으로 갑니다.

Arrange-Act-Assert 세 단계로 나뉜 테스트 구조 다이어그램 - 준비 단계에서 사용자 배열을 만들고, 실행 단계에서 filterByQuery를 호출하고, 검증 단계에서 expect로 결과를 확인하는 흐름이 세 개의 가로 띠로 표현되어 있습니다
Arrange-Act-Assert 세 단계로 나뉜 테스트 구조 다이어그램 - 준비 단계에서 사용자 배열을 만들고, 실행 단계에서 filterByQuery를 호출하고, 검증 단계에서 expect로 결과를 확인하는 흐름이 세 개의 가로 띠로 표현되어 있습니다

단위 테스트가 뭐길래

단위 테스트(unit test) 는 프로그램에서 가장 작은 단위 하나를 떼어내, 다른 것들과 격리한 채 검증하는 테스트입니다. 프론트엔드에서 “단위"는 대개 함수 하나예요. 네트워크도 데이터베이스도 화면도 끼어들지 않으니, 세 가지 특성이 따라옵니다.

  1. 빠릅니다 — 한 개에 밀리초 단위. 수백 개를 저장할 때마다 돌려도 부담이 없어요.
  2. 실패 지점이 정확합니다 — 깨지면 “이 함수의 이 케이스"까지 바로 좁혀집니다. 화면 전체를 검증하는 테스트가 깨졌을 때의 수사극이 여기엔 없어요.
  3. 명세가 됩니다 — “빈 검색어는 전체를 반환한다” 같은 테스트 이름들이 쌓이면, 그게 곧 함수의 사용 설명서입니다.

물론 단위 테스트가 전부는 아닙니다. 함수들이 맞물린 화면은 컴포넌트 테스트가, 실제 브라우저의 전체 흐름은 E2E 테스트가 맡죠. 하지만 그 모든 층의 기초 체력이 여기서 만들어집니다 — 테스트 전략에서 단위 테스트를 피라미드의 가장 넓은 바닥에 두는 이유예요. (무엇을 얼마나 테스트할지의 큰 그림은 테스트 전략 편에 있습니다.)


왜 순수 함수부터일까

순수 함수는 두 가지 약속을 지키는 함수입니다. ①같은 입력을 넣으면 언제나 같은 출력이 나오고 ②함수 바깥의 세계를 건드리지 않습니다(부수효과 없음).

ts
// 순수: 입력이 같으면 결과도 항상 같다
function add(a: number, b: number) {
  return a + b
}

// 비순수: 부를 때마다 결과가 다르다 (지금 시각에 의존)
function greet(name: string) {
  const hour = new Date().getHours()
  return hour < 12 ? `좋은 아침, ${name}` : `안녕하세요, ${name}`
}

add(2, 3)은 백 번을 불러도 5지만, greet('아이작')은 오전에 부르느냐 오후에 부르느냐에 따라 답이 달라져요. 테스트 입장에서 이 차이는 큽니다. 순수 함수는 넣고 → 받고 → 비교하면 끝이라 준비할 게 없거든요. 비순수 함수는 “지금이 오전이라고 치자” 같은 상황 조작부터 해야 합니다(그 기술은 다음 편에서 배워요).

그래서 단위 테스트 여정은 순수 함수부터 시작하는 게 정석입니다. 가장 쉬운 상대로 폼을 만들고, 점점 어려운 상대로 올라가는 거죠.


검색 필터를 만들며 시작

데모 앱의 심장이 될 검색 필터 로직을 만듭니다. src/lib/users.ts 파일을 새로 만들어주세요. (lib는 화면과 무관한 순수 로직을 모아두는 폴더예요 — 이렇게 분리해두면 테스트하기도 좋습니다.)

ts
// src/lib/users.ts
export type User = { id: string; name: string; email: string }

/** 순수 함수: 이름/이메일을 대소문자 구분 없이 부분 검색 */
export function filterByQuery(users: User[], query: string): User[] {
  const k = query.trim().toLowerCase()
  if (!k) return users
  return users.filter(
    (u) => u.name.toLowerCase().includes(k) || u.email.toLowerCase().includes(k),
  )
}

세 줄짜리 로직이지만 하는 일을 뜯어보면:

  1. query.trim().toLowerCase() — 검색어 앞뒤 공백을 잘라내고 소문자로 통일합니다. " Alice ""alice"로.
  2. if (!k) return users — 정리하고 났더니 빈 문자열이면? 검색어가 없는 것이니 전체 목록을 그대로 돌려줍니다.
  3. users.filter(...) — 이름 또는 이메일에 검색어가 포함되는 사용자만 남깁니다. 양쪽 다 소문자로 비교하니 대소문자는 무시되고요.

같은 입력이면 언제나 같은 결과 — 순수 함수 맞죠? 이제 이 함수가 정말 저 세 가지 일을 하는지, 테스트로 못 박아봅시다.


AAA 패턴

좋은 테스트는 세 박자로 읽힙니다. 준비(Arrange) — 재료를 차린다, 실행(Act) — 검사할 동작을 한 번 수행한다, 검증(Assert) — 결과가 기대와 같은지 확인한다. 요리로 치면 재료 손질 → 조리 → 맛보기 순서예요.

src/lib/users.test.ts를 만들고(테스트 파일은 항상 대상 파일 옆에, .test.ts를 붙여서) 첫 테스트를 이 세 박자로 써봅니다.

ts
import { describe, it, expect } from 'vitest'
import { filterByQuery, type User } from './users'

// Arrange — 모든 테스트가 함께 쓸 재료
const users: User[] = [
  { id: '1', name: 'Alice', email: '[email protected]' },
  { id: '2', name: 'bob', email: '[email protected]' },
]

describe('filterByQuery', () => {
  it('대소문자 구분 없이 이름으로 검색한다', () => {
    // Act — 딱 한 번, 검사할 동작
    const result = filterByQuery(users, 'ALICE')

    // Assert — 기대와 비교
    expect(result).toEqual([users[0]])
  })

  it('이메일로도 검색된다', () => {
    expect(filterByQuery(users, 'test.io')).toEqual([users[1]])
  })
})

몇 가지 짚어볼게요.

  • describe 는 관련 테스트를 묶는 그룹입니다. 실행 결과가 “filterByQuery › 대소문자 구분 없이…“처럼 계층으로 읽혀서, 무엇의 어디가 깨졌는지 한눈에 보여요.
  • 재료(users)를 파일 위쪽에 두면 Arrange가 자연스럽게 공유됩니다. 두 번째 테스트처럼 준비·실행·검증이 한 줄에 압축돼도, 세 박자는 그대로 들어 있어요.
  • 대문자 'ALICE'로 검색한 건 우연이 아닙니다 — 테스트 하나가 “검색이 된다"와 “대소문자를 무시한다” 두 사실을 동시에 문서화하고 있죠. 테스트 이름이 곧 명세가 되는 순간입니다.

저장하는 순간 watch 모드가 돌면서 이렇게 보일 거예요.

bash
 ✓ src/lib/users.test.ts (2 tests)
 ✓ src/App.test.tsx (1 test)

 Test Files  2 passed (2)
      Tests  3 passed (3)

경계값을 노려라

버그는 대개 가장자리에 삽니다. 한가운데(평범한 검색어)는 개발하면서 눈으로 이미 확인했을 확률이 높아요. 하지만 가장자리는 아무도 안 눌러봤을 확률이 높죠. 함수를 앞에 두고 이 세 가지를 물어보세요.

  1. 입력이 “없다"면? — 빈 문자열, 공백만 있는 문자열
  2. 결과가 “없다"면? — 일치하는 항목 0개
  3. 입력이 “이상하다"면? — 아주 길거나, 특수문자이거나, 예상 밖의 타입
입력 공간을 나타낸 가로 스펙트럼 다이어그램 - 가운데의 평범한 검색어 영역은 개발 중 눈으로 확인되지만, 양쪽 가장자리의 빈 문자열·공백·결과 0개·특수문자 영역은 확인되지 않은 채 남아 버그가 숨는 곳으로 표시되어 있습니다
입력 공간을 나타낸 가로 스펙트럼 다이어그램 - 가운데의 평범한 검색어 영역은 개발 중 눈으로 확인되지만, 양쪽 가장자리의 빈 문자열·공백·결과 0개·특수문자 영역은 확인되지 않은 채 남아 버그가 숨는 곳으로 표시되어 있습니다

filterByQuery에 1번과 2번을 적용하면 테스트 두 개가 나옵니다.

ts
it('빈 검색어(공백 포함)는 전체를 반환한다', () => {
  expect(filterByQuery(users, '   ')).toHaveLength(2)
})

it('일치하는 항목이 없으면 빈 배열', () => {
  expect(filterByQuery(users, 'zzz')).toEqual([])
})

toHaveLength(2)는 “배열 길이가 2"를 확인하는 매처입니다. 전체 반환이 목적이니 내용까지 비교할 필요 없이 길이면 충분해요 — 매처를 목적에 맞게 고르는 것도 요령입니다.

일부러 부숴봅시다

경계 테스트가 정말 일하는지 확인하는 가장 좋은 방법은 코드를 일부러 망가뜨려보는 것입니다. users.ts에서 trim()을 지워보세요.

ts
const k = query.toLowerCase()   // trim() 삭제!

저장하는 순간 watch 모드가 빨간불을 띄웁니다.

bash
 FAIL  src/lib/users.test.ts
   × filterByQuery › 빈 검색어(공백 포함)는 전체를 반환한다
     AssertionError: expected [] to have a length of 2 but got +0

실패 메시지를 읽는 법: 첫 줄이 어느 테스트인지(filterByQuery › 빈 검색어…), 그 아래가 무엇이 어긋났는지(길이 2를 기대했는데 0)를 말해줍니다. 공백만 있는 검색어가 trim 없이는 " " 그대로 남아서, 아무도 매칭이 안 된 거죠. trim()을 되돌리면 다시 초록불 — 이제 이 테스트는 미래의 누군가(대개 미래의 나)가 같은 실수를 하는 순간 즉시 잡아냅니다.

이렇게 “고장 → 빨간불 확인 → 복구"를 한 번 돌려보는 습관은, 테스트가 장식이 아니라 진짜 경보기인지 확인하는 가장 값싼 방법이에요.


초록불을 만드는 감각 몇 가지

테스트 이름은 문장으로. it('works')는 실패했을 때 아무것도 말해주지 않습니다. it('빈 검색어는 전체를 반환한다')는 실패 리포트가 곧 버그 리포트가 되죠.

toBetoEqual은 다릅니다. toBe는 “같은 것인가”(참조 비교), toEqual은 “내용이 같은가”(값 비교)예요. 숫자·문자열은 toBe로 충분하지만, 배열과 객체는 내용이 같아도 서로 다른 존재라 toBe가 실패합니다.

ts
expect([1, 2]).toEqual([1, 2])   // 통과 — 내용이 같다
expect([1, 2]).toBe([1, 2])      // 실패! — 서로 다른 배열

한 번쯤 데여봐야 어른이 됩니다. 저는 이걸로 30분을 날렸으니, 여러분은 지금 3분으로 끝내신 거예요.

하나의 it에는 하나의 관심사. “검색도 되고 정렬도 되고 페이징도 된다"를 한 테스트에 넣으면, 실패했을 때 셋 중 뭐가 깨졌는지부터 다시 조사해야 합니다. 쪼개두면 리포트가 대신 말해줍니다.


한 장 요약

바쁜 분들을 위해, 오늘의 전부를 다섯 줄로 접어두면:

  • 단위 테스트 = 가장 작은 단위(대개 함수 하나)를 격리해 검증. 빠르고, 실패 지점이 정확하고, 그 자체가 명세
  • 시작은 순수 함수부터 — 같은 입력이면 같은 출력이라 준비 없이 넣고·받고·비교하면 끝
  • 테스트는 AAA 세 박자로: 준비(Arrange) → 실행(Act) → 검증(Assert)
  • 버그는 가장자리에 사니 경계값을 노릴 것 — “입력이 없다면? 결과가 없다면? 입력이 이상하다면?”
  • 다 썼으면 일부러 부숴서 빨간불을 확인할 것 — 안 울리는 경보기는 장식이니까

가장 단단한 층을 깔았습니다

세 줄짜리 함수에 테스트 네 개 — 과해 보일 수 있지만, 이 함수는 앞으로 검색 UI·API 연동·E2E까지 계속 재등장하는 주인공입니다. 주인공의 기초 체력은 이렇게 다져두는 거예요.

순수 함수라는 가장 쉬운 상대로 폼을 만들었으니, 다음은 현실의 복병들입니다. 기다려주지 않는 시간, 실패하는 요청, 던져지는 에러 — 비동기 테스트로 갑니다.

레벨업: 순수 함수를 AAA 패턴과 경계값으로 단단하게 검증할 수 있습니다.

다음 편: 비동기·타이머·에러를 테스트하는 법

낯선 용어가 있었다면 — 용어집에 전부 한 줄씩 정리돼 있어요.