Use Cases (업데이트: 2026. 6. 2.)

Claude Code TDD 실전 가이드: Vitest와 node:test로 테스트 주도 개발하기

Claude Code로 TDD를 실천하는 방법을 Vitest, node:test, CI, hooks, 프롬프트 템플릿과 함께 설명합니다.

Claude Code TDD 실전 가이드: Vitest와 node:test로 테스트 주도 개발하기

Claude Code는 코드를 빠르게 만들어 줍니다. 하지만 빠르다는 것만으로 안전한 변경이 되지는 않습니다. 나중에 문제가 되는 부분은 대개 경계값 누락, 기존 동작 파괴, 할인 계산 오류, 로컬에서는 통과했지만 CI에서만 실패하는 테스트입니다.

TDD, 즉 테스트 주도 개발은 이 속도를 통제하는 실용적인 방법입니다. 먼저 실패하는 테스트를 쓰고, 그 테스트를 통과시키는 최소 구현을 만든 뒤, 동작을 바꾸지 않고 코드를 정리합니다. 이 흐름을 Red-Green-Refactor라고 부릅니다. Red는 실패, Green은 성공, Refactor는 정리입니다.

Claude Code와 TDD는 잘 맞습니다. Claude Code가 테스트 케이스를 나열하고, 실패 로그를 읽고, 최소 구현을 만들고, CI 설정까지 정리할 수 있기 때문입니다. 단, “기능을 만들어 줘”라고만 요청하면 구현에 맞춘 테스트를 나중에 붙이는 흐름이 되기 쉽습니다. 이 글에서는 먼저 테스트를 고정하고 Claude Code를 안전하게 움직이는 방법을 다룹니다.

이번 업데이트에서는 공식 Claude Code hooks reference, Claude Code memory, Claude Code settings, Vitest Getting Started, Vitest CLI, Node.js test runner를 확인했습니다. 아래 hook 예시는 현재 문서에 맞춰 JSON stdin에서tool_input.file_path를 읽습니다.

TDD에서 Claude Code가 맡을 일

Claude Code에는 테스트 관점 정리, 실패 테스트 작성, 최소 구현, 실패 로그 해석, CI 보강, 변경 요약을 맡기기 좋습니다. 반대로 비즈니스 규칙, 보안 경계, 공개 API 계약, 릴리스 판단은 사람이 먼저 정해야 합니다.

단계Claude Code 작업사람이 확인할 점
Red사양에서 실패 테스트 작성요구사항을 임의로 늘리지 않았는가
Green통과하는 최소 구현불필요한 추상화가 없는가
Refactor중복 제거와 이름 정리동작이 바뀌지 않았는가
CIPR마다 테스트 실행Node 버전이 실제와 맞는가
운영hooks와CLAUDE.md로 습관화자동화가 너무 느리지 않은가
flowchart LR
  A["사양을 작게 나누기"] --> B["Red: 실패 테스트"]
  B --> C["Green: 최소 구현"]
  C --> D["Refactor: 정리"]
  D --> E["CI와 hooks로 재실행"]
  E --> B

예제1: Vitest로 가격 계산 테스트하기

가격, 쿠폰, 구독 플랜은 TDD에 잘 맞습니다. 작은 계산 오류가 매출과 신뢰에 직접 연결되기 때문입니다.

npm install -D vitest
{
  "type": "module",
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest"
  },
  "devDependencies": {
    "vitest": "^3.0.0"
  }
}

먼저src/cart.test.ts를 작성합니다.

import { describe, expect, it } from "vitest";
import { priceCart, ValidationError } from "./cart";

describe("priceCart", () => {
  it("calculates subtotal and total without a coupon", () => {
    const result = priceCart({
      items: [
        { sku: "book", unitPriceCents: 1200, quantity: 2 },
        { sku: "video", unitPriceCents: 3000, quantity: 1 },
      ],
    });

    expect(result).toEqual({
      subtotalCents: 5400,
      discountCents: 0,
      totalCents: 5400,
    });
  });

  it("applies a valid percent coupon", () => {
    const result = priceCart(
      {
        items: [{ sku: "course", unitPriceCents: 10000, quantity: 1 }],
        coupon: {
          code: "SPRING20",
          percentOff: 20,
          expiresAt: "2026-06-30T00:00:00.000Z",
        },
      },
      { now: new Date("2026-06-02T00:00:00.000Z") },
    );

    expect(result.totalCents).toBe(8000);
    expect(result.discountCents).toBe(2000);
  });

  it("rejects expired coupons", () => {
    expect(() =>
      priceCart(
        {
          items: [{ sku: "course", unitPriceCents: 10000, quantity: 1 }],
          coupon: {
            code: "OLD20",
            percentOff: 20,
            expiresAt: "2026-05-01T00:00:00.000Z",
          },
        },
        { now: new Date("2026-06-02T00:00:00.000Z") },
      ),
    ).toThrow(ValidationError);
  });

  it("rejects zero or negative quantity", () => {
    expect(() =>
      priceCart({
        items: [{ sku: "book", unitPriceCents: 1200, quantity: 0 }],
      }),
    ).toThrow("quantity must be positive");
  });
});

Claude Code에는 Red를 먼저 확인하도록 요청합니다.

현재는 Red 단계입니다. src/cart.test.ts는 있지만 src/cart.ts는 없습니다.

요청:
1. npm test를 실행해 실패를 확인하세요.
2. 테스트를 통과시키는 최소 src/cart.ts만 구현하세요.
3. UI, DB, 외부 API, 미래 기능은 추가하지 마세요.
4. Green 이후에만 동작을 바꾸지 않는 리팩터링을 하세요.

src/cart.ts 구현입니다.

export class ValidationError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "ValidationError";
  }
}

type CartItem = {
  sku: string;
  unitPriceCents: number;
  quantity: number;
};

type Coupon = {
  code: string;
  percentOff: number;
  expiresAt: string;
};

type CartInput = {
  items: CartItem[];
  coupon?: Coupon;
};

type PriceOptions = {
  now?: Date;
};

export function priceCart(input: CartInput, options: PriceOptions = {}) {
  if (input.items.length === 0) {
    throw new ValidationError("cart must contain at least one item");
  }

  const subtotalCents = input.items.reduce((sum, item) => {
    if (!Number.isInteger(item.quantity) || item.quantity <= 0) {
      throw new ValidationError("quantity must be positive");
    }
    if (!Number.isInteger(item.unitPriceCents) || item.unitPriceCents < 0) {
      throw new ValidationError("unitPriceCents must be a non-negative integer");
    }
    return sum + item.unitPriceCents * item.quantity;
  }, 0);

  const discountCents = calculateDiscount(subtotalCents, input.coupon, options.now ?? new Date());

  return {
    subtotalCents,
    discountCents,
    totalCents: subtotalCents - discountCents,
  };
}

function calculateDiscount(subtotalCents: number, coupon: Coupon | undefined, now: Date) {
  if (!coupon) return 0;

  if (coupon.percentOff <= 0 || coupon.percentOff > 100) {
    throw new ValidationError("percentOff must be between 1 and 100");
  }

  if (new Date(coupon.expiresAt).getTime() < now.getTime()) {
    throw new ValidationError("coupon expired");
  }

  return Math.round(subtotalCents * (coupon.percentOff / 100));
}

예제2: node:test로 CLI 경계값 확인

작은 Node 도구라면 표준node:test만으로도 충분합니다. 다음 파일은limit.test.mjs로 저장해 바로 실행할 수 있습니다.

import test from "node:test";
import assert from "node:assert/strict";

export function parseLimit(value, fallback = 20) {
  if (value === undefined || value === "") return fallback;

  const parsed = Number(value);
  if (!Number.isInteger(parsed)) {
    throw new TypeError("limit must be an integer");
  }
  if (parsed < 1 || parsed > 100) {
    throw new RangeError("limit must be between 1 and 100");
  }

  return parsed;
}

test("parseLimit uses fallback when the value is empty", () => {
  assert.equal(parseLimit(undefined), 20);
  assert.equal(parseLimit("", 50), 50);
});

test("parseLimit accepts values from 1 to 100", () => {
  assert.equal(parseLimit("1"), 1);
  assert.equal(parseLimit("100"), 100);
});

test("parseLimit rejects decimals and out-of-range values", () => {
  assert.throws(() => parseLimit("1.5"), /integer/);
  assert.throws(() => parseLimit("0"), /between 1 and 100/);
  assert.throws(() => parseLimit("101"), /between 1 and 100/);
});
node --test limit.test.mjs

예제3: API 버그를 회귀 테스트로 고정

운영 버그는 먼저 실패 테스트로 남겨야 합니다.

TDD로 API 회귀 테스트를 추가하세요.

배경:
- POST /checkout이 만료된 쿠폰을 잘못 허용했습니다.
- 정상 쿠폰과 쿠폰 없는 결제는 그대로 동작해야 합니다.

Red:
- 만료 쿠폰이면 400을 기대하는 테스트를 먼저 추가합니다.
- 현재 구현에서 그 테스트가 실패하는지 확인합니다.

Green:
- 최소 API 수정으로 통과시킵니다.

Refactor:
- 중복된 날짜 비교만 함수로 추출합니다.

반환:
- 테스트 이름, 실패 로그, 변경 파일, 실행 명령, 남은 위험.

관련 내용은API 테스트 가이드테스트 전략 가이드를 함께 보면 좋습니다.

CI와 hooks

CI는 Red-Green-Refactor를 팀 전체에서 유지하게 해 줍니다.

name: test
on:
  pull_request:
  push:
    branches: [main]

jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm test

편집 후 관련 Vitest만 실행하는 hook 예시입니다.

{
  "$schema": "https://json.schemastore.org/claude-code-settings.json",
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": "node .claude/hooks/run-related-vitest.mjs",
            "timeout": 120
          }
        ]
      }
    ]
  }
}
import { spawnSync } from "node:child_process";
import path from "node:path";

let raw = "";
for await (const chunk of process.stdin) {
  raw += chunk;
}

const event = raw ? JSON.parse(raw) : {};
const filePath = event.tool_input?.file_path;

if (typeof filePath !== "string" || !/\.[cm]?[jt]sx?$/.test(filePath)) {
  process.exit(0);
}

const target = path.isAbsolute(filePath)
  ? path.relative(process.cwd(), filePath)
  : filePath;

const result = spawnSync("npx", ["vitest", "related", target, "--run"], {
  stdio: "inherit",
  shell: process.platform === "win32",
});

process.exit(result.status ?? 1);

CLAUDE.md에는 짧은 규칙만 둡니다.

## TDD workflow
- Behavior changes start with a failing test.
- Show the Red result before implementation.
- Implement the smallest change that makes the test pass.
- Refactor only after the targeted test is Green.
- Report the command, result, changed files, and remaining risk.

자세한 설정은hooks 가이드CLAUDE.md 모범 사례를 참고하세요.

프롬프트 템플릿

새 기능 TDD:
목표:
  [기능] 추가.
사양:
  - [정상 흐름]
  - [경계값]
  - [실패 동작]
절차:
  1. 테스트를 먼저 작성.
  2. npm test로 Red 확인.
  3. 최소 구현으로 Green.
  4. 동작을 바꾸지 않고 Refactor.
보고:
  실패 로그, 명령, 변경 파일, 남은 위험.
버그 수정 TDD:
재현:
  [입력/사용자 동작/로그]
기대:
  [올바른 동작]
현재:
  [실제 동작]
요청:
  먼저 실패하는 회귀 테스트를 추가하세요.
  그 다음 최소 수정으로 통과시키세요.
  이유 없이 기존 테스트를 약화하거나 삭제하지 마세요.
안전한 리팩터링:
대상:
  [파일/함수]
제약:
  외부 동작은 바꾸지 않습니다.
단계:
  1. 현재 동작을 characterization test로 고정.
  2. Green 확인.
  3. 내부 구조만 정리.
  4. 같은 테스트를 다시 실행.

흔한 함정

첫째, Red를 건너뛰는 것입니다. 처음부터 통과하는 테스트는 버그를 잡지 못할 수 있습니다. 둘째, 동작이 아니라 구현 세부사항을 테스트하는 것입니다. 셋째, 테스트에서 실제 시간을 직접 쓰는 것입니다. 위 예시처럼now를 주입해야 합니다. 넷째, mock만 믿는 것입니다. 결제, 이메일, CRM은 계약 테스트나 스테이징 확인이 필요합니다. 다섯째, Claude Code가 Green을 만들기 위해 테스트를 삭제하도록 두는 것입니다.

CTA

처음에는 가격 규칙 하나, CLI 파라미터 하나, API 회귀 버그 하나로 시작하세요. 개인 개발자는무료 Claude Code 치트시트와 이 글의 템플릿을 활용하면 됩니다. 재사용 가능한 프롬프트, hooks, 리뷰 체크리스트가 필요하면ClaudeCodeLab 제품을 확인하세요. 팀 저장소에 TDD, CI, 권한, 리뷰 흐름을 적용하려면Claude Code 교육 및 상담이 적합합니다.

실제로 시도한 결과

Masa의 운영에서는 Claude Code에 구현을 먼저 시키는 것보다 실패 테스트를 먼저 만들게 하는 편이 리뷰 시간을 줄였습니다. 만료 쿠폰, 수량 0, 미인증 API처럼 놓치기 쉬운 경계값이 초기에 드러났습니다. 반면 모든 테스트를 hook에서 매번 실행하는 방식은 느렸기 때문에, 편집 직후에는 관련 Vitest만 실행하고 전체 E2E는 CI에 맡기는 구성이 가장 현실적이었습니다.

#Claude Code #TDD #test-driven development #testing #quality assurance
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.