통합 테스트와 E2E 테스트 소개
단위·통합·E2E 테스트가 검증하는 연결 범위와 비용을 비교하고 테스트 피라미드에 맞춰 시나리오를 배치합니다.
앞 글 단위 테스트 (Jest 사용)에서는 함수나 작은 모듈을 따로 검사하는 단위 테스트를 Jest로 작성했습니다. 단위 테스트는 각 부품을 믿을 수 있게 만들고 버그를 빨리 찾게 해 주지만, 애플리케이션은 부품을 모아 놓은 것만으로 완성되지 않습니다. 여러 부품이 데이터를 주고받으며 하나의 기능을 이루고, 각 부품이 따로는 문제없어도 연결했을 때 문제가 생길 수 있습니다.
예를 들어 로그인 컴포넌트와 사용자 정보 표시 컴포넌트가 각각 단위 테스트를 통과했더라도, 로그인에 성공한 뒤 사용자 정보가 제대로 전달되어 화면에 보이는지는 단위 테스트만으로 알 수 없습니다. 이렇게 더 큰 흐름을 검증하는 것이 통합 테스트(integration test)와 E2E(end-to-end) 테스트입니다. 이 글에서는 두 테스트의 목적과 쓰는 상황, 주요 도구를 간단히 정리합니다.
먼저 몇 가지 용어를 정리해 둡니다. 의존성(dependency)은 어떤 코드가 동작하려고 호출하거나 데이터를 받아야 하는 다른 코드입니다. 스텁(stub)은 미리 정한 응답을 돌려주는 대역이고, 모의 객체(mock)는 응답과 함께 호출되었는지, 무엇을 넘겨받았는지도 검사할 수 있는 대역입니다. 도구에 따라 두 용어를 넓게 섞어 쓰기도 하므로, 이름보다 테스트에서 실제로 실행한 연결과 대역으로 바꾼 연결이 무엇인지 기록해 두는 것이 중요합니다.
테스트 범위 고르기
FLOWCHART · WEB PLATFORM CONTRACT
위험으로 고르는 테스트 범위
먼저 실패 영향과 원인 진단 비용을 기록하고, 검증 질문이 격리한 동작·협력자 계약·전체 사용자 여정 중 어디에 걸리는지에 따라 주 테스트 범위를 고른다.
세 범위는 상호 배타적 종류가 아니다. 같은 기능에 여러 범위를 조합할 수 있다. E2E의 전체 경로는 통제된 테스트 환경에서 실행하며 실제 운영 환경을 뜻하지 않는다.
위 그림은 테스트의 범위를 고르는 흐름입니다. 먼저 확인하려는 질문을 적고, 그 기능이 실패했을 때의 영향, 원인을 찾는 데 드는 비용, 코드가 얼마나 자주 바뀌는지를 평가합니다. 그다음 그 질문에 답하려면 무엇을 함께 실행해야 하는지 묻습니다. 따로 떼어 낸 규칙 하나면 충분하다면 단위 테스트를, 모듈과 API, 데이터베이스가 서로 맞물리는지 확인해야 한다면 통합 테스트를, 핵심 사용자 흐름 전체를 확인해야 한다면 E2E 테스트를 주된 범위로 고릅니다.
세 갈래는 차례로 실행하는 단계가 아니라 각각 따로 고르는 범위이며, 마지막에는 하나의 위험 포트폴리오로 모여 함께 배치됩니다. 빠른 하위 범위는 넓게 두고, 비싼 상위 범위는 실패했을 때 영향이 큰 경로에 집중합니다. 그림 아래의 설명처럼 세 범위는 서로 배타적인 종류가 아니어서 같은 기능을 여러 범위로 함께 검증할 수 있고, E2E의 전체 경로도 실제 운영 환경이 아니라 통제된 테스트 환경에서 실행합니다.
테스트 피라미드
이런 조합은 흔히 테스트 피라미드(test pyramid)로 설명합니다. 맨 아래의 단위 테스트는 함수나 메서드 같은 작은 규칙을 따로 검사하므로 빠르고, 그래서 많이 둘 수 있습니다. 가운데의 통합 테스트는 여러 모듈이 주고받는 데이터를 검사하며, 실제 데이터베이스까지 포함하는지처럼 연결하는 범위에 따라 비용이 달라집니다. 맨 위의 E2E 테스트는 브라우저에서 사용자 흐름을 시작해 시스템의 최종 결과까지 확인하므로, 준비하고 실행하고 실패 원인을 찾는 비용이 커서 중요한 흐름에만 둡니다.
피라미드는 아래로 갈수록 개수가 많고 위로 갈수록 적은 모양입니다. 빠른 검사를 넓게 깔고 비싼 검사를 핵심 위험에 두자는 출발점이지, 개수의 비율이나 속도의 순서를 정해 둔 법칙은 아닙니다. 화면과 상태의 연결이 중심인 앱이라면 통합 테스트의 비중이 더 클 수도 있습니다. 그림처럼 먼저 검증할 질문에 필요한 연결을 고르고, 실제 실행 시간과 실패 원인을 좁히는 비용을 보며 조합을 조정하면 됩니다.
통합 테스트
통합 테스트는 둘 이상의 모듈이나 컴포넌트가 함께 동작할 때의 상호작용과 데이터 흐름을 검증합니다. 단위 테스트로 확인한 기능들이 서로 연결되었을 때도 올바르게 동작하는지 보는 것입니다.
통합 테스트로 확인하는 것은 크게 세 가지입니다. 모듈끼리 데이터를 주고받는 방식이 서로 맞는지, 한 모듈의 출력이 다른 모듈의 입력으로 쓰일 때 데이터가 올바르게 전달되고 처리되는지, 데이터베이스나 외부 API 같은 의존성과 제대로 연동되는지입니다. 외부 시스템은 실제로 연결하기도 하고, 모의 객체로 바꿔 일부만 검증하기도 합니다.
단위 테스트는 작은 공개 동작을 떼어 내 검사합니다. 바깥 의존성이 있으면 모의 객체나 스텁으로 통제하지만, 순수한 계산 함수처럼 대역이 필요 없는 경우도 있습니다. 통합 테스트는 실제 컴포넌트를 서로 연결한 상태로 검사하며, 의존성도 실제의 것을 쓰거나 꼭 필요한 부분만 대역으로 바꿔 실제 환경에 가깝게 만듭니다.
통합 테스트를 쓰는 상황은 다양합니다. 로그인 폼 컴포넌트, 인증 로직, API 호출 서비스가 함께 동작해 로그인과 인증 토큰 발급이 되는지 확인할 때가 그렇고, 이때 API는 실제 서버 대신 목 API를 쓸 수 있습니다. 상품 선택 컴포넌트와 장바구니 상태 관리가 연결되어 상품이 담기고 총 가격이 바뀌는지, 데이터를 저장하는 함수가 개발용 데이터베이스에 데이터를 제대로 저장하는지 확인할 때도 씁니다.
통합 테스트 도구
통합 테스트는 특정 도구 하나로 정해져 있지 않고, 어디까지 검사할지에 따라 도구를 고릅니다. Jest로는 여러 모듈을 함께 불러와 검사하면서 jest.mock()으로 바깥 의존성의 일부만 대역으로 바꿀 수 있습니다. Testing Library는 React나 Vue 같은 프레임워크의 컴포넌트를 사용자 관점에서 DOM으로 다루며 검사하게 해 주고, MSW(Mock Service Worker)는 네트워크 요청을 가로채 모의 응답을 돌려줘 실제 백엔드 없이 프런트엔드의 네트워크 흐름을 검사하게 해 줍니다.
Testing Library는 사용자가 화면에서 요소를 찾는 방식에 가깝게 DOM을 찾도록 돕습니다. 버튼은 역할과 접근 가능한 이름으로, 입력란은 연결된 레이블로 찾는 방식을 먼저 씁니다. 다만 DOM에서 컴포넌트를 검사했다고 실제 브라우저의 배치나 터치, 서버 전체와의 연결까지 검증한 것은 아닙니다. 자세한 원칙은 Testing Library의 조회 문서에 있습니다.
MSW를 썼다면 화면에서 요청을 만들고 정해진 응답을 처리하는 흐름을 검사한 것입니다. 실제 서버가 그 모의 응답과 같은 응답을 돌려주는지, 서버가 데이터베이스에 제대로 쓰는지는 따로 검증해야 합니다. 사용법은 MSW 공식 문서에서 볼 수 있습니다.
작은 연결 직접 검증하기
앞 글의 Jest와 Babel 연습 설정을 그대로 씁니다. 다음 두 모듈은 장바구니의 금액 계산과 표시 문자열 만들기를 연결합니다. 입력은 숫자 가격과 양의 정수 수량이며, 세금, 배송비, 할인, 잘못된 입력 처리는 이 예제에서 다루지 않습니다.
// src/cart.js
export function cartTotal(items) {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}// src/cart-summary.js
import { cartTotal } from './cart';
export function cartSummary(items) {
const quantity = items.reduce((sum, item) => sum + item.quantity, 0);
return `${quantity}개 · ${cartTotal(items)}원`;
}// src/cart-summary.test.js
import { cartSummary } from './cart-summary';
test('실제 계산 결과와 총 수량을 표시 문자열에 전달한다', () => {
const items = [
{ price: 2000, quantity: 2 },
{ price: 12000, quantity: 1 },
];
expect(cartSummary(items)).toBe('3개 · 16000원');
});기대값은 2000 × 2 + 12000 × 1 = 16000, 수량은 2 + 1 = 3으로 손으로 계산해 정합니다. 이 테스트는 cartTotal을 대역으로 바꾸지 않았으므로 두 모듈의 실제 연결을 확인합니다. 만약 cartSummary가 총 수량 대신 상품 종류의 수인 items.length를 썼다면 2개 · 16000원이 나와 이 테스트가 실패합니다. 다만 이 테스트는 화면 표시나 클릭, 저장 API까지 확인하지는 않습니다.
E2E 테스트
E2E 테스트는 사용자의 입력부터 최종 결과까지, 테스트할 범위로 정한 시스템 전체가 연결되어 동작하는지 검증합니다. 예를 들어 브라우저, 테스트용 백엔드, 테스트용 데이터베이스를 연결하고 주문이 실제로 저장되는지 확인합니다. 결제 서비스처럼 직접 통제할 수 없는 외부 서비스는 테스트용 환경이나 대역을 쓸 수 있으며, 그렇게 바꾼 부분의 실제 연결까지 통과했다고 말하지는 않습니다.
브라우저 자동화로 화면을 검사하더라도 모든 API를 가로채 모의 응답만 줬다면, 실제 백엔드와 데이터베이스의 연동은 확인되지 않습니다. 테스트 이름에 E2E라고 붙이는 것보다, 어디부터 어디까지를 실제로 연결했는지 밝히는 것이 중요합니다. Playwright의 모범 사례 문서도 외부 의존성과 테스트 데이터를 통제하라고 안내합니다.
E2E 테스트의 목적과 단점
E2E 테스트는 회원 가입부터 로그인, 상품 검색, 장바구니 추가, 결제 완료까지 같은 핵심 흐름이 실제와 가까운 환경에서 문제없이 동작하는지 확인합니다. 테스트 범위에 포함한 프런트엔드, 백엔드, 데이터베이스가 함께 맞물려 결과를 내는지 보고, 사용자에게 배포하기 전에 중요한 흐름이 깨지지 않았는지 확인하는 데 씁니다. 다만 E2E 테스트 몇 개가 통과했다고 접근성, 성능, 보안까지 보장되는 것은 아닙니다.
단점도 분명합니다. 실제 브라우저를 띄우고 네트워크 요청을 보내고 화면을 그리므로 실행 시간이 가장 깁니다. 테스트를 작성하고 유지하고 실행 환경을 갖추는 데 많은 시간이 듭니다. 네트워크 지연, 외부 서비스의 일시적인 문제, 화면 변경 때문에 실패하지 않아야 할 테스트가 가끔 실패하는 불안정한(flaky) 테스트도 생기기 쉽습니다.
E2E 테스트 도구
E2E 테스트는 실제 브라우저를 자동으로 조작해 사용자의 동작을 흉내 내는 도구로 작성합니다. Cypress는 실제 브라우저에서 동작을 자동화하고 실행 과정을 눈으로 확인할 수 있게 해 줍니다. Microsoft가 만든 Playwright는 Chromium, Firefox, WebKit을 지원하고, 자바스크립트와 TypeScript뿐 아니라 Python 같은 다른 언어로도 테스트를 쓸 수 있습니다. Selenium은 WebDriver로 여러 언어와 브라우저에서 사용자 흐름을 자동화하는 오래된 도구입니다. 어느 도구를 고르든 테스트 데이터와 외부 요청, 기다리는 방식을 통제해야 안정적인 테스트가 됩니다.
같은 장바구니를 E2E로 확인한다면
다음은 앞의 두 함수를 쓰는 가상의 실습 앱을 기준으로 한 시나리오입니다. 이 교재 사이트에 그런 장바구니 서버가 있다는 뜻은 아닙니다.
| 단계 | 고정할 조건이나 동작 | 통과 기준 |
|---|---|---|
| 준비 | 다른 테스트와 겹치지 않는 테스트 계정, 빈 장바구니, 상품 A 2000원과 B 12000원, 테스트할 빌드 | 처음 수량과 금액이 0이고 상품 데이터가 같음 |
| 입력 | 브라우저에서 A를 두 개, B를 한 개 담음 | 장바구니 요약에 3개 · 16000원이 보임 |
| 저장 연결 | 실제 테스트 백엔드와 데이터베이스를 쓰고, 저장이 끝난 뒤 새로고침 | 저장한 수량과 금액이 그대로 복원됨 |
| 마무리 | 이번 테스트가 만든 데이터와 로그인 상태를 정리 | 다른 테스트를 혼자 실행해도 같은 초기 조건을 얻음 |
Playwright로 이 시나리오의 입력과 저장 연결 단계를 쓰면 다음과 같은 모양이 됩니다. 버튼 이름과 주소는 가상의 앱을 가정한 것입니다.
// tests/cart.spec.js (가상의 실습 앱 기준)
import { test, expect } from '@playwright/test';
test('상품을 담으면 요약이 바뀌고 새로고침 뒤에도 남는다', async ({ page }) => {
await page.goto('http://localhost:5173/');
await page.getByRole('button', { name: '상품 A 담기' }).click();
await page.getByRole('button', { name: '상품 A 담기' }).click();
await page.getByRole('button', { name: '상품 B 담기' }).click();
const summary = page.getByRole('status', { name: '장바구니 요약' });
await expect(summary).toHaveText('3개 · 16000원'); // 보일 때까지 다시 확인
await page.reload();
await expect(summary).toHaveText('3개 · 16000원'); // 저장된 값이 복원되는지 확인
});버튼을 눌렀다는 사실이 아니라, 최종 요약과 새로고침 뒤의 복원 상태를 단언합니다. expect(locator).toHaveText()는 조건을 만족할 때까지 정해진 시간 동안 다시 확인하므로, 화면에서 관찰할 수 있는 상태를 기다리는 데 알맞습니다. 반면 무조건 1초씩 기다리는 코드는 서버가 느려지면 쉽게 실패합니다. 또 Playwright가 테스트마다 새 브라우저 컨텍스트를 만들어 쿠키와 스토리지를 분리해 줘도, 여러 테스트가 함께 쓰는 서버 데이터베이스의 장바구니까지 저절로 비워 주지는 않으므로 준비와 마무리 단계가 필요합니다.
더 깊이: Playwright는 무엇을 기다려 줄까
E2E 테스트가 가끔만 실패하는 흔한 이유는 화면이 준비되기 전에 조작하거나 확인하는 것입니다. Playwright는 이를 줄이려고 조작 전에 실행 가능성 검사(actionability check)를 합니다. 예를 들어 locator.click()은 요소가 화면에 보이는지, 애니메이션이 끝나 위치가 멈췄는지, 다른 요소에 가려지지 않아 클릭을 받을 수 있는지, 비활성 상태가 아닌지를 확인한 뒤에 누릅니다. 정해진 시간 안에 검사를 통과하지 못하면 TimeoutError로 실패하므로, 실패 메시지에서 어느 조건이 맞지 않았는지 읽을 수 있습니다.
단언도 두 종류로 나뉩니다. await expect(locator).toHaveText('3개 · 16000원')처럼 로케이터를 넘기는 웹 우선 단언(web-first assertion)은 조건이 맞을 때까지 요소를 다시 찾아 확인하며, 기본으로 5초까지 기다립니다. 반면 expect(await locator.textContent()).toBe('3개 · 16000원')은 글자를 한 번 읽어 둔 뒤 비교하므로, 그 순간 화면이 아직 바뀌지 않았다면 다시 확인하지 않고 바로 실패합니다.
모양은 비슷해도 둘의 안정성은 크게 다르므로, 화면 상태를 확인할 때는 로케이터를 단언에 직접 넘기는 형태를 씁니다. 기다리는 조건과 기본 시간은 Playwright의 실행 가능성 문서와 단언 문서에 정리되어 있습니다.
정리
- 단위 테스트를 통과한 부품도 연결하면 문제가 생길 수 있으므로, 더 큰 흐름은 통합 테스트와 E2E 테스트로 검증합니다.
- 테스트 범위는 확인할 질문에 무엇을 함께 실행해야 하는지로 고르고, 실패 영향과 진단 비용을 보며 단위, 통합, E2E를 조합합니다.
- 테스트 피라미드는 빠른 단위 테스트를 넓게, 비싼 E2E 테스트를 핵심 흐름에 두자는 출발점이며 정해진 비율은 아닙니다.
- 통합 테스트는 실제 모듈을 연결해 데이터가 올바르게 오가는지 확인하고, 대역으로 바꾼 연결은 검증하지 못합니다.
- E2E 테스트는 브라우저부터 서버와 데이터베이스까지 정한 범위를 연결해 사용자 흐름을 확인하며, 느리고 불안정해지기 쉽습니다.
- E2E 테스트는 동작이 아니라 관찰할 수 있는 최종 상태를 단언하고, 테스트 데이터를 준비하고 정리해 서로 영향을 주지 않게 합니다.
연습 문제
1. 범위마다 찾을 수 있는 결함
장바구니 금액이 틀렸다고 가정합니다. ① cartTotal만 호출, ② 두 모듈을 실제로 연결, ③ 브라우저에서 상품을 담되 API는 모의 응답, ④ 실제 테스트 서버와 데이터베이스로 담고 새로고침의 네 가지 검사를 각각 실행했습니다. 각 검사가 찾을 수 있는 결함과 놓치는 결함을 하나씩 적어 보세요. ③은 통과하고 ④만 실패했다면 다음에 무엇을 확인해야 할까요?
해설 보기
①은 곱셈과 합계 규칙을 확인하지만, 결과가 표시 문자열로 전달되는 과정은 놓칩니다. ②는 계산과 문자열의 연결을 확인하지만 브라우저 입력은 놓칩니다. ③은 화면 입력과 정해진 응답의 처리를 확인하지만 실제 API와 데이터베이스 저장은 놓칩니다. ④는 브라우저에서 서버, 저장, 복원까지 정한 경로를 확인하지만, 테스트에 넣지 않은 외부 서비스나 다른 사용자 흐름은 여전히 남습니다.
③이 통과하고 ④만 실패했다고 서버 버그로 단정하지는 않습니다. 실제 요청의 본문과 응답, 테스트 데이터와 계정, 저장이 끝나는 시점, 데이터베이스의 읽기와 쓰기 경로를 차례로 비교합니다. 먼저 준비 조건이 같은지 확인하고, 저장 실패와 복원 실패를 나눠 보면 원인의 범위를 좁힐 수 있습니다.
2. 어느 테스트가 실패할까
cartSummary가 실수로 총 수량 대신 items.length를 쓰도록 바뀌었습니다. cartTotal만 검사하는 단위 테스트와, 앞의 cartSummary 통합 테스트는 각각 어떻게 될까요?
해설 보기
cartTotal 단위 테스트는 통과하고, cartSummary 통합 테스트는 실패합니다. cartTotal은 바뀌지 않았으므로 금액 계산은 여전히 맞습니다.
하지만 cartSummary는 상품 종류의 수 2를 수량으로 써서 2개 · 16000원을 돌려주므로, 기대값 3개 · 16000원과 맞지 않아 실패합니다. 부품을 이어 붙이는 코드의 실수는 부품 하나만 검사하는 테스트로는 드러나지 않습니다.
3. 대역으로 바꾸면
cartSummary 테스트 파일의 맨 위에 jest.mock('./cart', () => ({ cartTotal: () => 16000 }));를 넣었습니다. 그 뒤 cartTotal에 버그가 생겨 수량을 곱하지 않게 되었다면, 이 테스트는 통과할까요? 이 테스트는 이제 무엇을 검증하는 것일까요?
해설 보기
통과합니다. cartTotal이 항상 16000을 돌려주는 대역으로 바뀌었으므로, 실제 cartTotal의 버그가 결과에 드러나지 않습니다.
이제 이 테스트는 cartSummary가 수량을 올바르게 더하고, 받은 금액으로 문자열을 만드는지만 검증합니다. 두 모듈의 실제 연결은 더 이상 확인하지 않으므로, 대역을 쓸 때는 무엇을 바꿨는지 기록하고 실제 연결을 확인하는 테스트를 따로 남겨 둬야 합니다.
다음 글 크로스 브라우저 테스팅에서는 같은 페이지가 여러 브라우저와 기기에서도 똑같이 동작하는지 확인하는 방법을 살펴봅니다.