테스트: 자동 검증으로 변경에 대한 신뢰를 확보하는 소프트웨어 공학 기법
테스트(Test)는 코드가 기대한 대로 동작하는지 자동으로 확인하는 절차다. 단위·통합·E2E 테스트를 비용과 신뢰도 관점에서 어떻게 배분하는지, 그리고 의존성을 대체하는 테스트 더블(Test Double)을 어떻게 쓰는지 다룬다. TypeScript 예제로 실행 가능한 단위 테스트와 목(Mock) 활용법을 보인다.
By 송민성
1. 개념
테스트(Test)는 프로그램의 실제 동작을 기대 동작과 비교해 일치 여부를 판정하는 활동이다. 자동화된 테스트는 이 비교를 코드로 작성해 반복 실행할 수 있게 만든 것이다.
테스트는 범위에 따라 보통 세 계층으로 나눈다.
| 종류 | 검증 범위 | 특징 |
|---|---|---|
| 단위 테스트(Unit Test) | 함수·클래스 하나 | 빠르고 원인 파악이 쉽다 |
| 통합 테스트(Integration Test) | 여러 모듈, DB, 외부 API 연동 | 경계에서 생기는 결함을 잡는다 |
| 종단 간 테스트(End-to-End, E2E) | 사용자 시나리오 전체 | 신뢰도는 높지만 느리고 깨지기 쉽다 |
2. 왜 사용하는가
- 회귀(Regression) 방지: 기존 기능이 수정 후에도 동작함을 자동으로 확인한다.
- 리팩터링 안전망: 내부 구조를 바꿔도 외부 동작이 유지되는지 즉시 알 수 있다.
- 실행 가능한 명세: 테스트 이름과 입력·출력이 함수의 의도를 문서처럼 보여준다.
- 설계 피드백: 테스트하기 어려운 코드는 대개 의존성이 강하게 얽혀 있다는 신호다.
3. 동작 원리
대부분의 테스트는 AAA 패턴을 따른다.
- Arrange: 입력과 대상 객체, 의존성을 준비한다.
- Act: 검증할 동작을 한 번 실행한다.
- Assert: 결과가 기대값과 같은지 단언(assertion)한다.
테스트 러너(Test Runner)는 테스트 함수를 수집해 실행하고, 단언이 실패하면 예외를 던져 그 테스트를 실패로 기록한다. 예외 없이 끝나면 통과다.
외부 의존성(DB, 네트워크, 시간 등)은 테스트 더블(Test Double)로 대체해 테스트를 빠르고 결정적(deterministic)으로 만든다.
- 스텁(Stub): 미리 정한 값을 반환한다.
- 목(Mock): 호출 여부와 인자를 검증한다.
- 페이크(Fake): 실제와 비슷하게 동작하는 가벼운 구현이다(예: 메모리 DB).
테스트 주도 개발(TDD)은 실패하는 테스트 작성(Red) → 통과시키는 최소 구현(Green) → 구조 개선(Refactor)을 반복하는 방식이다.
4. 코드 예제
Node.js 내장 테스트 러너(node:test)를 사용한 예제다. 별도 설치 없이 실행할 수 있다.
// discount.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
type PriceRepo = { getPrice(id: string): Promise<number> };
export async function calcTotal(
repo: PriceRepo,
ids: string[],
rate: number
): Promise<number> {
if (rate < 0 || rate > 1) throw new RangeError("rate must be 0..1");
let sum = 0;
for (const id of ids) sum += await repo.getPrice(id);
return Math.round(sum * (1 - rate));
}
test("할인율이 적용된 합계를 반환한다", async () => {
// Arrange: 스텁으로 DB 의존성 대체
const prices: Record<string, number> = { a: 1000, b: 2000 };
const repo: PriceRepo = { getPrice: async (id) => prices[id] };
// Act
const total = await calcTotal(repo, ["a", "b"], 0.1);
// Assert
assert.equal(total, 2700);
});
test("잘못된 할인율이면 RangeError를 던진다", async () => {
const repo: PriceRepo = { getPrice: async () => 0 };
await assert.rejects(() => calcTotal(repo, [], 1.5), RangeError);
});
test("각 상품 ID로 저장소를 한 번씩 호출한다 (목 방식 검증)", async () => {
const calls: string[] = [];
const repo: PriceRepo = {
getPrice: async (id) => {
calls.push(id);
return 100;
},
};
await calcTotal(repo, ["x", "y"], 0);
assert.deepEqual(calls, ["x", "y"]);
});실행:
node --test --experimental-strip-types discount.test.tsNode.js 버전에 따라 TypeScript 직접 실행 옵션이 다를 수 있다. 지원하지 않는 버전에서는 tsx 같은 도구로 실행하거나 JavaScript로 컴파일한 뒤 node --test를 사용한다.
5. 시간 복잡도 또는 성능 특성
테스트에는 알고리즘 복잡도보다 실행 비용과 신뢰도의 트레이드오프가 중요하다.
- 단위 테스트는 보통 밀리초 단위로 끝나 개발 중 수시로 실행할 수 있다.
- 통합·E2E 테스트는 환경 구성과 I/O 때문에 느리고, 타이밍 의존으로 불안정해지기 쉽다.
- 이 때문에 단위 테스트를 많이, E2E를 적게 두는 테스트 피라미드(Test Pyramid) 구성이 널리 권장된다.
- 테스트 스위트 전체 시간은 CI 피드백 속도를 좌우한다. 병렬 실행과 변경 영향 범위 기반 선택 실행으로 줄일 수 있다.
6. 실무 사용 사례
- CI 파이프라인: 풀 리퀘스트마다 테스트를 자동 실행해 실패 시 병합을 막는다.
- API 통합 테스트: 테스트용 DB 컨테이너를 띄워 실제 쿼리와 마이그레이션을 검증한다.
- 계약 테스트(Contract Test): 마이크로서비스 간 요청·응답 규격이 어긋나지 않는지 확인한다.
- 버그 재현 테스트: 버그를 고치기 전에 실패하는 테스트를 먼저 작성해 재발을 막는다.
7. 주의할 점
- 구현 세부사항에 결합된 테스트: 내부 호출 순서까지 검증하면 리팩터링할 때마다 깨진다. 가능하면 관찰 가능한 결과를 검증한다.
- 과도한 목 사용: 목이 많을수록 실제 연동 결함을 놓칠 수 있다. 경계는 통합 테스트로 보완한다.
- 불안정한 테스트(Flaky Test): 시간, 랜덤, 네트워크, 실행 순서 의존이 원인이다. 시계와 난수를 주입해 고정한다.
- 커버리지(Coverage) 맹신: 코드가 실행됐다는 뜻일 뿐 올바르게 검증됐다는 뜻이 아니다. 단언 없는 테스트도 커버리지를 올린다.
- 테스트 간 상태 공유: 테스트는 서로 독립적이어야 하며 순서와 무관하게 통과해야 한다.
8. 핵심 정리
- 테스트는 기대 동작과 실제 동작을 자동으로 비교하는 코드다.
- AAA(Arrange-Act-Assert) 구조로 작성하면 의도가 분명해진다.
- 단위·통합·E2E를 비용과 신뢰도에 맞춰 배분한다(테스트 피라미드).
- 스텁·목·페이크로 외부 의존성을 대체해 빠르고 결정적인 테스트를 만든다.
- 구현이 아니라 동작을 검증하고, 불안정한 테스트와 커버리지 수치에 대한 과신을 경계한다.