테스트 코드가 없는 AI 프로젝트에 첫 단위 테스트 추가하기

AI가 만든 프로젝트에는 왜 테스트가 없을까?

바이브 코딩으로 프로젝트를 만들면 로그인 화면, 게시판, 결제 계산, API 연결 같은 기능을 빠르게 구현할 수 있다. 하지만 AI는 사용자가 요청한 화면과 기능을 완성하는 데 집중하기 때문에 테스트 코드는 생략하는 경우가 많다.

테스트가 없는 프로젝트도 처음에는 정상적으로 실행될 수 있다. 문제는 기능을 추가하거나 AI에게 기존 코드를 수정해 달라고 요청했을 때 발생한다. 게시판의 정렬 방식을 바꿨는데 검색 기능이 고장 나거나, 할인 계산을 수정했는데 정상 가격까지 잘못 계산되는 식이다.

단위 테스트는 특정 함수나 작은 로직에 값을 입력한 뒤 예상한 결과가 나오는지 자동으로 확인하는 코드다. 한 번 작성해 두면 AI가 해당 코드를 다시 수정했을 때 기존 동작이 깨졌는지 빠르게 알 수 있다.

첫 테스트는 어디에 작성해야 할까?

테스트가 하나도 없는 프로젝트에서는 가장 중요한 기능부터 테스트하고 싶어지기 쉽다. 하지만 로그인, 데이터베이스, 결제, 파일 업로드처럼 외부 시스템과 연결된 기능은 첫 단위 테스트 대상으로 적합하지 않다.

처음에는 다음 조건에 해당하는 함수를 선택하는 것이 좋다.

  • 입력값과 반환값이 분명하다.
  • 데이터베이스에 연결하지 않는다.
  • 외부 API를 호출하지 않는다.
  • 브라우저 화면을 직접 조작하지 않는다.
  • 현재 시간이나 난수에 따라 결과가 바뀌지 않는다.
  • 실패했을 때 원인을 한 파일 안에서 확인할 수 있다.

가격 계산, 문자열 변환, 입력값 검증, 배열 정렬, 권한 판별과 같은 함수가 좋은 후보가 될 수 있다.

예를 들어 다음과 같은 가격 계산 함수가 있다고 가정해 보자.

export function calculateFinalPrice(
  price: number,
  discountRate: number,
): number {
  return Math.round(price * (1 - discountRate));
}

입력값이 같으면 항상 같은 결과를 반환하고 외부 서비스에 의존하지 않기 때문에 첫 단위 테스트 대상으로 적합하다.

프로젝트에 이미 테스트 도구가 있는지 확인하기

새로운 패키지를 설치하기 전에 package.json을 열어 다음 이름이 있는지 확인한다.

vitest
jest
mocha
@testing-library/react
node:test

이미 Jest나 Vitest가 설치되어 있다면 별도의 테스트 도구를 추가하지 말고 기존 도구를 사용해야 한다. 테스트 도구가 여러 개 섞이면 설정 파일과 명령어가 복잡해지고 AI가 서로 다른 문법의 테스트를 생성할 수 있다.

Vite 기반 React, Vue, Svelte 또는 TypeScript 프로젝트라면 Vitest를 사용하기 편리하다. Vitest는 Vite의 설정, 변환 기능과 플러그인을 활용하며 TypeScript와 JSX도 지원한다. Vitest 공식 기능 문서

별도의 프레임워크가 없는 간단한 Node.js 프로젝트라면 Node.js에 포함된 node:test도 선택할 수 있다. Node.js 테스트 러너는 node --test 명령으로 실행할 수 있다. Node.js 테스트 러너 공식 문서

이번 글에서는 바이브 코딩으로 자주 만들어지는 Vite·TypeScript 프로젝트를 기준으로 Vitest를 사용한다.

Vitest 설치하기

프로젝트 루트에서 다음 명령을 실행한다.

npm install --save-dev vitest

설치가 끝나면 package.jsonscripts에 테스트 명령을 추가한다.

{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run"
  }
}

npm run test는 개발 중 파일 변경을 감지해 관련 테스트를 다시 실행하는 용도로 사용할 수 있다. 한 번만 실행하고 결과를 확인하려면 다음 명령이 적합하다.

npm run test:run

Vitest 공식 문서에 따르면 기본 테스트 파일 이름에는 .test. 또는 .spec.이 포함되어야 한다. 또한 vitest run을 사용하면 감시 모드 없이 테스트를 한 번 실행할 수 있다. Vitest 시작 문서

첫 테스트 파일 만들기

앞에서 확인한 함수가 다음 경로에 있다고 가정한다.

src/utils/calculateFinalPrice.ts

같은 폴더에 다음 테스트 파일을 만든다.

src/utils/calculateFinalPrice.test.ts

테스트 파일 내용은 다음과 같이 작성할 수 있다.

import { describe, expect, test } from "vitest";
import { calculateFinalPrice } from "./calculateFinalPrice";

describe("calculateFinalPrice", () => {
  test("10% 할인된 가격을 반환한다", () => {
    const result = calculateFinalPrice(10000, 0.1);

    expect(result).toBe(9000);
  });
});

각 부분의 역할은 다음과 같다.

describe는 관련 테스트를 하나의 그룹으로 묶는다. 함수 이름을 그룹 이름으로 사용하면 어떤 코드를 검사하는지 쉽게 알 수 있다.

test는 하나의 동작을 검증한다. 테스트 이름은 구현 방식보다 사용자가 기대하는 동작을 적는 것이 좋다.

expect는 실제 결과와 기대 결과를 비교한다. 위 코드에서는 계산 결과가 9000인지 확인한다.

테스트를 직접 실행해 보기

테스트 파일을 저장한 뒤 다음 명령을 실행한다.

npm run test:run

정상적으로 통과하면 대략 다음과 같은 결과를 확인할 수 있다.

✓ calculateFinalPrice.test.ts
  ✓ 10% 할인된 가격을 반환한다

Test Files  1 passed
Tests       1 passed

테스트가 실패한다면 실패 자체를 나쁜 결과로 보면 안 된다. 오히려 테스트가 기존 코드와 기대 동작 사이의 차이를 발견했다는 뜻이다.

실패했을 때는 다음 순서로 확인한다.

  1. 테스트의 기대값이 실제 요구사항과 맞는지 확인한다.
  2. 잘못된 파일이나 함수를 가져오지 않았는지 확인한다.
  3. 함수가 예상과 다른 단위의 값을 받는지 확인한다.
  4. 구현 코드에 실제 오류가 있는지 확인한다.
  5. 테스트를 통과시키기 위해 기대값만 바꾸지 않는다.

AI에게 테스트를 작성하게 하면 현재 구현 결과를 그대로 기대값으로 복사하는 경우가 있다. 그렇게 작성된 테스트는 코드가 지금처럼 동작한다는 사실만 확인할 뿐, 동작이 올바른지는 검증하지 못한다. 기대값은 코드가 아니라 서비스 요구사항을 기준으로 결정해야 한다.

정상 값 하나로 끝내지 말고 경계값을 추가하자

첫 테스트가 통과했다면 같은 함수에 두세 개의 경계값 테스트를 추가한다.

import { describe, expect, test } from "vitest";
import { calculateFinalPrice } from "./calculateFinalPrice";

describe("calculateFinalPrice", () => {
  test("10% 할인된 가격을 반환한다", () => {
    expect(calculateFinalPrice(10000, 0.1)).toBe(9000);
  });

  test("할인이 없으면 원래 가격을 반환한다", () => {
    expect(calculateFinalPrice(10000, 0)).toBe(10000);
  });

  test("100% 할인이면 0원을 반환한다", () => {
    expect(calculateFinalPrice(10000, 1)).toBe(0);
  });

  test("소수점 결과는 가장 가까운 정수로 반올림한다", () => {
    expect(calculateFinalPrice(9999, 0.15)).toBe(8499);
  });
});

경계값은 정상적인 중간 값보다 오류를 더 잘 발견한다. 가격 계산이라면 할인 없음, 전체 할인, 0원, 소수점 결과를 확인할 수 있다. 배열이라면 빈 배열과 요소 하나만 있는 배열을 검사할 수 있다.

단, 코드에 정의되지 않은 규칙을 테스트가 마음대로 만들어서는 안 된다. 음수 가격이나 100%를 초과한 할인율을 오류로 처리할 것인지 먼저 요구사항을 정해야 한다.

오류 입력도 테스트하려면 요구사항부터 정하자

가격이 음수이거나 할인율이 범위를 벗어났을 때 오류를 발생시키기로 결정했다면 구현은 다음과 같이 바뀔 수 있다.

export function calculateFinalPrice(
  price: number,
  discountRate: number,
): number {
  if (price < 0) {
    throw new Error("가격은 0 이상이어야 합니다.");
  }

  if (discountRate < 0 || discountRate > 1) {
    throw new Error("할인율은 0부터 1 사이여야 합니다.");
  }

  return Math.round(price * (1 - discountRate));
}

오류 발생 여부는 다음과 같이 테스트한다.

test("음수 가격을 입력하면 오류가 발생한다", () => {
  expect(() => calculateFinalPrice(-1000, 0.1)).toThrow(
    "가격은 0 이상이어야 합니다.",
  );
});

test("1보다 큰 할인율을 입력하면 오류가 발생한다", () => {
  expect(() => calculateFinalPrice(10000, 1.2)).toThrow(
    "할인율은 0부터 1 사이여야 합니다.",
  );
});

여기서 중요한 점은 테스트와 구현을 동시에 AI에게 통째로 맡기지 않는 것이다. 먼저 오류 입력을 어떻게 처리할지 결정하고 테스트를 작성한 다음 구현을 수정해야 한다.

첫 테스트에서 모킹을 피해야 하는 이유

모킹은 데이터베이스나 외부 API처럼 실제 의존성을 대신하는 가짜 객체를 만드는 방법이다. 유용한 기능이지만 첫 테스트부터 사용하면 무엇을 검증하는지 이해하기 어려워진다.

다음과 같은 코드가 첫 테스트 대상이라면 범위가 너무 넓을 수 있다.

async function createOrder() {
  const user = await getCurrentUser();
  const product = await fetchProduct();
  const payment = await requestPayment();
  return saveOrder();
}

이 함수는 사용자 인증, 상품 API, 결제 서비스, 데이터 저장소에 모두 의존한다. 첫 단위 테스트로 선택하면 여러 모킹 설정이 필요하다.

대신 계산이나 검증 부분을 작은 함수로 분리한다.

export function canPurchase(
  stock: number,
  requestedQuantity: number,
): boolean {
  return stock >= requestedQuantity && requestedQuantity > 0;
}

작은 순수 함수부터 테스트하면 복잡한 설정 없이 핵심 규칙을 보호할 수 있다. 이후 테스트에 익숙해졌을 때 외부 API와 모킹을 다루는 것이 좋다.

코드 커버리지 100%를 목표로 하면 안 되는 이유

Vitest는 V8 또는 Istanbul 기반 코드 커버리지를 지원한다. 다음과 같은 스크립트로 커버리지 검사를 실행할 수 있다.

{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage"
  }
}

하지만 테스트가 하나도 없던 프로젝트에서 처음부터 커버리지 100%를 목표로 하면 의미가 약한 테스트를 대량으로 만들 가능성이 높다.

코드 커버리지는 어떤 코드가 실행됐는지를 보여줄 뿐 그 결과를 올바르게 검증했는지까지 보장하지 않는다. 처음에는 결제 금액, 권한 판별, 입력값 검증처럼 잘못됐을 때 피해가 큰 로직을 우선적으로 테스트하는 것이 좋다. Vitest 코드 커버리지 문서

AI에게 첫 단위 테스트를 요청하는 프롬프트

AI가 구현 코드를 임의로 변경하지 않도록 요청 범위를 구체적으로 제한해야 한다.

다음 함수에 Vitest 단위 테스트를 추가해 줘.

조건:
1. 먼저 함수의 입력값, 반환값, 예외 조건을 정리해 줘.
2. 현재 구현 코드는 수정하지 마.
3. 정상 값, 경계값, 잘못된 입력을 구분해 테스트해 줘.
4. 외부 API나 데이터베이스가 필요 없는 테스트만 작성해 줘.
5. 각 기대값이 어떤 요구사항에서 나온 것인지 설명해 줘.
6. 테스트 파일 하나만 생성해 줘.
7. 실행 명령어와 예상 결과를 알려 줘.
8. 테스트를 통과시키기 위해 기대값을 구현 결과에 맞추지 마.

생성된 테스트를 바로 적용하기 전에는 함수 이름, import 경로, 기대값, 테스트 설명이 실제 프로젝트와 일치하는지 확인해야 한다.

첫 단위 테스트를 추가한 후 확인할 항목

테스트가 통과하면 다음 사항을 마지막으로 점검한다.

  • 테스트 파일 이름에 .test. 또는 .spec.이 포함됐는가?
  • 테스트가 실제 구현 파일을 가져오고 있는가?
  • 기대값이 현재 구현이 아니라 요구사항을 기준으로 작성됐는가?
  • 같은 입력으로 실행할 때 항상 같은 결과가 나오는가?
  • 네트워크와 실제 데이터베이스에 접근하지 않는가?
  • 정상 값뿐 아니라 경계값도 포함했는가?
  • 테스트를 일부러 실패시켰을 때 실패를 감지하는가?
  • npm run test:run이 정상 종료되는가?
  • AI가 운영 코드를 불필요하게 수정하지 않았는가?
  • 변경된 파일을 Git Diff로 검토했는가?

마무리

테스트가 없는 AI 프로젝트에 처음부터 거대한 테스트 시스템을 만들 필요는 없다. 입력과 출력이 분명한 함수 하나를 선택하고 정상 값과 경계값을 검증하는 테스트 파일 하나부터 시작하면 된다.

첫 단위 테스트의 가장 중요한 역할은 테스트 개수를 늘리는 것이 아니다. AI가 다음 코드를 수정했을 때 기존 기능이 깨지는 순간을 자동으로 알려주는 작은 안전망을 만드는 것이다.

가격 계산 함수 하나를 보호했다면 다음에는 입력값 검증, 권한 판별, 데이터 정렬처럼 중요한 순수 함수로 범위를 넓혀갈 수 있다. 이렇게 작은 테스트를 누적하면 바이브 코딩 프로젝트도 수정할수록 불안해지는 코드가 아니라 변경 결과를 확인할 수 있는 프로젝트로 발전한다.

댓글 남기기