본문으로 건너뛰기

버그 제보

이상한 점을 알려 주시면 고칠게요. 지금 보고 있는 페이지와 브라우저 정보가 함께 담겨요.

페이지:

Frontend

프론트엔드 테스트의 종류와 도구

작성 2026. 10. 21.추가
목차15개 항목
출처와 참고

이현호의 「테스트를 처음 공부하는 프론트엔드 개발자의 개념 다지기」(2022.06)와 김정환의 「자스민으로 프론트엔드 테스트 코드 작성하기」(2017.03)를 바탕으로 재구성한 노트다. 테스트 작성 원칙은 Yoni Goldberg의 「JavaScript Testing Best Practices」에서, 폴더 역할별 테스트 단위는 김승규의 「내가 생각하는 프론트엔드 테스트 단위」(2023.06)에서 뽑았다. 테스트 우선순위, 전략의 모양, 목적별 테스트 종류는 web.dev의 「Learn Testing」 강좌로 보강했다. 「실용적인 테스트 피라미드」의 내용을 더해 보강했다. 테스트 대역은 「Test Double」, 「Test Doubles - Fakes, Mocks and Stubs」, 「Best Practices for Spies, Stubs and Mocks in Sinon.js」, 「testdouble.js vs. sinon.js」을 바탕으로 보강했다. Testing Library 쿼리 접두어 표는 「Frontend_TIL: Jest UI testing」을 바탕으로 보강했다.

프론트엔드 테스트란

화면을 띄워 눈으로 확인하고, 버튼을 눌러 보고, console.log로 값을 찍는 것도 테스트다. 문제는 매번 손으로 반복해야 한다는 것이다. 기능이 늘수록 수동 확인은 감당이 안 되고, 확인이 두려워 리팩터링을 미루면 기술 부채가 쌓인다. 테스트 코드는 이 확인을 자동화해 다음을 얻는다.

  • 겁 없이 리팩터링할 수 있다.
  • 요구 사항을 만족하는지 계속 검사한다.
  • 테스트가 곧 동작 명세라 코드를 읽고 협업하기 쉬워진다.

모든 코드를 테스트할 필요는 없다. 작성 비용이 들기 때문에 깨지면 아픈 곳부터 테스트한다. 테스트는 소프트웨어를 만들기 위한 수단이지 목적이 아니다.

무엇부터 테스트하나

작은 단위 테스트를 잔뜩 써서 커버리지를 올리기는 쉽다. 하지만 그것이 프로젝트의 위험을 줄인다는 보장은 없다. 서랍 하나를 따로 열고 닫는 테스트가 통과해도, 옆 서랍 손잡이에 걸려 안 열리는 문제는 둘을 함께 놓아야 보인다.

우선순위대상이유
1앱·사이트·라이브러리의 핵심 사용 사례사용자 경험을 떠받치는 부분이 깨지면 가장 아프다
2가장 많은 정보를 주는 코드: 깨지기 쉬운 곳, 레거시, 엉망으로 짠 곳테스트로 동작을 먼저 고정해 두면 리팩터링하거나 걷어 낼 때까지 버틴다
  • 테스트마다 목표를 하나 분명히 둔다. 모든 것을 확인하는 거대한 테스트는 관리하기 어렵다.
  • 테스트가 대상 코드와 깔끔하게 상호작용하지 못하면 그 코드의 추상화가 약하다는 신호다.
  • 무엇이 중요한지는 제품이 정한다. 알고리즘이 핵심인 시스템은 정교한 단위 테스트가, 터치 입력이 많은 도구는 시각·E2E 테스트가 중심이 된다.

테스트의 층

층확인하는 것실행 비용대표 도구
정적 검사타입 오류, 잘못된 패턴, 컨벤션거의 없음 (저장할 때마다)TypeScript, ESLint, Biome
단위 테스트함수, 커스텀 훅, 컴포넌트 하나낮음Vitest, Jest
통합 테스트여러 모듈이 함께 동작하는가. 상태 변화에 따른 UI, UI와 API의 상호작용중간Testing Library + Vitest/Jest, MSW
E2E 테스트실제 브라우저에서 사용자 흐름 전체높음 (느리고 불안정할 수 있음)Playwright, Cypress

아래 층일수록 빠르고 많이 쓰고, 위 층일수록 느리지만 실제 사용자 경험에 가깝다. 프론트엔드에서는 통합 테스트가 비용 대비 확신을 가장 많이 준다는 「테스팅 트로피」 관점이 널리 쓰인다.

단위와 통합의 경계: 협력 객체를 진짜로 쓰나

방식단위 테스트에서통합 테스트에서
진짜를 쓴다Sociable: 자식 컴포넌트까지 렌더링Broad: 의존 모듈을 모두 연결
대역으로 바꾼다Solitary: 자식 컴포넌트를 목으로 대체Narrow: 외부 API를 가짜 서버로 대체

단위·컴포넌트·통합·E2E라는 이름에는 엄격한 정의가 없다. 「컴포넌트」만 해도 React 컴포넌트일 수도, 의존성이 있는 클래스일 수도 있다. 팀이 자기 코드베이스에 맞게 뜻을 정한다. 컴포넌트의 의존성을 대역으로 바꾸는 실제 기법은 의존성이 있는 컴포넌트 테스트하기에 있다.

테스트 전략의 모양

층마다 테스트를 얼마나 둘지는 도형으로 비유한다. 어느 모양이든 아래는 빠르고 많게, 위는 느리지만 넓게라는 원리는 같고, 차이는 어느 층에 무게를 두느냐다.

모양무게 중심언제 맞나
피라미드(pyramid)단위 테스트를 가장 많이, E2E를 가장 적게로직이 많은 라이브러리·백엔드
트로피(trophy)정적 검사를 바닥에 깔고 통합 테스트를 가장 두껍게컴포넌트와 상태가 얽힌 프론트엔드
다이아몬드(diamond)·벌집(honeycomb)가운데(통합) 층이 가장 넓다여러 서비스가 맞물리는 시스템, 마이크로서비스
아이스크림콘(ice cream cone)수동·E2E가 가장 많고 단위가 적다피해야 할 안티패턴. 느리고 깨지기 쉽다

많은 모양에서 꼭대기는 수동 테스트다. 자동화하기에 너무 복잡한 상호작용이 남기 때문이다.

목적별 테스트

모양은 범위라는 한 차원만 그린다. 아래 테스트는 범위가 아니라 목적·기법으로 붙은 이름이라 어느 층에서든 쓸 수 있다. 예를 들어 스모크 테스트는 단위 테스트일 수도 E2E일 수도 있고, 시각 테스트는 컴포넌트 하나에도 사이트 전체에도 건다. 그래서 기능 하나에 여러 종류의 테스트가 필요할 때가 많다.

종류확인하는 것메모
스모크(smoke)코드베이스가 쓸 만한 상태인가. 영향 범위가 넓은 곳(로그인·인증) 위주큰 프로젝트의 기본 test 스크립트 후보. 배포 직후에도 돌린다
회귀(regression)기존 기능이 그대로이고 고친 버그가 돌아오지 않았나버그를 재현하는 테스트를 먼저 쓰고 고친 뒤 남겨 두면 회귀 테스트가 된다(TDD)
시각(visual)스크린숏을 기준 이미지와 비교실제 브라우저가 필요하다. 컴포넌트를 여러 화면 폭으로 그리는 하네스를 둔다. 무관한 변경(새 기능, OS의 이모지 렌더링)에도 깨지므로 너무 엄격해지기 쉽다
API내가 제공하는 API, 또는 내가 쓰는 외부 API의 계약시스템을 통째로 붙이지 않고 경계만 본다. 실제 외부 서비스에 붙이면 불안정하다
계약(contract)서비스 사이의 요청·응답 형식이 서로의 기대와 맞나소비자 주도 계약 테스트(CDC): 소비자가 기대하는 응답을 테스트로 적어 공급자에게 넘기고, 공급자는 이를 CI에서 계속 돌린다. Pact가 대표 도구다. 양쪽을 통째로 띄우는 E2E 없이 경계가 깨지는 것을 잡는다
탐색적(exploratory)자동화가 놓친 품질 문제사람이 직접 써 보며 찾는다. 찾은 버그는 자동화 테스트로 옮겨 재발을 막는다
인수(acceptance)제품이 사용자 요구를 만족하는가사람이 하거나 접근성 기반 테스트처럼 기획서를 자동화한다
퍼즈(fuzz)무작위·잘못된 입력에도 죽지 않나무작위 입력을 「카오스 테스트」라 부르기도 한다. 생성 입력은 프로퍼티 기반 테스트로(모범 사례)
장애(failure)네트워크 끊김·의존 서비스 실패에 통제된 방식으로 반응하나MSW로 오류 응답, Playwright의 오프라인 모드
빌드빌드 산출물이 생기고 기대한 내용이 들어 있나정적 사이트·CMS 출력처럼 산출물이 복잡할 때
부하·스트레스(load·stress)운영 시스템이 큰 트래픽을 견디나시스템 설계 쪽 관심사다

같은 로직을 여러 층에서 반복 검증하지 않는다. 아래 층에서 이미 확인한 분기는 위 층에서 다시 쓰지 않고, 위 층은 아래 층이 볼 수 없는 연결만 본다. 고유한 가치를 더하지 않는 테스트는 지운다.

E2E는 테스트용 사용자로 로그인해 핵심 동작을 하고 최종 상태를 확인하는 식으로 짠다. 범위를 줄이려면 테스트 환경 플래그로 로그인을 건너뛰는 식으로 일부를 가짜로 둔다. 테스트가 데이터베이스에 남긴 데이터가 다음 테스트를 흔들지 않도록 레코드는 테스트마다 만들고, 정리 시점(테스트마다 또는 전체가 끝난 뒤)을 정한다(모범 사례).

폴더 역할별로 무엇을 테스트하나

층을 정했으면 "어느 파일을 어떤 단위로 테스트할까"가 남는다. 실용적인 기준은 폴더(모듈)마다 맡은 책임을 하나씩 검증하는 것이다. 정답이 정해진 분류는 아니고, 팀이 합의할 출발점이다.

폴더책임검증할 것방법
apis서버와 통신올바른 요청을 보내고 응답을 원하는 모양으로 돌려주나MSW로 응답을 고정하고 함수를 호출한다. axios·fetch를 직접 목으로 바꾸면 구현에 묶인다
components (공통 UI)받은 props대로 그리고 알린다렌더링되나, 클릭하면 받은 onClick을 부르나단위 테스트. 버튼이 어디에 쓰일지가 아니라 받은 콜백을 부르는 것까지가 책임이다
hooks재사용 로직입력·환경(예: 저장된 토큰 유무)에 따라 올바른 값을 돌려주나renderHook
context·스토어도메인 상태추가·수정·삭제 뒤 상태가 맞나, Provider 밖에서 쓰면 에러를 던지나Provider로 감싸 렌더링하거나 renderHook의 wrapper
pages기능 하나의 사용자 흐름유효한 입력일 때만 버튼이 켜지나, 성공하면 이동하고 실패하면 알리나, 로그인 상태면 리다이렉트하나페이지 통합 테스트(Testing Library + MSW + 메모리 라우터). 실제 브라우저가 필요한 핵심 흐름만 E2E
test('저장된 토큰이 있으면 로그인 상태다', () => {
  localStorage.setItem('token', 'abc')
  const { result } = renderHook(() => useAuth())
  expect(result.current).toEqual({ isLogin: true, token: 'abc' })
})

아래 폴더일수록 단위 테스트로 좁게, pages로 갈수록 통합 테스트로 넓게 검증한다. 같은 동작을 여러 층에서 반복해 확인하지 않는다.

테스트 대역(Test Double)

테스트 대상이 다른 객체에 의존할 때 그 자리를 대신하는 가짜 객체를 통틀어 테스트 대역이라 한다. 「목(mock)」이라고 뭉뚱그려 부르지만 역할이 다르다.

종류하는 일예
더미(dummy)자리만 채운다. 실제로 쓰이지 않는다필수 인자라서 넘기는 빈 객체
스텁(stub)정해진 값을 돌려준다항상 "World"를 반환하는 getName
스파이(spy)스텁처럼 동작하면서 호출 기록을 남긴다vi.fn(), jest.fn(), vi.spyOn()
페이크(fake)간단하지만 실제로 동작하는 구현. 운영에는 못 쓴다메모리 DB, 가짜 저장소
목(mock)기대한 호출이 일어났는지 행위를 검증한다"저장 메서드가 이 인자로 한 번 불렸다"

대역의 종류는 만드는 API가 아니라 쓰는 방식이 정한다. vi.fn() 하나가 반환값을 정하면 스텁, 호출을 단언하면 스파이 역할을 한다. 과거 Sinon.js는 spy·stub·mock을 따로 만들게 했는데, testdouble.js는 함수 하나(td.func())를 설정에 따라 쓰게 하고 전역 td.reset()으로 한 번에 복원하는 쪽을 택했다. Vitest·Jest의 vi.fn/jest.fn도 같은 방향이다.

언제, 어떤 대역을 쓰나

대역이 필요한 곳은 부작용(side effect)이 있는 의존성이다. 결과가 인자에만 달린 순수 함수(toLowerCase)는 진짜를 쓴다. 저장·네트워크·현재 시각·랜덤처럼 바깥 상태에 기대거나, 너무 느려 테스트를 늦추는 것만 바꾼다.

고르는 기준은 명령-조회 분리(CQS) 로 정리된다.

대상 메서드검증 방법대역
조회(query): 값을 돌려주고 상태를 바꾸지 않는다대상의 반환값을 단언한다스텁. 계산에 필요한 만큼만 값을 정한다(gradesFor → [8, 6, 10]이면 평균 8)
명령(command): 상태를 바꾸고 돌려주는 값이 없다의존성이 불렸는지를 단언한다스파이(또는 목). 메일을 실제로 보내지 않고 send가 이 인자로 한 번 불렸는지 본다
진짜를 쓰기엔 무겁지만 동작은 필요하다결과 상태를 단언한다페이크. 메모리 저장소로 DB 없이 서비스 통합 테스트를 돌린다. 프로토타입에서 DB 설계를 미루는 데도 쓴다

스텁은 이것 말고도 평소엔 안 타는 경로를 강제로 탄다(mockRejectedValue(new Error('oops'))로 에러 처리 검증). 비동기 콜백을 즉시 부르게 해 테스트를 동기로 만들 수도 있다.

네트워크는 함수를 목으로 바꾸기보다 요청 단에서 가로채는 편이 테스트를 덜 깨뜨린다. MSW(Mock Service Worker)는 같은 핸들러를 테스트, 브라우저 개발 환경, Storybook에서 함께 쓴다. 목을 많이 쓸수록 테스트가 비싸지는 이유와 줄이는 설계는 험블 객체 패턴에 정리했다.

쓸 때의 함정

함정내용대응
목의 과한 기대목은 기대를 먼저 선언하고(expects('save').once().withArgs(user)) 마지막에 verify()한다. 조건을 늘리기 쉬워 구현 세부에 묶인 깨지기 쉬운 테스트가 된다. 준비-실행-단언(AAA) 순서도 거스른다스텁 + 필요한 단언 한두 개로 쓴다. 단언이 여러 개 필요하면 테스트를 나눈다
부분 대역(partial double)vi.spyOn(obj, 'method')은 기본적으로 진짜 구현을 호출하면서 기록만 한다. "호출을 검증"하려는지 "통합 동작을 확인"하려는지가 섞인다의도를 하나로 정한다. 대체하려면 .mockImplementation()이나 .mockReturnValue()를 붙인다
복원 누락바꿔 끼운 메서드를 되돌리지 않으면 다음 테스트들이 연쇄로 실패한다. 테스트가 중간에 던지면 끝의 restore()까지 가지 못한다afterEach(() => vi.restoreAllMocks()) 또는 설정의 restoreMocks: true. 공유 스텁은 beforeEach에서 만든다
가짜 타이머와 비동기가짜 타이머가 켜져 있으면 setTimeout에 기대는 비동기 테스트가 시간 초과로 실패한다필요한 테스트에서만 vi.useFakeTimers(), 끝나면 vi.useRealTimers()

구현이 아니라 행위를 검증한다

컴포넌트 테스트에는 두 접근이 있다.

접근검증 대상리팩터링 시
구현 중심컴포넌트 내부 state, props, 자식 구조동작이 같아도 내부를 바꾸면 깨진다
행위 중심사용자가 보는 텍스트, 역할(role), 상호작용 결과사용자가 보는 동작이 같으면 통과한다

두 접근은 일반적인 용어로 불투명 상자(opaque box, 블랙박스)와 투명 상자(clear box, 화이트박스) 테스트다. 특별한 이유가 없으면 불투명 상자로 시작한다. 그래야 테스트가 실제 사용 방식을 반영하고, 내부를 고쳐도 회귀를 잡는다. 내부를 들여다봐야만 테스트할 수 있다면 의존성을 추상화해 주입받거나 상태를 알려 주는 콜백을 두어 코드를 불투명하게 바꾼다.

행위 중심이 표준이다. Testing Library는 state나 props에 접근하는 API를 아예 주지 않고, 사용자가 화면을 찾는 방식(텍스트, 역할, 레이블)으로만 요소를 찾게 한다. 역할과 접근 가능한 이름으로 요소를 찾는 방법은 접근성 기반 UI 테스트 자동화에 있다.

테스트 작성 원칙

모든 원칙의 출발점은 한 문장이다. 테스트는 단순하고 짧고 추상화 없이, 보자마자 의미를 알 수 있게 만든다. 이름에 대상·상황·기대를 담고, AAA로 나누고, 공개된 동작만 검증하고, 데이터는 현실적으로 테스트마다 따로 만든다. 원칙 전체와 커버리지·뮤테이션 테스트·CI 운영은 JavaScript 테스트 모범 사례에 모았다.

도구 고르기

용도기본 선택대안·비고
테스트 러너·단언Vitest (Vite 기반 프로젝트, ESM·TS 기본 지원)Jest. 기존 프로젝트와 React Native에서 여전히 많이 쓴다. API가 거의 같아 옮기기 쉽다
DOM 환경jsdom, happy-dom실제 브라우저가 필요하면 Vitest 브라우저 모드. 환경 고르는 기준은 테스트 실행 환경
컴포넌트Testing Library (React·Vue·Svelte 등)사용자 이벤트는 user-event로
네트워크MSW
E2EPlaywright (Chromium·Firefox·WebKit, 병렬 실행, 자동 대기)Cypress는 대화형 디버깅 UI가 강점. Selenium은 여러 언어·레거시 브라우저 그리드가 필요할 때
시각 회귀Playwright 스크린샷 비교, Storybook + Chromatic
기본 API역할
describe관련 테스트를 그룹으로 묶는다
test / it테스트 하나
expect(...).matcher()결과 검증. toBe, toEqual, toHaveBeenCalledTimes, toThrow 등
vi.fn() / jest.fn()호출 여부·인자·횟수를 기록하는 스파이 함수
render, screen.getByRoleTesting Library로 렌더링하고 역할로 요소를 찾는다
userEvent.setup() → await user.click()실제 사용자 동작(포커스 이동, 키 입력 순서)을 흉내 낸다. fireEvent는 user-event가 지원하지 않을 때만

Testing Library 쿼리는 접두어로 "없을 때 어떻게 하나" 를, 접미어로 무엇으로 찾는지(ByRole·ByLabelText·ByPlaceholderText·ByText·ByDisplayValue·ByAltText·ByTitle·ByTestId, 앞쪽일수록 우선)를 정한다.

접두어없으면여러 개면쓰는 때
getBy즉시 에러에러있어야 하는 요소
queryBynull에러없음을 확인할 때: expect(screen.queryByText('오류')).toBeNull()
findBy기다리다(기본 1초) 거부거부비동기로 나타나는 요소. await한다
getAllBy·queryAllBy·findAllBy위와 같음(queryAllBy는 [])배열목록

toHaveTextContent, toBeInTheDocument 같은 DOM 전용 matcher는 @testing-library/jest-dom을 설정해야 쓸 수 있다. 실행 옵션(한 파일 커버리지, 특정 테스트 빼기)은 테스트를 돌리는 시점에, 단언·스위트·수명 주기 훅의 개념은 테스트의 기본 요소에 있다.

역사

  • 2010년대 브라우저 테스트는 Jasmine(매처가 내장된 BDD 스타일 프레임워크)을 Karma(실제 브라우저를 띄워 테스트를 돌리는 러너)로 실행하는 조합이 표준이었다. 화면 없이 돌리려고 PhantomJS를 썼지만 ES6를 지원하지 못해 헤드리스 Chrome으로 넘어갔다. Karma는 2023년에 지원 종료가 발표됐고, Angular도 기본 러너를 바꿨다.
  • Node 쪽은 러너인 Mocha에 단언 라이브러리(Chai)와 목 라이브러리(Sinon)를 따로 붙여 썼다. Jest가 러너·단언·목·스냅숏을 한 패키지로 묶으며 주류가 됐고, 이후 Vite 생태계와 함께 Vitest가 그 자리를 이어받고 있다.
  • React 컴포넌트는 한때 Airbnb의 Enzyme으로 내부 state와 얕은 렌더링(shallow rendering)을 검사했다. 구현에 묶여 쉽게 깨지는 문제와 React 18 미지원으로 지금은 Testing Library로 대체됐다.

정리

  • 정적 검사 → 단위 → 통합 → E2E 순으로 비용과 실제감이 함께 오른다. 프론트엔드는 통합 테스트의 효율이 가장 좋다.
  • 핵심 사용 사례와 깨지기 쉬운 코드부터 테스트한다. 모양(피라미드·트로피)은 범위를, 스모크·회귀·시각은 목적을 말한다.
  • 「목」은 더미·스텁·스파이·페이크·목을 구분해서 쓴다. 네트워크는 MSW로 요청 단에서 가로챈다.
  • 사용자가 보는 행위를 검증한다. 내부 구현을 검사하는 테스트는 리팩터링을 막는다.
  • 테스트는 짧고 읽기 쉽게: 이름에 대상·상황·기대 결과, 본문은 AAA.
  • 기본 조합은 TypeScript·ESLint + Vitest + Testing Library + MSW + Playwright다.

관련 노트: JavaScript 테스트 모범 사례, 프론트엔드에서의 TDD, 험블 객체 패턴, 테스트를 돌리는 시점, 접근성 기반 UI 테스트 자동화, 소프트웨어 테스트, 테스트 실행 환경, 의존성이 있는 컴포넌트 테스트하기, 테스트의 기본 요소, 정적 분석, 백엔드 테스트, API 모킹: MSW와 json-server, Storybook

원본 기록14

댓글

GitHub 계정으로 댓글을 남길 수 있어요.