알고리즘을 처음 배울 때는 주어진 예제의 입력을 코드에 넣고 기대한 출력이 나오는지 확인한다.
10% 할인 쿠폰의 할인 금액을 계산하는 문제를 생각해보자.
function calculateDiscount(orderAmount, discountRate) {
return orderAmount * discountRate;
}
calculateDiscount(10000, 0.1);
입력은 주문 금액 10000과 할인율 0.1이고, 출력은 할인 금액 1000이다.
테스트도 간단하게 작성할 수 있다.
it("10% 할인 금액을 계산한다", () => {
expect(calculateDiscount(10000, 0.1)).toBe(1000);
});
이 테스트가 통과하면 준비한 입력에서 함수가 예상한 결과를 반환한다는 사실을 알 수 있다.
알고리즘의 기본 동작을 이해하고 구현 실수를 발견하기에는 유용한 방법이다.
하지만 실제 쿠폰 서비스에서는 예제 하나가 맞는다는 사실만으로 할인 계산이 올바르다고 판단할 수 없다.
하나의 예제 테스트는 하나의 경로만 확인한다. 실제 서비스가 처리해야 하는 입력과 상태의 범위는 그보다 훨씬 넓다.
실제 서비스에서 테스트한다는 것은 몇 개의 예제에서 기대값이 나오는지 확인하는 일이 아니라, 가능한 입력을 의미 있는 범위로 나누고 어떤 상황에서도 지켜져야 하는 서비스의 약속을 검증하는 일이다.
앞의 할인 함수는 정상적인 예제에서 올바르게 동작한다.
function calculateDiscount(orderAmount, discountRate) {
return orderAmount * discountRate;
}
하지만 다음 입력에서는 어떤 결과가 나와야 할까?
calculateDiscount(-10000, 0.1);
calculateDiscount(10000, -0.1);
calculateDiscount(10000, 1.5);
calculateDiscount("10000", 0.1);
calculateDiscount(999, 0.15);
현재 함수는 음수 할인 금액을 만들거나 주문 금액보다 큰 할인을 반환할 수 있다. 문자열을 숫자처럼 계산하며, 소수점 금액을 그대로 반환할 수도 있다.
JavaScript가 계산을 완료했다는 사실과 쿠폰 서비스가 올바른 결과를 만들었다는 사실은 다르다.
먼저 서비스가 허용하는 입력과 결과를 정의해야 한다.
예를 들어 다음과 같은 규칙을 세울 수 있다.
이 규칙이 정해져야 무엇을 테스트해야 하는지도 알 수 있다.
function calculateDiscount({
orderAmount,
discountRate,
maxDiscount,
}) {
if (
!Number.isSafeInteger(orderAmount) ||
orderAmount < 0
) {
throw new Error("주문 금액이 올바르지 않다.");
}
if (
!Number.isFinite(discountRate) ||
discountRate <= 0 ||
discountRate > 1
) {
throw new Error("할인율이 올바르지 않다.");
}
const calculatedDiscount = Math.floor(
orderAmount * discountRate
);
return Math.min(
calculatedDiscount,
maxDiscount,
orderAmount
);
}
이 함수는 입력의 범위를 검증하고 할인 금액을 서비스가 허용하는 범위 안으로 제한한다.
코드가 길어진 이유는 테스트를 통과하기 위해서가 아니다. 현실의 금액과 할인 정책을 프로그램이 처리할 수 있는 규칙으로 구체화했기 때문이다.
그렇다면 모든 숫자를 하나씩 테스트해야 할까?
그럴 필요는 없다. 대신 같은 규칙으로 처리되는 입력을 묶어 생각해야 한다.
할인 함수가 받을 수 있는 숫자는 매우 많다. 모든 주문 금액과 할인율을 하나씩 테스트할 수는 없다.
대신 입력을 결과가 달라지는 기준에 따라 나눌 수 있다.
| 입력 범위 | 대표 사례 | 기대하는 동작 |
|---|---|---|
| 주문 금액이 음수 | -1 | 입력 거부 |
| 주문 금액이 0 | 0 | 할인 금액 0 |
| 정상적인 주문 금액 | 10000 | 정책에 따라 할인 계산 |
| 안전한 정수 범위를 벗어남 | 매우 큰 숫자 | 입력 거부 |
| 할인율이 0 이하 | 0, -0.1 | 입력 거부 |
| 할인율이 정상 범위 | 0.1 | 비율 할인 계산 |
| 할인율이 1 초과 | 1.1 | 입력 거부 |
| 계산 결과가 최대 할인 이하 | 1000 | 계산 결과 사용 |
| 계산 결과가 최대 할인 초과 | 5000 | 최대 할인 금액 적용 |
| 계산 결과가 주문 금액 초과 | 15000 | 주문 금액 이하로 제한 |
각 범위에서 대표값을 선택하면 무작위로 예제를 늘리는 것보다 훨씬 체계적으로 동작을 검증할 수 있다.
it.each([
{
orderAmount: 10000,
discountRate: 0.1,
maxDiscount: 3000,
expected: 1000,
},
{
orderAmount: 50000,
discountRate: 0.1,
maxDiscount: 3000,
expected: 3000,
},
{
orderAmount: 0,
discountRate: 0.1,
maxDiscount: 3000,
expected: 0,
},
])("할인 정책에 맞는 금액을 반환한다", (input) => {
expect(calculateDiscount(input)).toBe(
input.expected
);
});
첫 번째 사례는 일반적인 계산을 확인한다. 두 번째 사례는 최대 할인 금액을 확인한다. 세 번째 사례는 주문 금액이 0인 경계를 확인한다.
테스트 개수보다 중요한 것은 각 테스트가 서로 다른 규칙을 검증하는가이다.
비슷한 정상 사례를 열 개 추가해도 같은 경로만 반복해서 확인할 수 있다. 반면 의미가 다른 세 가지 범위를 선택하면 더 넓은 동작을 검증할 수 있다.
쿠폰의 최소 주문 금액이 10,000원이라고 생각해보자.
다음 조건은 자연스러워 보인다.
function meetsMinimumAmount(
orderAmount,
minimumOrderAmount
) {
return orderAmount > minimumOrderAmount;
}
하지만 주문 금액이 정확히 10,000원이면 false가 된다.
기획 문구가 “10,000원 이상 주문 시 사용 가능”이라면 비교 연산자는 >가 아니라 >=여야 한다.
function meetsMinimumAmount(
orderAmount,
minimumOrderAmount
) {
return orderAmount >= minimumOrderAmount;
}
이를 확인하려면 경계의 바로 아래, 경계와 같은 값, 경계의 바로 위를 테스트할 수 있다.
it.each([
[9999, false],
[10000, true],
[10001, true],
])(
"최소 주문 금액의 경계를 판단한다",
(orderAmount, expected) => {
expect(
meetsMinimumAmount(orderAmount, 10000)
).toBe(expected);
}
);
이 테스트는 단순히 숫자 세 개를 확인하는 것이 아니다.
“이상”이라는 현실의 정책이 코드에서 정확하게 표현되었는지 확인한다.
시간에도 같은 문제가 생긴다.
function isExpired(expiresAt, now) {
return now > expiresAt;
}
만료 시각과 현재 시각이 정확히 같다면 이 함수는 아직 만료되지 않았다고 판단한다.
서비스 정책이 “만료 시각부터 사용할 수 없다”라면 다음과 같아야 한다.
function isExpired(expiresAt, now) {
return now >= expiresAt;
}
경곗값 테스트는 사소한 숫자 차이를 찾는 기술이 아니다. 자연어로 표현된 서비스 정책이 어느 순간부터 참에서 거짓으로 바뀌는지 확인하는 방법이다.
각 입력에 대한 정확한 출력값을 작성하는 방식은 이해하기 쉽다.
하지만 할인 계산에는 입력이 달라져도 항상 유지되어야 하는 조건이 있다.
이처럼 구현 방법이나 입력값이 달라져도 유지되어야 하는 조건을 불변 조건이라고 한다.
const discount = calculateDiscount({
orderAmount,
discountRate,
maxDiscount,
});
expect(discount).toBeGreaterThanOrEqual(0);
expect(discount).toBeLessThanOrEqual(orderAmount);
expect(discount).toBeLessThanOrEqual(maxDiscount);
이 테스트는 특정 입력의 정답을 외워 비교하지 않는다.
계산 결과가 서비스에서 허용할 수 있는 범위를 벗어나지 않는지 확인한다.
여러 입력을 자동으로 만들어 같은 조건을 반복해서 검증할 수도 있다.
for (let orderAmount = 0; orderAmount <= 100000; orderAmount += 1000) {
const discount = calculateDiscount({
orderAmount,
discountRate: 0.15,
maxDiscount: 5000,
});
expect(discount).toBeGreaterThanOrEqual(0);
expect(discount).toBeLessThanOrEqual(orderAmount);
expect(discount).toBeLessThanOrEqual(5000);
}
이 코드는 여러 주문 금액에서 같은 안전 조건이 유지되는지 확인한다.
예제 테스트는 “이 입력의 결과는 이것이다”를 확인한다. 불변 조건 테스트는 “입력이 달라져도 이 약속은 절대로 깨지면 안 된다”를 확인한다.
두 방식은 서로 대체하는 것이 아니라 함께 사용해야 한다.
할인 계산 테스트가 통과했더라도 입력으로 사용한 주문 금액의 출처가 잘못되면 서비스는 안전하지 않다.
클라이언트가 다음 값을 보냈다고 생각해보자.
const requestBody = {
couponId: "coupon_123",
orderAmount: 1000,
};
실제 장바구니 금액이 10,000원이더라도 사용자가 요청을 수정해 1000을 보낼 수 있다.
반대로 최소 주문 금액을 통과하기 위해 더 큰 값을 보낼 수도 있다.
따라서 결제에 사용할 주문 금액은 서버가 최신 상품 가격과 수량을 기준으로 다시 계산해야 한다.
const cart = await cartRepository.findByUserId(
currentUser.id
);
const orderAmount = calculateOrderAmount(
cart.items
);
클라이언트가 보낸 금액은 화면 표시나 변경 감지에 참고할 수 있다. 그러나 최종 할인 판단의 Source of Truth가 되어서는 안 된다.
테스트에서도 단순히 함수에 숫자를 전달하는 것만 확인해서는 이 문제를 발견할 수 없다.
다음 흐름을 함께 검증해야 한다.
알고리즘의 입력이 어디에서 만들어지는지 검증하지 않으면 올바른 계산 함수가 잘못된 데이터에 사용될 수 있다.
쿠폰 만료 여부를 다음과 같이 확인할 수 있다.
function isCouponAvailable(coupon) {
return new Date() < coupon.expiresAt;
}
이 함수는 실행할 때마다 현재 시간을 직접 읽는다.
오늘은 통과한 테스트가 내일은 실패할 수 있다. 테스트가 실행되는 속도나 서버의 시간대에 따라 결과가 달라질 수도 있다.
현재 시간을 입력으로 명시하면 같은 조건을 반복해서 검증할 수 있다.
function isCouponAvailable(coupon, now) {
return now < coupon.expiresAt;
}
테스트에서는 시간을 고정한다.
it("만료 시각 전에는 쿠폰을 사용할 수 있다", () => {
const coupon = {
expiresAt: new Date("2026-12-31T00:00:00Z"),
};
const now = new Date("2026-12-30T23:59:59Z");
expect(isCouponAvailable(coupon, now)).toBe(
true
);
});
이 코드는 테스트가 실행되는 실제 날짜와 관계없이 같은 결과를 만든다.
현재 시간, 무작위 값, 외부 API 응답처럼 실행할 때마다 달라지는 데이터를 함수 내부에서 직접 가져오면 어떤 조건을 검증했는지 불명확해진다.
테스트 가능한 알고리즘은 필요한 입력과 외부 의존성을 명시적으로 드러낸다.
쿠폰을 사용할 수 있는지 판단하는 함수를 충분히 테스트했다고 생각해보자.
function canRedeemCoupon(coupon, now) {
return (
coupon.status === "ACTIVE" &&
coupon.remainingUses > 0 &&
now < coupon.expiresAt
);
}
이 함수의 모든 조건을 확인해도 실제 쿠폰 사용 과정은 여전히 잘못될 수 있다.
const coupon = await couponRepository.findById(
couponId
);
if (canRedeemCoupon(coupon, serverNow)) {
await couponRepository.decreaseRemainingUses(
couponId
);
}
남은 횟수가 1회일 때 두 요청이 동시에 들어오면 두 요청이 모두 같은 상태를 읽을 수 있다. 순수한 함수의 테스트는 모두 통과하지만 실제 서비스에서는 쿠폰이 두 번 사용될 수 있다.
이 문제는 함수 입력과 출력만으로 재현되지 않는다. 데이터베이스의 읽기와 변경 순서, 동시 요청을 함께 테스트해야 한다.
UPDATE coupons
SET remaining_uses = remaining_uses - 1
WHERE id = $1
AND status = 'ACTIVE'
AND remaining_uses > 0
AND expires_at > $2
RETURNING id;
조건을 만족하는 행이 있을 때만 차감하도록 만들 수 있다.
그다음 두 요청을 동시에 실행하는 테스트가 필요하다.
const results = await Promise.all([
redeemCoupon(command),
redeemCoupon(command),
]);
const successCount = results.filter(
(result) => result.ok
).length;
expect(successCount).toBe(1);
이 테스트는 두 요청 중 하나만 성공해야 한다는 서비스 규칙을 검증한다.
알고리즘이 서비스 상태를 변경한다면 단위 테스트만으로 충분하지 않다. 실제 저장소와 트랜잭션이 규칙을 지키는지도 확인해야 한다.
크리스가 쿠폰 사용 버튼을 한 번 눌렀지만 네트워크 응답을 받지 못했다고 생각해보자.
프런트엔드는 실패로 판단하고 같은 요청을 다시 보낼 수 있다.
이것은 비정상적인 사용자의 특이한 행동이 아니다. 네트워크가 있는 서비스에서 자연스럽게 발생할 수 있는 상황이다.
const firstResult = await redeemCoupon({
couponId: "coupon_123",
requestId: "request_456",
});
const retriedResult = await redeemCoupon({
couponId: "coupon_123",
requestId: "request_456",
});
두 번째 요청이 쿠폰을 다시 차감해서는 안 된다.
expect(retriedResult).toEqual(firstResult);
expect(
await couponRepository.getRemainingUses(
"coupon_123"
)
).toBe(0);
이 테스트는 같은 요청이 반복되어도 결과가 한 번만 반영된다는 규칙을 확인한다.
예제 테스트를 작성할 때 정상적인 입력을 “사용자가 버튼을 정확히 한 번 누르고 네트워크가 정상적으로 응답하는 상황”으로만 생각하면 실제 서비스의 정상적인 실패와 재시도를 놓치게 된다.
쿠폰 10개에서 가장 좋은 쿠폰을 찾는 테스트가 통과했다고 생각해보자.
it("할인 금액이 가장 큰 쿠폰을 찾는다", () => {
const result = selectBestCoupon(coupons, cart);
expect(result.id).toBe("coupon_3");
});
이 테스트는 결과의 정확성을 확인하지만 처리 비용은 보여주지 않는다.
구현이 쿠폰마다 모든 장바구니 상품을 확인한다면 데이터가 커질수록 실행 횟수가 빠르게 증가할 수 있다.
for (const coupon of coupons) {
for (const item of cart.items) {
checkCouponApplicability(coupon, item);
}
}
쿠폰 10개와 상품 5개라면 50번 확인한다. 쿠폰 10만 개와 상품 100개라면 1,000만 번 확인한다.
운영 환경에서 지원해야 하는 데이터 규모를 이용한 테스트가 필요하다.
const coupons = createCoupons(10000);
const cart = createCartItems(100);
const startedAt = performance.now();
selectBestCoupon(coupons, cart);
const elapsed = performance.now() - startedAt;
expect(elapsed).toBeLessThan(200);
이 테스트의 시간 기준은 실행 환경에 따라 흔들릴 수 있으므로 일반 단위 테스트와 분리해 관리해야 한다. 실제 운영 환경과 비슷한 조건에서 반복 측정하는 편이 적절하다.
핵심은 특정 숫자 200이 아니다.
정확한 결과를 만드는지와 실제 데이터 규모에서 제시간에 결과를 만드는지는 서로 다른 질문이라는 점이다.
모든 문제를 하나의 테스트 방식으로 확인할 수는 없다.
| 테스트 범위 | 확인할 책임 | 쿠폰 서비스의 예 |
|---|---|---|
| 계산 함수 | 순수한 규칙과 경곗값 | 할인율, 최대 할인, 반올림 |
| 서비스 로직 | 여러 규칙의 처리 순서 | 소유자, 상태, 만료와 최소 금액 검증 |
| 저장소 통합 | 실제 데이터 변경 | 조건부 차감과 트랜잭션 |
| API | 외부 요청과 응답 계약 | 입력 검증, 인증과 오류 코드 |
| 동시성 | 여러 요청 사이의 정확성 | 1회용 쿠폰이 한 번만 사용됨 |
| 성능 | 운영 규모의 처리 비용 | 대량 쿠폰 조회와 응답 시간 |
| 사용자 흐름 | 기능 전체의 연결 | 선택, 적용, 결제 금액 갱신 |
할인 계산의 경곗값을 확인하는 데 실제 브라우저를 실행할 필요는 없다. 반대로 데이터베이스의 동시 갱신을 단순한 함수 테스트로 증명할 수도 없다.
테스트의 범위를 넓히는 것이 목적은 아니다.
실패할 수 있는 책임이 어디에 있는지 찾고, 그 책임을 관찰할 수 있는 가장 작은 범위에서 검증하는 것이 중요하다.
사용할 수 없는 쿠폰에 대해 모두 false를 반환할 수 있다.
return false;
하지만 프런트엔드는 사용자가 다음에 무엇을 해야 하는지 알 수 없다.
실패 원인을 명확한 결과로 표현할 수 있다.
return {
ok: false,
reason: "MINIMUM_AMOUNT_NOT_MET",
requiredAmount: 10000,
};
이제 화면은 최소 주문 금액이 부족하다는 안내를 보여줄 수 있다.
다만 다른 사용자의 쿠폰인지처럼 외부에 공개하면 안 되는 정보는 일반화할 수 있다.
return {
ok: false,
reason: "COUPON_NOT_FOUND",
};
테스트는 계산 결과뿐 아니라 실패 계약도 확인해야 한다.
expect(result).toEqual({
ok: false,
reason: "MINIMUM_AMOUNT_NOT_MET",
requiredAmount: 10000,
});
이 테스트는 단순히 함수가 실패했다는 사실이 아니라 다음 책임자가 실패를 올바르게 처리할 수 있는 결과를 받는지 확인한다.
테스트가 많다고 반드시 안전한 것은 아니다.
다음과 같은 테스트가 20개 있어도 모두 정상적인 주문 금액과 활성 쿠폰만 사용한다면 비슷한 경로를 반복해서 확인할 뿐이다.
효과적인 테스트는 서로 다른 위험을 다룬다.
테스트 코드를 작성하기 전에 이 질문들을 먼저 정리하면 비슷한 예제를 반복하는 대신 서비스의 실제 위험을 검증할 수 있다.
구현이 예제 테스트를 통과한 뒤 다음 질문을 확인할 수 있다.
이 질문들은 모든 함수에 모든 종류의 테스트를 작성하라는 뜻이 아니다.
잘못되었을 때 발생하는 피해와 변경 가능성을 기준으로 필요한 검증 범위를 선택하기 위한 체크리스트다.
예제 테스트는 중요하다.
개발자가 이해한 요구사항을 구체적인 입력과 출력으로 표현하고, 가장 기본적인 구현 실수를 빠르게 발견하게 해준다. 복잡한 문제를 작은 사례로 이해하는 데도 도움이 된다.
하지만 예제 하나가 통과했다는 사실은 그 사례가 맞았다는 뜻일 뿐이다.
실제 서비스의 알고리즘을 신뢰하려면 다음 단계까지 확장해야 한다.
예제 테스트를 통과했다는 것은 알고 있는 한 가지 상황에서 코드가 동작했다는 뜻이다. 문제가 해결되었다고 말하려면 입력의 범위, 경계, 실패, 상태 변화와 운영 조건에서도 서비스의 약속이 유지되는지 확인해야 한다.
테스트의 목적은 많은 초록색 표시를 만드는 데 있지 않다.
우리가 아직 생각하지 못한 입력과 상황을 발견하고, 구현이 어떤 조건에서 올바르다고 말할 수 있는지 근거를 만드는 데 있다.