
프로그래밍을 처음 배울 때 테스트는 보통 함수를 실행하고 예상한 값이 나오는지 확인하는 코드라고 배운다.
import { expect, test } from "vitest";
test("두 숫자를 더한다", () => {
expect(1 + 2).toBe(3);
});
입력과 출력이 분명한 함수의 기본 동작을 이해하기에는 충분한 설명이다.
하지만 실제 서비스를 개발하면 테스트가 통과한다는 사실만으로는 부족하다.
테스트는 코드가 동작하는지 확인하는 일이 아니다.
테스트는 서비스가 사용자와 다른 시스템에 제공하기로 한 약속을 실행 가능한 형태로 기록하고, 코드가 변경된 뒤에도 그 약속이 유지되는지 검증하는 일이다.
크리스가 도서 대출 서비스를 개발한다고 생각해 보자.
회원은 대출 가능한 책을 빌릴 수 있고, 일반 회원은 동시에 다섯 권까지만 대출할 수 있다.
처음에는 메서드가 호출되었는지를 테스트할 수 있다.
test("책을 대출하면 저장소를 호출한다", async () => {
const repository = {
save: vi.fn(),
};
const service = new BorrowingService(repository);
await service.borrow({
memberId: "member-1",
bookId: "book-1",
});
expect(repository.save).toHaveBeenCalled();
});
이 테스트는 save가 호출되었다는 사실만 확인한다.
저장된 데이터에 잘못된 회원 ID가 들어가거나, 이미 대출 중인 책을 다시 저장하거나, 대출 한도를 무시해도 테스트가 통과할 수 있다.
도서 대출 서비스가 지켜야 하는 약속을 기준으로 테스트하는 편이 낫다.
test(
"대출 가능한 책을 빌리면 활성 대출이 생성된다",
async () => {
const service = createBorrowingService({
availableBookIds: ["book-1"],
activeBorrowings: [],
});
const borrowing = await service.borrow({
memberId: "member-1",
bookId: "book-1",
});
expect(borrowing).toMatchObject({
memberId: "member-1",
bookId: "book-1",
status: "active",
});
}
);
이 테스트는 저장 메서드 호출이 아니라 서비스가 만들어야 하는 결과를 확인한다.
구현이 메모리 저장소에서 PostgreSQL로 바뀌거나 내부 메서드 이름이 달라져도 “대출 가능한 책을 빌리면 활성 대출이 만들어진다”는 약속은 유지된다.
다음 테스트 이름은 무엇을 검증하는지 충분히 설명하지 않는다.
test("borrow test", async () => {
// ...
});
실패했을 때 개발자는 테스트 코드를 열어봐야 의미를 알 수 있다.
서비스의 조건과 결과를 이름에 표현할 수 있다.
test(
"이미 대출 중인 책은 다른 회원이 빌릴 수 없다",
async () => {
// ...
}
);
test(
"활성 대출이 다섯 권인 일반 회원은 책을 더 빌릴 수 없다",
async () => {
// ...
}
);
test(
"책을 반납하면 같은 책을 다른 회원이 빌릴 수 있다",
async () => {
// ...
}
);
테스트 목록만 읽어도 도서 대출 서비스의 주요 규칙을 파악할 수 있다.
테스트 이름은 구현 함수의 이름을 반복하는 설명이 아니다. 특정 상황에서 서비스가 보장해야 하는 결과를 기록하는 문장이다.
테스트 안에서 데이터 생성, 함수 호출, 결과 확인이 섞이면 어떤 조건을 검증하는지 파악하기 어렵다.
준비, 행동, 검증의 흐름으로 나눌 수 있다.
test(
"이미 대출 중인 책은 다시 빌릴 수 없다",
async () => {
// Arrange
const service = createBorrowingService({
availableBookIds: [],
activeBorrowings: [
{
memberId: "member-2",
bookId: "book-1",
},
],
});
// Act
const borrow = () =>
service.borrow({
memberId: "member-1",
bookId: "book-1",
});
// Assert
await expect(borrow).rejects.toThrow(
BookAlreadyBorrowedError
);
}
);
준비 단계는 이미 대출 중인 책이라는 상황을 만든다. 행동 단계는 다른 회원의 대출 시도를 표현한다. 검증 단계는 서비스가 요청을 거절해야 한다는 약속을 확인한다.
이 구조는 형식 자체가 목적은 아니다. 테스트가 어떤 상황에서 어떤 행동을 했고 어떤 결과를 기대하는지 빠르게 읽을 수 있게 하는 도구다.
일반 회원은 최대 다섯 권, 연구 회원은 최대 열 권을 빌릴 수 있다고 가정해 보자.
이 규칙은 데이터베이스나 HTTP 서버가 없어도 확인할 수 있다.
type MembershipType =
| "standard"
| "researcher";
function canBorrowMore(
membershipType: MembershipType,
activeBorrowingCount: number
): boolean {
const limit =
membershipType === "researcher" ? 10 : 5;
return activeBorrowingCount < limit;
}
이 함수는 회원 유형과 현재 대출 수를 받아 추가 대출 가능 여부를 반환한다.
경계값을 테스트할 수 있다.
describe("canBorrowMore", () => {
test.each([
["standard", 4, true],
["standard", 5, false],
["researcher", 9, true],
["researcher", 10, false],
] as const)(
"%s 회원의 활성 대출이 %i권이면 %s를 반환한다",
(membershipType, count, expected) => {
expect(
canBorrowMore(membershipType, count)
).toBe(expected);
}
);
});
한도 바로 아래와 한도에 도달한 상태를 함께 확인한다. 이런 작은 테스트는 빠르고 실패 원인이 분명하므로 다양한 비즈니스 조건을 검증하기에 적합하다.
하지만 작은 함수 테스트만으로는 저장소 연결, 쿼리, API 응답까지 올바르다는 사실을 알 수 없다. 다음 질문은 어느 경계를 실제로 연결해 확인해야 하는가이다.
크리스가 저장소를 모두 가짜 객체로 대체했다고 생각해 보자.
const borrowingRepository = {
countActiveByMember: vi
.fn()
.mockResolvedValue(4),
create: vi.fn(),
};
이 가짜 객체는 서비스 로직을 빠르게 테스트하는 데 유용하다. 하지만 실제 데이터베이스의 컬럼 이름, 제약 조건, 날짜 변환, 쿼리 조건이 올바른지는 확인하지 못한다.
실제 저장소 구현은 별도의 통합 테스트로 확인할 수 있다.
test(
"회원의 활성 대출만 계산한다",
async () => {
await seedBorrowings([
{
memberId: "member-1",
status: "active",
},
{
memberId: "member-1",
status: "returned",
},
{
memberId: "member-2",
status: "active",
},
]);
const count =
await borrowingRepository
.countActiveByMember("member-1");
expect(count).toBe(1);
}
);
이 테스트는 실제 데이터베이스와 저장소 코드를 연결해 member-1의 활성 대출만 조회되는지 확인한다.
단위 테스트와 통합 테스트는 같은 목적을 중복해서 수행하는 것이 아니다.
| 테스트 범위 | 주로 확인하는 약속 |
|---|---|
| 작은 단위 테스트 | 계산과 비즈니스 규칙 |
| 저장소 통합 테스트 | 쿼리, 매핑, 제약 조건 |
| API 테스트 | 요청·응답 계약과 인증 |
| 화면 테스트 | 사용자가 기능을 이용하는 흐름 |
| End-to-End 테스트 | 핵심 경로가 전체 시스템에서 연결되는지 |
테스트 종류는 우열이 아니라 서로 다른 경계에서 발생할 수 있는 실패를 나눠 확인하는 방법이다.
도서 대출 API가 다음 요청을 받는다고 생각해 보자.
POST /borrowings
Content-Type: application/json
{
"bookId": "550e8400-e29b-41d4-a716-446655440000"
}
외부에서 받은 값은 검증 전까지 신뢰할 수 없다.
import { z } from "zod";
const BorrowBookSchema = z.object({
bookId: z.string().uuid(),
});
이 스키마는 bookId가 UUID 형식인지 검증한다. 하지만 스키마를 작성했다는 사실만으로 실제 API가 이를 사용한다고 보장할 수는 없다.
API 경계에서 잘못된 입력을 테스트해야 한다.
test(
"bookId가 UUID가 아니면 400을 반환한다",
async () => {
const response = await app.request(
"/borrowings",
{
method: "POST",
body: JSON.stringify({
bookId: "invalid-book-id",
}),
headers: {
"content-type": "application/json",
},
}
);
expect(response.status).toBe(400);
expect(await response.json()).toMatchObject({
code: "INVALID_BORROW_REQUEST",
});
}
);
이 테스트는 검증 라이브러리의 내부 동작이 아니라 클라이언트와 서버 사이의 약속을 확인한다.
성공 응답도 계약으로 검증할 수 있다.
test(
"대출이 생성되면 201과 대출 정보를 반환한다",
async () => {
const response =
await borrowAvailableBook();
expect(response.status).toBe(201);
expect(await response.json()).toMatchObject({
status: "active",
bookId: expect.any(String),
dueAt: expect.any(String),
});
}
);
응답 상태, 필드 이름, 값의 의미는 프론트엔드와 다른 클라이언트가 의존하는 공개 계약이다.
React 컴포넌트의 내부 상태를 직접 확인하는 테스트를 작성할 수 있다.
expect(
componentInstance.state.isBorrowed
).toBe(true);
그러나 함수형 컴포넌트로 리팩터링하거나 상태 관리 방식을 바꾸면 사용자 화면이 그대로여도 테스트가 깨진다.
사용자가 기능을 사용하는 방식으로 검증하는 편이 낫다.
test(
"대출 버튼을 누르면 완료 메시지가 표시된다",
async () => {
render(<BookDetails bookId="book-1" />);
await user.click(
screen.getByRole("button", {
name: "대출하기",
})
);
expect(
await screen.findByText(
"대출이 완료되었다"
)
).toBeVisible();
}
);
이 테스트는 컴포넌트 인스턴스나 내부 상태에 접근하지 않는다. 사용자가 찾을 수 있는 버튼을 클릭하고 화면에 표시되는 결과를 확인한다.
사용자에게 보이는 동작이 유지된다면 내부 구조를 바꿔도 테스트가 통과할 수 있다. 이것이 리팩터링을 돕는 테스트의 중요한 성질이다.
실제 브라우저, API 서버, 데이터베이스를 모두 연결하면 높은 신뢰를 얻을 수 있다.
test(
"회원이 책을 검색하고 대출한다",
async ({ page }) => {
await page.goto("/books");
await page.getByLabel("도서 검색")
.fill("Clean Code");
await page.getByRole("link", {
name: "Clean Code",
}).click();
await page.getByRole("button", {
name: "대출하기",
}).click();
await expect(
page.getByText("대출이 완료되었다")
).toBeVisible();
}
);
이 테스트는 사용자의 핵심 흐름이 실제 구성 요소를 통과하는지 확인한다.
하지만 고수준 테스트는 실행 시간이 길고 실패 원인을 찾기 어렵다. 네트워크 지연, 테스트 데이터, 화면 렌더링 시점의 영향을 받을 수도 있다.
따라서 모든 조건을 브라우저 테스트로 반복하기보다 범위에 맞게 나누는 편이 낫다.
많은 작은 규칙 테스트
↓
필요한 통합·계약 테스트
↓
소수의 핵심 사용자 흐름 테스트
대출 한도 경계값은 작은 테스트에서 충분히 확인할 수 있다. 브라우저 테스트는 로그인부터 검색, 대출 완료까지 핵심 흐름이 연결되는지 확인하는 데 집중할 수 있다.
도서 대출 완료 후 이메일을 발송한다고 생각해 보자.
테스트에서 실제 이메일 서비스에 요청하면 실행이 느려지고 실제 메시지가 발송될 수 있다.
외부 경계를 가짜 구현으로 바꿀 수 있다.
const notificationGateway = {
sendBorrowingConfirmation:
vi.fn().mockResolvedValue(undefined),
};
const service = new BorrowingService({
notificationGateway,
});
이 가짜 객체는 이메일 서비스의 성공 응답을 통제한다.
test(
"대출이 완료되면 알림을 요청한다",
async () => {
await service.borrow({
memberId: "member-1",
bookId: "book-1",
});
expect(
notificationGateway
.sendBorrowingConfirmation
).toHaveBeenCalledWith(
expect.objectContaining({
memberId: "member-1",
bookId: "book-1",
})
);
}
);
이 테스트는 외부 이메일 시스템 자체가 아니라 대출 서비스가 알림 요청 책임을 수행하는지 확인한다.
Mock을 너무 많이 사용하면 테스트가 실제 서비스가 아니라 Mock 사이의 호출 순서만 검증하게 된다.
Mock은 테스트를 현실과 분리하기 위한 목적이 아니라, 특정 약속을 검증하는 동안 통제할 경계를 선택하는 도구다.
대출 기한이 현재 시각을 기준으로 14일 뒤라면 다음 코드가 작성될 수 있다.
function createBorrowing() {
const borrowedAt = new Date();
const dueAt = addDays(borrowedAt, 14);
return {
borrowedAt,
dueAt,
};
}
테스트가 실행되는 순간마다 값이 달라진다. 날짜 경계와 시간대에 따라 예상하지 못한 실패가 발생할 수도 있다.
시간을 의존성으로 전달할 수 있다.
type Clock = {
now(): Date;
};
function createBorrowing(clock: Clock) {
const borrowedAt = clock.now();
return {
borrowedAt,
dueAt: addDays(borrowedAt, 14),
};
}
테스트에서는 고정된 시간을 사용한다.
test(
"일반 대출의 기한은 대출 시점부터 14일이다",
() => {
const clock = {
now: () =>
new Date("2026-10-05T00:00:00Z"),
};
const borrowing =
createBorrowing(clock);
expect(
borrowing.dueAt.toISOString()
).toBe("2026-10-19T00:00:00.000Z");
}
);
시간이 고정되므로 언제 실행해도 같은 결과가 나온다.
ID 생성, 무작위 추천, 재시도 지연도 같은 방식으로 통제할 수 있다. 테스트는 실행 순서, 컴퓨터, 시간대에 따라 결과가 달라지지 않아야 한다.
모든 필드를 가진 거대한 회원 객체를 매번 만들 수 있다.
const member = {
id: "member-1",
name: "Chris",
email: "chris@example.com",
phone: "0400000000",
address: {
// 많은 필드
},
preferences: {
// 많은 필드
},
membershipType: "standard",
activeBorrowingCount: 5,
};
대출 한도를 테스트하는 데 주소와 알림 설정은 필요하지 않다. 불필요한 데이터가 많으면 어떤 조건이 결과에 영향을 주는지 알기 어렵다.
테스트 목적에 필요한 값을 강조하는 빌더를 사용할 수 있다.
const member = buildMember({
membershipType: "standard",
activeBorrowingCount: 5,
});
이 코드는 일반 회원이 이미 다섯 권을 빌렸다는 조건을 눈에 띄게 만든다. 나머지 필드는 안전한 기본값으로 생성할 수 있다.
다만 빌더의 기본값이 중요한 조건을 숨기지 않도록 주의해야 한다.
const member = buildMember();
이 데이터가 왜 테스트에 적합한지 알 수 없다면 중요한 조건을 명시하는 편이 낫다.
좋은 테스트 데이터는 운영 데이터를 그대로 복제하는 것이 아니다. 해당 테스트의 원인과 결과를 가장 작고 분명하게 표현하는 데이터다.
월말에 반납된 책의 연체 일수가 하루 더 계산되는 버그가 발견되었다고 생각해 보자.
바로 구현을 수정하면 현재 문제는 해결할 수 있다. 하지만 같은 조건이 나중에 다시 깨지는지 확인할 기록은 남지 않는다.
먼저 버그를 재현하는 테스트를 작성할 수 있다.
test(
"기한 당일 반납은 연체로 계산하지 않는다",
() => {
const overdueDays = calculateOverdueDays({
dueAt: new Date(
"2026-10-19T00:00:00Z"
),
returnedAt: new Date(
"2026-10-19T18:00:00Z"
),
timeZone: "Australia/Sydney",
});
expect(overdueDays).toBe(0);
}
);
수정 전에는 이 테스트가 실패해야 한다. 구현을 수정한 뒤 통과하면 문제를 재현한 조건과 기대 결과가 회귀 테스트로 남는다.
버그 수정 테스트는 단순히 현재 코드가 맞다는 표시가 아니다.
한 번 실제로 깨졌던 서비스의 약속이 다시 깨지지 않도록 남기는 기록이다.
코드 커버리지는 테스트 실행 중 어떤 코드가 지나갔는지를 보여준다.
다음 테스트는 조건문을 실행해 커버리지를 높일 수 있다.
test("borrow를 실행한다", async () => {
await service.borrow({
memberId: "member-1",
bookId: "book-1",
});
});
하지만 아무 결과도 검증하지 않는다. 코드가 실행되기만 하면 통과한다.
반대로 중요한 경계 하나를 정확히 검증하는 테스트는 커버리지 숫자에 작은 영향만 줄 수 있다.
test(
"일반 회원은 다섯 번째 책까지 빌릴 수 있지만 여섯 번째 책은 빌릴 수 없다",
async () => {
// 경계 규칙 검증
}
);
커버리지는 테스트되지 않은 영역을 찾는 참고 자료로 유용하다. 그러나 테스트 품질의 Source of Truth는 아니다.
더 중요한 질문은 다음과 같다.
커버리지 목표를 채우기 위해 의미 없는 테스트를 추가하면 숫자는 높아져도 변경에 대한 신뢰는 높아지지 않는다.
테스트가 실패하면 코드를 테스트에 맞추고 싶어진다. 그러나 테스트가 오래된 정책을 표현하고 있을 수도 있다.
기존 테스트가 일반 회원의 대출 기간을 21일로 가정한다고 생각해 보자.
expect(borrowing.dueAt).toEqual(
addDays(borrowing.borrowedAt, 21)
);
현재 정책이 14일로 변경되었다면 코드를 21일로 되돌리는 것이 정답은 아니다.
다음 자료를 함께 확인해야 한다.
대출 시점에 확정된 dueAt은 이후 정책이 바뀌어도 유지해야 하는 비즈니스 사실일 수 있다.
type Borrowing = {
borrowedAt: Date;
dueAt: Date;
loanPolicyVersion: string;
};
이 구조는 대출 당시 계산된 기한과 적용된 정책 버전을 함께 보존한다.
테스트는 요구사항을 실행 가능한 형태로 기록하지만 요구사항 그 자체를 자동으로 결정하지는 않는다. 코드와 테스트가 충돌하면 어느 쪽을 억지로 통과시킬지가 아니라 현재 서비스의 약속이 무엇인지 다시 확인해야 한다.
내부 구현을 바꿀 때마다 수많은 테스트가 깨진다면 테스트가 구현 세부사항에 지나치게 결합되어 있을 수 있다.
예를 들어 다음 테스트는 내부 메서드 호출 순서를 고정한다.
expect(repository.findBook)
.toHaveBeenCalledBefore(
repository.findMember
);
사용자에게 제공되는 결과와 관계없는 순서라면 리팩터링을 방해한다.
외부에서 관찰할 수 있는 결과를 검증하는 편이 낫다.
expect(borrowing).toMatchObject({
memberId: "member-1",
bookId: "book-1",
status: "active",
});
저장소 호출 순서가 바뀌어도 올바른 대출 결과가 생성되면 테스트는 통과한다.
좋은 테스트는 코드 구조를 얼려두지 않는다.
테스트의 목적은 변화를 줄이는 것이 아니라 안전하게 변화할 수 있는 범위를 넓히는 데 있다.
Mock 호출은 맞지만 사용자에게 잘못된 결과가 반환될 수 있다.
가능하면 서비스가 외부에 제공하는 결과와 상태 변화를 확인해야 한다.
내부 함수 이름과 호출 순서를 검증하면 정상적인 리팩터링에도 테스트가 깨진다.
사용자가 관찰할 수 있는 동작과 공개 계약을 중심으로 검증해야 한다.
실행 시간이 길어지고 실패 원인을 찾기 어려워진다.
작은 규칙 테스트, 통합 테스트, 소수의 핵심 흐름 테스트를 조합해야 한다.
테스트 속에서는 모든 호출이 성공하지만 실제 쿼리와 외부 계약이 깨질 수 있다.
실제 경계를 확인하는 통합·계약 테스트가 필요하다.
날짜, 시간대, 실행 순서와 데이터 변화에 따라 테스트가 불규칙하게 실패한다.
시간과 데이터는 테스트가 명시적으로 소유하고 통제해야 한다.
검증문이 없는 테스트도 코드를 실행해 커버리지를 높일 수 있다.
중요한 약속과 실패 경로가 보호되는지를 먼저 확인해야 한다.
정책 변경이나 잘못된 기대값 때문에 테스트가 실패할 수도 있다.
요구사항, 코드, 테스트 중 무엇이 현재 약속과 다른지 확인해야 한다.
프로그래밍을 처음 배울 때는 테스트를 입력에 대해 예상한 출력이 나오는지 확인하는 코드라고 이해해도 충분하다.
test("두 숫자를 더한다", () => {
expect(1 + 2).toBe(3);
});
하지만 실제 서비스에서는 현재 코드가 실행되는지만 확인해서는 변경의 안전성을 판단하기 어렵다.
테스트는 코드가 동작하는지 확인하는 일이 아니다.
테스트는 서비스가 사용자와 다른 시스템에 제공하기로 한 약속을 실행 가능한 형태로 기록하고, 코드가 변경된 뒤에도 그 약속이 유지되는지 검증하는 일이다.