테스트는 코드가 동작하는지 확인하는 일이 아니다

vx_developer·약 10시간 전

개발하다가

목록 보기
45/45
post-thumbnail

프로그래밍을 처음 배울 때 테스트는 보통 함수를 실행하고 예상한 값이 나오는지 확인하는 코드라고 배운다.

import { expect, test } from "vitest";

test("두 숫자를 더한다", () => {
  expect(1 + 2).toBe(3);
});

입력과 출력이 분명한 함수의 기본 동작을 이해하기에는 충분한 설명이다.

하지만 실제 서비스를 개발하면 테스트가 통과한다는 사실만으로는 부족하다.

  • 무엇을 서비스의 정상 동작으로 정의할 것인가?
  • 구현 방식이 바뀌어도 유지되어야 하는 약속은 무엇인가?
  • 데이터베이스와 외부 API의 연결까지 어디에서 검증할 것인가?
  • 시간, 네트워크, 무작위 값 때문에 테스트가 흔들리지 않게 하려면 어떻게 해야 하는가?
  • 모든 흐름을 브라우저에서 테스트해야 하는가?
  • 테스트와 실제 요구사항이 서로 다르면 무엇을 기준으로 판단해야 하는가?

테스트는 코드가 동작하는지 확인하는 일이 아니다.

테스트는 서비스가 사용자와 다른 시스템에 제공하기로 한 약속을 실행 가능한 형태로 기록하고, 코드가 변경된 뒤에도 그 약속이 유지되는지 검증하는 일이다.


함수가 실행된다는 사실보다 어떤 약속을 지키는지가 중요하다

크리스가 도서 대출 서비스를 개발한다고 생각해 보자.

회원은 대출 가능한 책을 빌릴 수 있고, 일반 회원은 동시에 다섯 권까지만 대출할 수 있다.

처음에는 메서드가 호출되었는지를 테스트할 수 있다.

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 테스트는 내부 함수가 아니라 외부 계약을 검증해야 한다

도서 대출 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();
  }
);

이 테스트는 컴포넌트 인스턴스나 내부 상태에 접근하지 않는다. 사용자가 찾을 수 있는 버튼을 클릭하고 화면에 표시되는 결과를 확인한다.

사용자에게 보이는 동작이 유지된다면 내부 구조를 바꿔도 테스트가 통과할 수 있다. 이것이 리팩터링을 돕는 테스트의 중요한 성질이다.


모든 흐름을 End-to-End 테스트로 확인하면 느리고 불안정해질 수 있다

실제 브라우저, 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();
  }
);

이 테스트는 사용자의 핵심 흐름이 실제 구성 요소를 통과하는지 확인한다.

하지만 고수준 테스트는 실행 시간이 길고 실패 원인을 찾기 어렵다. 네트워크 지연, 테스트 데이터, 화면 렌더링 시점의 영향을 받을 수도 있다.

따라서 모든 조건을 브라우저 테스트로 반복하기보다 범위에 맞게 나누는 편이 낫다.

많은 작은 규칙 테스트
        ↓
필요한 통합·계약 테스트
        ↓
소수의 핵심 사용자 흐름 테스트

대출 한도 경계값은 작은 테스트에서 충분히 확인할 수 있다. 브라우저 테스트는 로그인부터 검색, 대출 완료까지 핵심 흐름이 연결되는지 확인하는 데 집중할 수 있다.


Mock은 현실을 없애는 도구가 아니라 경계를 제어하는 도구다

도서 대출 완료 후 이메일을 발송한다고 생각해 보자.

테스트에서 실제 이메일 서비스에 요청하면 실행이 느려지고 실제 메시지가 발송될 수 있다.

외부 경계를 가짜 구현으로 바꿀 수 있다.

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 사이의 호출 순서만 검증하게 된다.

  • 외부 API, 시간, 무작위 값처럼 통제가 필요한 경계는 대체할 수 있다.
  • 순수한 도메인 객체는 가능하면 실제 구현을 사용한다.
  • 데이터베이스 쿼리는 가짜 저장소만으로 끝내지 않고 통합 테스트를 추가한다.
  • 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는 아니다.

더 중요한 질문은 다음과 같다.

  • 핵심 비즈니스 규칙이 검증되는가?
  • 위험한 경계와 실패 경로가 포함되는가?
  • 변경이 기존 사용자 흐름을 깨뜨리면 테스트가 실패하는가?
  • 테스트가 통과하면 배포 판단에 실제로 도움이 되는가?

커버리지 목표를 채우기 위해 의미 없는 테스트를 추가하면 숫자는 높아져도 변경에 대한 신뢰는 높아지지 않는다.


테스트도 틀릴 수 있으므로 요구사항의 Source of Truth를 확인해야 한다

테스트가 실패하면 코드를 테스트에 맞추고 싶어진다. 그러나 테스트가 오래된 정책을 표현하고 있을 수도 있다.

기존 테스트가 일반 회원의 대출 기간을 21일로 가정한다고 생각해 보자.

expect(borrowing.dueAt).toEqual(
  addDays(borrowing.borrowedAt, 21)
);

현재 정책이 14일로 변경되었다면 코드를 21일로 되돌리는 것이 정답은 아니다.

다음 자료를 함께 확인해야 한다.

  • 현재 승인된 비즈니스 정책
  • API 계약과 사용자 안내
  • 데이터베이스에 저장해야 하는 사실
  • 기존 사용자에게 적용되는 이전 정책
  • 테스트가 작성될 당시의 의도

대출 시점에 확정된 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",
});

저장소 호출 순서가 바뀌어도 올바른 대출 결과가 생성되면 테스트는 통과한다.

좋은 테스트는 코드 구조를 얼려두지 않는다.

  • 유지해야 할 동작이 바뀌면 실패한다.
  • 내부 구현만 바뀌면 가능한 한 계속 통과한다.
  • 실패했을 때 어떤 약속이 깨졌는지 알려준다.
  • 변경 후 배포 여부를 판단할 근거를 제공한다.

테스트의 목적은 변화를 줄이는 것이 아니라 안전하게 변화할 수 있는 범위를 넓히는 데 있다.


테스트를 설계하기 전에 무엇을 물어야 하는가

어떤 약속을 검증하는가

  1. 사용자가 기대하는 결과는 무엇인가?
  2. 비즈니스 규칙의 경계값은 어디인가?
  3. 성공뿐 아니라 예상된 거절과 실패도 정의되어 있는가?
  4. 구현이 바뀌어도 유지되어야 하는 동작인가?
  5. 테스트 이름만 읽어도 규칙을 이해할 수 있는가?

어느 범위에서 확인해야 하는가

  1. 순수한 계산은 작은 단위 테스트로 확인할 수 있는가?
  2. 데이터베이스 쿼리와 제약 조건은 실제 저장소로 검증하는가?
  3. 외부 API와의 요청·응답 계약을 확인하는가?
  4. 사용자의 핵심 흐름은 화면이나 End-to-End 테스트로 연결하는가?
  5. 같은 규칙을 여러 계층에서 불필요하게 반복하지 않는가?

테스트가 결정적인 결과를 만드는가

  1. 현재 시간과 시간대를 통제하는가?
  2. 무작위 값과 ID 생성을 통제하는가?
  3. 테스트마다 독립된 데이터를 사용하는가?
  4. 실행 순서에 따라 결과가 달라지지 않는가?
  5. 네트워크와 외부 서비스의 일시적 상태에 의존하지 않는가?

Mock의 경계를 적절히 정했는가

  1. 외부 시스템만 필요한 만큼 대체하는가?
  2. 내부 구현 호출 순서를 과도하게 검증하지 않는가?
  3. Mock의 응답이 실제 계약과 일치하는가?
  4. 데이터베이스 구현을 가짜 객체만으로 검증하고 있지 않은가?
  5. 실패와 시간 초과도 통제해서 확인할 수 있는가?

보안과 검증 경로가 포함되어 있는가

  1. 외부 입력의 형식 오류를 테스트하는가?
  2. 인증되지 않은 사용자와 권한 없는 사용자를 구분하는가?
  3. 다른 회원의 대출 정보에 접근할 수 없는가?
  4. 오류 응답이 내부 정보나 비밀값을 노출하지 않는가?
  5. 로그와 테스트 출력에 민감한 데이터가 남지 않는가?

테스트의 Source of Truth를 확인했는가

  1. 테스트가 현재 승인된 정책을 표현하는가?
  2. 오래된 요구사항을 그대로 고정하고 있지 않은가?
  3. 코드와 테스트가 다를 때 실제 서비스의 약속을 확인하는가?
  4. 파생된 값을 구현과 같은 알고리즘으로 다시 계산해 검증하고 있지 않은가?
  5. 버그 수정 후 재현 조건이 회귀 테스트로 남는가?

흔한 실수는 테스트 통과를 서비스 품질과 동일하게 보는 것이다

메서드가 호출되었는지만 확인한다

Mock 호출은 맞지만 사용자에게 잘못된 결과가 반환될 수 있다.

가능하면 서비스가 외부에 제공하는 결과와 상태 변화를 확인해야 한다.

구현 세부사항을 고정한다

내부 함수 이름과 호출 순서를 검증하면 정상적인 리팩터링에도 테스트가 깨진다.

사용자가 관찰할 수 있는 동작과 공개 계약을 중심으로 검증해야 한다.

모든 것을 End-to-End로 테스트한다

실행 시간이 길어지고 실패 원인을 찾기 어려워진다.

작은 규칙 테스트, 통합 테스트, 소수의 핵심 흐름 테스트를 조합해야 한다.

모든 의존성을 Mock으로 바꾼다

테스트 속에서는 모든 호출이 성공하지만 실제 쿼리와 외부 계약이 깨질 수 있다.

실제 경계를 확인하는 통합·계약 테스트가 필요하다.

현재 시간과 운영 데이터를 그대로 사용한다

날짜, 시간대, 실행 순서와 데이터 변화에 따라 테스트가 불규칙하게 실패한다.

시간과 데이터는 테스트가 명시적으로 소유하고 통제해야 한다.

커버리지 숫자만 목표로 삼는다

검증문이 없는 테스트도 코드를 실행해 커버리지를 높일 수 있다.

중요한 약속과 실패 경로가 보호되는지를 먼저 확인해야 한다.

실패하는 테스트를 무조건 코드 문제로 본다

정책 변경이나 잘못된 기대값 때문에 테스트가 실패할 수도 있다.

요구사항, 코드, 테스트 중 무엇이 현재 약속과 다른지 확인해야 한다.


테스트의 핵심은 변경 후에도 서비스의 약속을 지키는 데 있다

프로그래밍을 처음 배울 때는 테스트를 입력에 대해 예상한 출력이 나오는지 확인하는 코드라고 이해해도 충분하다.

test("두 숫자를 더한다", () => {
  expect(1 + 2).toBe(3);
});

하지만 실제 서비스에서는 현재 코드가 실행되는지만 확인해서는 변경의 안전성을 판단하기 어렵다.

  • 사용자가 의존하는 약속은 무엇인가?
  • 비즈니스 규칙의 경계와 실패 조건은 무엇인가?
  • 구현이 바뀌어도 유지되어야 할 동작은 무엇인가?
  • 작은 단위, 데이터베이스, API, 화면 중 어디에서 검증해야 하는가?
  • 실제 경계와 Mock을 어떻게 나눌 것인가?
  • 시간과 외부 시스템을 통제해 같은 결과를 만들 수 있는가?
  • 테스트가 실패했을 때 어떤 약속이 깨졌는지 알 수 있는가?
  • 테스트가 현재 정책과 요구사항을 표현하는가?
  • 버그가 회귀 테스트로 남아 있는가?
  • 테스트 결과가 배포 판단에 실질적인 근거가 되는가?

테스트는 코드가 동작하는지 확인하는 일이 아니다.

테스트는 서비스가 사용자와 다른 시스템에 제공하기로 한 약속을 실행 가능한 형태로 기록하고, 코드가 변경된 뒤에도 그 약속이 유지되는지 검증하는 일이다.


참고 자료

profile
Vision eXperience Developer

0개의 댓글