croissant_code
테스트 코드 종류 본문
테스트 코드 종류 정리
Unit Test, Integration Test, E2E Test는 언제 무엇을 써야 할까?
테스트 코드를 공부하다 보면 가장 먼저 마주치는 질문이 있습니다.
“테스트에도 종류가 여러 개 있던데, 도대체 뭐가 다른 걸까?”
“Unit Test만 하면 되는 걸까?”
“Integration Test랑 E2E Test는 왜 필요한 걸까?”
처음에는 이름도 비슷하고, 전부 다 “코드가 잘 동작하는지 확인하는 것”처럼 보입니다.
하지만 실제로는 검증하는 범위가 다르고, 잡아내는 버그도 다릅니다.
이번 글에서는 대표적인 세 가지 테스트인
- Unit Test
- Integration Test
- E2E Test
가 무엇인지, 왜 사용하는지, 언제 써야 하는지, 그리고 코드 예시까지 함께 정리해보겠습니다.
1. 왜 테스트 종류를 나눌까?
테스트는 전부 같은 역할을 하지 않습니다.
어떤 테스트는 작은 함수 하나의 계산 로직을 검증하고,
어떤 테스트는 API와 DB가 함께 잘 연결되는지를 검증하며,
어떤 테스트는 사용자가 실제 화면에서 서비스를 끝까지 이용할 수 있는지를 검증합니다.
즉, 테스트 종류를 나누는 이유는 단순합니다.
- 검증 대상이 다르기 때문
- 발견하는 문제 유형이 다르기 때문
- 실행 속도와 비용이 다르기 때문
- 목적에 따라 적합한 테스트가 다르기 때문
작은 계산 로직을 검증하는 데 브라우저를 띄울 필요는 없습니다.
반대로 “로그인 후 주문 완료까지”를 확인하려면 함수 하나만 테스트해서는 부족합니다.
2. Unit Test란?
가장 작은 단위의 로직을 검증하는 테스트
Unit Test는 보통 함수, 메서드, 클래스 하나처럼 작은 단위를 테스트합니다.
핵심은 하나의 로직이 의도대로 동작하는지를 빠르게 확인하는 것입니다.
예를 들어 쇼핑몰에서 회원 등급에 따라 할인 가격을 계산하는 함수가 있다고 해보겠습니다.
예시 코드: 할인 금액 계산 함수
pricing.py
def calculate_final_price(base_price: int, member_grade: str) -> int:
discounts = {
"regular": 0.0,
"silver": 0.05,
"gold": 0.10,
}
if base_price <= 0:
raise ValueError("base_price must be positive")
if member_grade not in discounts:
raise ValueError("invalid member grade")
discount_rate = discounts[member_grade]
return int(base_price * (1 - discount_rate))
이 함수는 외부 DB도 없고, API 호출도 없고, 순수하게 입력값에 따라 결과를 계산합니다.
이런 로직은 Unit Test에 아주 적합합니다.
Unit Test 코드
test_pricing.py
import pytest
from pricing import calculate_final_price
@pytest.mark.parametrize(
"base_price, member_grade, expected",
[
(10000, "regular", 10000),
(10000, "silver", 9500),
(10000, "gold", 9000),
],
)
def test_calculate_final_price(base_price, member_grade, expected):
assert calculate_final_price(base_price, member_grade) == expected
def test_calculate_final_price_raises_for_invalid_price():
with pytest.raises(ValueError, match="base_price must be positive"):
calculate_final_price(0, "regular")
def test_calculate_final_price_raises_for_invalid_grade():
with pytest.raises(ValueError, match="invalid member grade"):
calculate_final_price(10000, "vip")
왜 Unit Test를 사용할까?
Unit Test를 쓰는 이유는 매우 분명합니다.
첫째, 빠릅니다.
함수 하나만 검증하므로 실행이 매우 빠릅니다.
둘째, 문제 위치를 찾기 쉽습니다.
이 테스트가 실패하면 할인 계산 함수 안에서 문제를 찾으면 됩니다.
셋째, 리팩토링에 강합니다.
함수 내부 구현을 바꿔도 결과만 같으면 테스트는 계속 통과합니다.
넷째, 비즈니스 규칙을 문서처럼 보여줍니다.
위 테스트만 봐도 regular는 할인 없음, silver는 5%, gold는 10%라는 정책이 바로 보입니다.
Unit Test의 한계
하지만 Unit Test만으로는 부족합니다.
왜냐하면 실제 서비스는 함수 하나로만 동작하지 않기 때문입니다.
예를 들어 이런 것은 Unit Test만으로 충분히 보장하기 어렵습니다.
- API 요청이 실제로 잘 들어오는가
- 요청 데이터 검증이 제대로 되는가
- DB 저장이 정상적으로 되는가
- 여러 계층이 연결되었을 때 문제가 없는가
이 지점을 보기 위해 Integration Test가 필요합니다.
3. Integration Test란?
여러 구성요소가 함께 잘 동작하는지 보는 테스트
Integration Test는 둘 이상의 컴포넌트가 연결될 때 문제가 없는지를 검증하는 테스트입니다.
즉, 개별 함수는 각각 멀쩡해 보여도
막상 API → 서비스 → 저장소 → 상태 변경 흐름을 연결하면 문제가 생길 수 있습니다.
예시 상황
간단한 주문 생성 API를 생각해보겠습니다.
기능은 다음과 같습니다.
- 사용자가 상품 ID와 수량을 보냄
- 서버가 상품 정보를 조회함
- 재고를 확인함
- 주문을 생성함
- 남은 재고를 줄임
- 결과를 응답함
이건 함수 하나만 테스트해서는 충분하지 않습니다.
실제 요청과 응답 흐름까지 함께 봐야 합니다.
예시 코드: FastAPI 주문 API
app.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI()
PRODUCTS = {
1: {"name": "Keyboard", "price": 50000, "stock": 3},
2: {"name": "Mouse", "price": 30000, "stock": 5},
}
ORDERS = []
class OrderCreate(BaseModel):
product_id: int
quantity: int
@app.post("/orders")
def create_order(order: OrderCreate):
product = PRODUCTS.get(order.product_id)
if product is None:
raise HTTPException(status_code=404, detail="Product not found")
if order.quantity <= 0:
raise HTTPException(status_code=400, detail="Quantity must be positive")
if product["stock"] < order.quantity:
raise HTTPException(status_code=400, detail="Not enough stock")
product["stock"] -= order.quantity
saved_order = {
"id": len(ORDERS) + 1,
"product_id": order.product_id,
"quantity": order.quantity,
"total_price": product["price"] * order.quantity,
}
ORDERS.append(saved_order)
return saved_order
Integration Test 코드
test_orders_api.py
from fastapi.testclient import TestClient
from app import app, PRODUCTS, ORDERS
client = TestClient(app)
def setup_function():
PRODUCTS.clear()
PRODUCTS.update({
1: {"name": "Keyboard", "price": 50000, "stock": 3},
2: {"name": "Mouse", "price": 30000, "stock": 5},
})
ORDERS.clear()
def test_create_order_success():
response = client.post(
"/orders",
json={"product_id": 1, "quantity": 2},
)
assert response.status_code == 200
assert response.json() == {
"id": 1,
"product_id": 1,
"quantity": 2,
"total_price": 100000,
}
assert PRODUCTS[1]["stock"] == 1
assert len(ORDERS) == 1
def test_create_order_fails_when_stock_not_enough():
response = client.post(
"/orders",
json={"product_id": 1, "quantity": 10},
)
assert response.status_code == 400
assert response.json()["detail"] == "Not enough stock"
assert PRODUCTS[1]["stock"] == 3
assert len(ORDERS) == 0
def test_create_order_fails_when_product_not_found():
response = client.post(
"/orders",
json={"product_id": 999, "quantity": 1},
)
assert response.status_code == 404
assert response.json()["detail"] == "Product not found"
왜 이 테스트가 Integration Test일까?
이 테스트는 단순히 함수 하나만 검증하지 않습니다.
실제로 다음을 함께 검증합니다.
- HTTP 요청이 정상적으로 들어가는가
- Pydantic이 요청 데이터를 파싱하는가
- FastAPI 엔드포인트가 올바르게 실행되는가
- 재고 체크 로직이 적용되는가
- 상태가 실제로 변경되는가
- 응답 형식이 기대한 대로 나가는가
즉, 여러 구성요소가 연결된 흐름을 한 번에 검증하기 때문에 Integration Test입니다.
왜 Integration Test를 사용할까?
Integration Test를 쓰는 이유는 실무의 많은 버그가 연결 지점에서 발생하기 때문입니다.
함수 자체는 맞는데도 다음 같은 문제가 생길 수 있습니다.
- 서비스가 저장소에 잘못된 값을 넘김
- API 응답 형식이 프론트 기대와 다름
- 상태 변경이 누락됨
- 예외가 실제 요청 흐름에서 다르게 처리됨
Integration Test는 이런 문제를 잡는 데 강합니다.
4. E2E Test란?
사용자의 실제 시나리오 전체를 검증하는 테스트
E2E는 End-to-End의 줄임말입니다.
말 그대로 처음부터 끝까지 전체 사용자 흐름을 검증하는 테스트입니다.
예를 들어 쇼핑몰에서는 이런 흐름이 있을 수 있습니다.
- 로그인
- 상품 상세 페이지 이동
- 장바구니 담기
- 결제하기
- 주문 완료 페이지 확인
이 전체가 진짜 되는지 확인하는 것이 E2E Test입니다.
E2E 예시 코드: Playwright
프론트엔드가 있다고 가정하고 Playwright로 테스트를 작성해보겠습니다.
order.e2e.spec.ts
import { test, expect } from "@playwright/test";
test("사용자가 상품을 주문할 수 있다", async ({ page }) => {
await page.goto("http://localhost:3000");
await page.getByRole("link", { name: "로그인" }).click();
await page.getByLabel("이메일").fill("user@example.com");
await page.getByLabel("비밀번호").fill("password1234");
await page.getByRole("button", { name: "로그인" }).click();
await page.getByRole("link", { name: "Keyboard" }).click();
await page.getByRole("button", { name: "장바구니 담기" }).click();
await page.getByRole("link", { name: "장바구니" }).click();
await page.getByRole("button", { name: "주문하기" }).click();
await expect(page.getByText("주문이 완료되었습니다")).toBeVisible();
await expect(page.getByText("Keyboard")).toBeVisible();
});
왜 E2E Test를 사용할까?
E2E Test는 사용자가 실제로 겪는 문제를 발견하기 위해 사용합니다.
예를 들어 이런 문제는 Unit Test나 Integration Test로는 놓칠 수 있습니다.
- 로그인 버튼 클릭 후 화면 이동이 안 됨
- 프론트가 잘못된 API를 호출함
- 응답은 오는데 화면 렌더링이 깨짐
- 결제 후 완료 페이지가 정상적으로 표시되지 않음
즉, E2E는 “코드가 맞는가?”보다
**“사용자가 실제로 성공할 수 있는가?”**를 검증하는 테스트입니다.
E2E Test의 한계
E2E는 가장 현실적이지만 가장 비용이 큽니다.
- 느립니다
- 환경에 영향을 많이 받습니다
- 유지보수가 어렵습니다
- 실패 시 원인 분석 범위가 넓습니다
그래서 보통 실무에서는 모든 기능을 E2E로 만들지 않고,
정말 중요한 핵심 사용자 흐름만 E2E로 관리합니다.
5. 세 가지 테스트를 코드 기준으로 다시 비교해보면
Unit Test
검증 대상은 작은 함수입니다.
예를 들어 할인 계산, 배송비 계산, 포인트 적립 규칙 같은 로직입니다.
위에서 본 calculate_final_price() 테스트가 대표적인 예입니다.
Integration Test
검증 대상은 여러 계층이 연결된 흐름입니다.
예를 들어 API 요청, 데이터 검증, 비즈니스 로직, 상태 변경, 저장까지 한 번에 확인합니다.
위에서 본 /orders API 테스트가 여기에 해당합니다.
E2E Test
검증 대상은 실제 사용자 행동 전체입니다.
예를 들어 로그인부터 결제 완료 화면 확인까지입니다.
위에서 본 Playwright 테스트가 대표적인 예입니다.
6. 언제 무엇을 써야 할까?
기준은 생각보다 단순합니다.
Unit Test를 써야 할 때
입력과 출력이 명확하고, 외부 의존성이 적은 로직일 때 좋습니다.
예:
- 가격 계산
- 할인율 계산
- 배송비 계산
- 상태 전환 규칙
- 문자열/숫자 처리 로직
Integration Test를 써야 할 때
여러 계층이 연결될 때 문제가 없는지 확인하고 싶을 때 좋습니다.
예:
- 회원가입 API
- 로그인 API
- 주문 생성 API
- 재고 차감
- 권한 체크
E2E Test를 써야 할 때
핵심 사용자 흐름이 끝까지 동작하는지 보고 싶을 때 좋습니다.
예:
- 회원가입 → 로그인 → 프로필 수정
- 상품 조회 → 장바구니 → 결제 → 주문 완료
- 글 작성 → 저장 → 목록 반영 → 상세 조회
7. 테스트는 왜 섞어서 써야 할까?
하나만 쓰면 항상 빈틈이 생깁니다.
Unit Test만 있으면 작은 로직은 잘 막을 수 있지만 실제 연결 구조는 놓칠 수 있습니다.
Integration Test만 있으면 신뢰도는 높지만 느리고, 로직 디버깅은 불편할 수 있습니다.
E2E만 있으면 실제 사용자 흐름은 보장되지만 너무 무겁고 유지보수 비용이 큽니다.
즉, 세 가지는 경쟁 관계가 아니라 서로 보완 관계입니다.
보통은 다음 식으로 가져갑니다.
- Unit Test를 가장 많이 작성
- 중요한 연결 흐름은 Integration Test로 보강
- 핵심 사용자 시나리오는 E2E로 최소 운영
8. 커버리지와 연결해서 보면 더 이해가 쉽다
테스트를 작성한 뒤 보통 커버리지 리포트를 확인하게 됩니다.
커버리지는 테스트가 실제로 어느 정도 코드를 실행했는지 보여주는 지표입니다.
예를 들어 pytest에서는 이렇게 실행할 수 있습니다.
pytest --cov=. --cov-report=term-missing --cov-report=html
이 명령어를 실행하면 보통 두 가지를 많이 봅니다.
- 터미널에 어떤 줄이 테스트되지 않았는지 나오는 결과
- htmlcov/index.html 같은 HTML 리포트
간단한 예시로 보는 커버리지의 의미
shipping.py
def get_shipping_fee(total_price: int) -> int:
if total_price < 0:
raise ValueError("invalid price")
if total_price >= 50000:
return 0
return 3000
이 함수에는 세 가지 경로가 있습니다.
- 금액이 음수이면 예외 발생
- 50,000원 이상이면 무료배송
- 그 외에는 배송비 3,000원
그런데 테스트를 아래처럼 두 개만 작성했다고 해보겠습니다.
test_shipping.py
from shipping import get_shipping_fee
def test_shipping_fee_is_free_for_large_order():
assert get_shipping_fee(50000) == 0
def test_shipping_fee_is_3000_for_small_order():
assert get_shipping_fee(30000) == 3000
이 테스트는 정상 케이스 두 개만 확인합니다.
즉, total_price < 0 분기는 전혀 검증하지 않았습니다.
이때 커버리지 리포트는
“아직 테스트가 지나가지 않은 줄이 있다”는 것을 알려줍니다.
그래서 아래 테스트를 추가해야 합니다.
import pytest
from shipping import get_shipping_fee
def test_shipping_fee_raises_for_negative_price():
with pytest.raises(ValueError, match="invalid price"):
get_shipping_fee(-1000)
커버리지를 왜 사용할까?
커버리지를 쓰는 이유는 단순히 숫자를 높이기 위해서가 아닙니다.
가장 중요한 목적은 테스트 사각지대를 찾기 위해서입니다.
즉, 커버리지는 “품질 점수판”이라기보다
어디가 아직 테스트되지 않았는지 보여주는 지도에 가깝습니다.
9. 정리
- Unit Test는 작은 로직이 맞는지 빠르게 검증하는 테스트다.
- Integration Test는 여러 부품이 연결되었을 때 잘 동작하는지 확인하는 테스트다.
- E2E Test는 사용자가 실제로 서비스를 끝까지 이용할 수 있는지 검증하는 테스트다.
- 커버리지는 테스트가 어디까지 닿았는지 보여주는 보조 지표다.
좋은 테스트 전략은
“무조건 많이 작성하는 것”이 아니라
어디에 어떤 테스트를 배치할지 잘 선택하는 것입니다.
마무리
테스트를 처음 접하면 이름이 많아서 복잡해 보이지만, 기준은 생각보다 단순합니다.
- 작은 규칙을 검증하고 싶다면 Unit Test
- 연결 구조를 검증하고 싶다면 Integration Test
- 실제 사용자 흐름을 검증하고 싶다면 E2E Test
그리고 커버리지는
“내가 테스트를 잘했나?”를 막연히 느끼는 대신
어디가 빠졌는지 눈으로 확인하게 해주는 도구입니다.
결국 테스트는 코드를 믿기 위해 작성합니다.
그리고 각 테스트 종류는 그 신뢰를 서로 다른 층위에서 쌓아 올리는 역할을 합니다.
있습니다.
'SW' 카테고리의 다른 글
| 스타트업 투자유치 단어 지표 (0) | 2026.04.08 |
|---|---|
| 테스트코드와 커버리지의 의미 (1) | 2026.03.13 |
| 이커머스에서 장바구니와 주문은 왜 Cart / Cart Item 구조로 설계할까 (0) | 2026.03.13 |
| 원격 SSH 접근 환경 만들기 (1) | 2025.11.25 |
| 리액트 올인원-4-React.js (0) | 2025.10.16 |