테스트 기본 #단위 테스트, 통합 테스트, 시스템 테스트, 테스트 더블과 Jest

테스트의 목적

소프트웨어 테스트는 코드가 의도한 대로 동작하는지 검증하고, 변경으로 인해 기존 기능이 손상되는 것을 방지하는 개발 활동이다.

코드가 정상적으로 실행된다는 사실만으로는 올바른 동작을 보장할 수 없다. 예상하지 못한 입력, 외부 시스템의 장애, 동시 요청, 데이터베이스 오류 등 다양한 상황에서 문제가 발생할 수 있기 때문이다.

테스트의 핵심 목적은 다음과 같다.

  • 정확성 검증: 요구사항과 구현 결과가 일치하는지 확인한다.
  • 회귀 방지: 기존 기능을 수정한 뒤 다른 기능이 손상되지 않았는지 검증한다.
  • 장애 조기 발견: 개발 단계에서 오류를 발견해 수정 비용을 줄인다.
  • 설계 개선: 테스트하기 어려운 코드를 식별하고 의존성을 분리한다.
  • 변경 안정성 확보: 리팩터링이나 기능 추가 이후에도 기존 동작을 보장한다.

테스트는 오류가 없음을 증명하는 수단이 아니다. 특정 조건에서 기대한 동작이 유지되는지를 검증하는 수단이다.

단위 테스트(Unit Test)

단위 테스트는 함수, 클래스, 모듈처럼 독립적으로 검증할 수 있는 작은 단위를 대상으로 수행하는 테스트다.

일반적으로 데이터베이스나 네트워크 같은 외부 의존성을 분리하고, 테스트 대상의 로직 자체를 검증한다.

예를 들어 게임 서버에서 포인트를 지급하는 함수가 있다고 가정하자.

function calculateReward(score) {
  if (score < 0) {
    throw new Error("Invalid score");
  }

  return score * 10;
}

module.exports = { calculateReward };

이 함수의 단위 테스트는 점수에 따른 보상 계산과 잘못된 입력에 대한 예외 처리를 검증한다.

const { calculateReward } = require("./reward");

test("점수에 비례해 보상을 계산한다", () => {
  expect(calculateReward(100)).toBe(1000);
});

test("음수 점수는 오류를 발생시킨다", () => {
  expect(() => calculateReward(-1))
    .toThrow("Invalid score");
});

단위 테스트는 실행 속도가 빠르고 실패 원인을 파악하기 쉽다. 따라서 다양한 입력과 경계 조건을 검증하는 데 적합하다.

특히 다음과 같은 로직은 단위 테스트의 효과가 크다.

  • 포인트와 보상 계산
  • 게임 승패 판정
  • 입력값 검증
  • 권한 확인 로직
  • 점수 및 랭킹 계산
  • 데이터 변환과 정규화
  • 예외 처리 및 경계 조건

단위 테스트에서 주의할 점은 구현 세부 사항에 지나치게 의존하지 않는 것이다. 내부 함수 호출 순서나 변수 구조까지 고정하면 코드의 동작은 동일해도 리팩터링할 때마다 테스트를 수정해야 할 수 있다.

핵심은 내부 구현이 아니라 외부에서 관찰할 수 있는 동작을 검증하는 것이다.

통합 테스트(Integration Test)

통합 테스트는 여러 모듈이나 시스템 구성 요소가 연결되었을 때 올바르게 동작하는지 검증한다.

개별 함수가 정상적으로 동작하더라도 모듈 간의 데이터 전달, 데이터베이스 쿼리, 트랜잭션 처리, 외부 서비스 연동에서 오류가 발생할 수 있다.

예를 들어 게임 서버에서 포인트를 지급하는 기능은 다음 구성 요소를 포함할 수 있다.

  • 보상 지급 서비스
  • 포인트 원장 저장소
  • 사용자 잔액 데이터베이스
  • 트랜잭션 처리 로직

단위 테스트에서는 보상 계산 결과를 검증할 수 있지만, 통합 테스트에서는 실제 데이터베이스와 연결해 지급 결과가 올바르게 저장되는지 확인할 수 있다.

test("보상 지급 결과가 데이터베이스에 반영된다", async () => {
  const user = await createTestUser({
    points: 100
  });

  await rewardService.grant(user.id, 50);

  const updatedUser = await userRepository.findById(
    user.id
  );

  expect(updatedUser.points).toBe(150);
});

위 코드는 테스트용 데이터베이스와 저장소 구현이 준비되어 있다는 가정하에 작성한 예시다.

통합 테스트는 실제 의존성을 사용하는 범위에 따라 다양한 형태로 구성할 수 있다. 예를 들어 실제 데이터베이스를 사용하거나, 외부 결제 서비스 대신 테스트 서버를 사용할 수 있다.

주요 검증 대상은 다음과 같다.

  • 데이터베이스 저장 및 조회
  • 트랜잭션의 커밋과 롤백
  • API와 서비스 계층 간의 연동
  • 캐시와 데이터베이스 간의 정합성
  • 메시지 브로커를 통한 이벤트 전달
  • 인증 미들웨어와 권한 검증의 연결

통합 테스트는 단위 테스트보다 실행 비용이 높다. 데이터베이스 초기화, 테스트 데이터 정리, 외부 서비스 준비 등이 필요하기 때문이다.

따라서 모든 테스트를 통합 테스트로 작성하기보다는, 구성 요소 간의 연결에서 발생할 수 있는 오류를 집중적으로 검증하는 편이 효율적이다.

시스템 테스트(System Test)

시스템 테스트는 완성된 시스템을 전체적인 관점에서 검증하는 테스트다.

개별 함수나 모듈이 아니라 시스템이 요구사항을 충족하는지 확인한다. 실제 사용자 요청부터 비즈니스 로직 실행, 데이터 저장, 결과 반환까지 전체 흐름을 검증할 수 있다.

예를 들어 멀티플레이어 게임 플랫폼에서 경기 결과에 따른 보상을 지급하는 기능을 생각해 보자.

사용자가 경기 결과를 제출하면 시스템은 다음 과정을 수행한다.

  • 요청을 수신하고 사용자를 인증한다.
  • 요청 데이터와 경기 결과를 검증한다.
  • 경기 결과를 저장한다.
  • 보상 지급을 처리한다.
  • 처리 결과를 사용자에게 반환한다.

시스템 테스트는 이 흐름이 하나의 기능으로 올바르게 작동하는지 검증한다.

주요 검증 대상은 다음과 같다.

  • 사용자 로그인부터 주요 기능 실행까지의 전체 흐름
  • 경기 종료부터 결과 저장 및 보상 지급까지의 과정
  • 잘못된 요청에 대한 오류 응답
  • 권한이 없는 사용자의 접근 차단
  • 장애 상황에서의 복구 동작
  • 성능 및 부하 요구사항 충족 여부

시스템 테스트는 실제 운영 환경과 유사한 조건에서 수행하는 것이 이상적이다. 다만 테스트 환경을 운영 환경과 완전히 동일하게 구성하기는 어렵기 때문에, 환경 차이와 외부 의존성의 영향을 고려해야 한다.

시스템 테스트와 인수 테스트는 관련이 있지만 목적이 완전히 같지는 않다. 시스템 테스트가 시스템 전체의 요구사항 충족 여부를 검증한다면, 인수 테스트는 사용자나 발주자의 수용 기준을 충족하는지 확인하는 데 초점을 둔다.

테스트 수준의 차이

구분단위 테스트통합 테스트시스템 테스트
검증 대상개별 함수 및 모듈구성 요소 간 연결전체 시스템
주요 목적로직의 정확성연동의 정확성요구사항 충족 여부
외부 의존성대체로 분리필요한 의존성을 연결실제 환경과 유사하게 구성
실행 비용낮음중간상대적으로 높음
실패 원인 파악비교적 쉬움연결 관계를 조사해야 함여러 계층을 추적할 수 있음
대표 사례보상 계산 검증DB 저장 및 트랜잭션 검증경기 결과부터 보상 지급까지 검증

효율적인 테스트 전략은 특정 테스트 수준 하나에 집중하지 않는다. 빠르고 세밀한 단위 테스트를 기반으로 통합 테스트를 추가하고, 핵심 사용자 흐름을 시스템 테스트로 검증하는 방식이 효과적이다.

테스트 더블(Test Double)

테스트 더블은 테스트 대상이 의존하는 실제 객체를 대체하기 위해 사용하는 객체나 구현체를 의미한다.

외부 시스템을 직접 호출하면 테스트가 느려지거나, 네트워크 상태와 외부 서비스의 데이터에 따라 결과가 달라질 수 있다. 테스트 더블은 이러한 의존성을 통제해 테스트를 독립적이고 예측 가능하게 만든다.

대표적인 유형은 다음과 같다.

Dummy

실제 동작에 사용하지 않고, 매개변수나 의존성을 채우기 위해 전달하는 객체다.

Stub

테스트에서 미리 정해진 값을 반환하는 객체다.

예를 들어 사용자 조회 결과를 고정해 서비스 로직만 검증할 수 있다.

const userRepository = {
  findById: async () => ({
    id: "user-a",
    points: 100
  })
};

Fake

실제 구현보다 단순하지만, 동작 가능한 대체 구현이다.

예를 들어 실제 데이터베이스 대신 메모리에 데이터를 저장하는 저장소를 만들 수 있다. Fake는 실제 시스템의 모든 특성을 재현하지 않으므로, 실제 데이터베이스의 트랜잭션이나 인덱스 동작까지 검증할 수는 없다.

Spy

객체나 함수의 호출 여부, 전달된 인자, 호출 횟수 등을 관찰하는 데 사용한다.

예를 들어 보상 지급 과정에서 알림 서비스가 호출되었는지 확인할 수 있다.

Mock

기대하는 호출 방식과 인자를 설정하고, 실제 실행 결과가 기대와 일치하는지 검증하는 객체다.

Jest에서는 Mock 함수를 사용해 함수 호출을 기록하고 반환값이나 동작을 제어할 수 있다.

const sendNotification = jest.fn();

sendNotification("user-a", "Reward received");

expect(sendNotification).toHaveBeenCalledWith(
  "user-a",
  "Reward received"
);

실무에서는 Stub, Spy, Mock의 기능이 겹칠 수 있다. 따라서 용어 자체를 엄격하게 구분하기보다는, 테스트 더블을 사용하는 목적이 무엇인지 명확히 하는 것이 중요하다.

테스트 더블은 외부 의존성을 분리하는 데 유용하지만, 모든 의존성을 Mock으로 대체하면 실제 시스템과 테스트 사이에 차이가 커질 수 있다. 특히 데이터베이스 쿼리, 트랜잭션, 메시지 전달처럼 실제 연동이 중요한 부분은 통합 테스트로 검증해야 한다.

Jest

Jest는 JavaScript 및 TypeScript 프로젝트에서 널리 사용되는 테스트 프레임워크다.

테스트 실행, 결과 비교, Mock 함수, 비동기 테스트, 코드 커버리지 측정 등의 기능을 제공한다. Node.js 기반 서버나 웹 애플리케이션의 자동화된 테스트를 구성할 때 활용할 수 있다.

설치 및 설정

Node.js 프로젝트에서 개발 의존성으로 Jest를 설치한다.

npm install --save-dev jest

package.json에 테스트 실행 스크립트를 추가한다.

{
  "scripts": {
    "test": "jest"
  }
}

이후 다음 명령으로 테스트를 실행할 수 있다.

npm test

Jest는 기본적으로 *.test.js, *.spec.js 등의 패턴에 해당하는 테스트 파일을 찾는다. 프로젝트 구성에 따라 테스트 파일의 위치나 실행 옵션을 조정할 수 있다.

테스트와 Matcher

Jest에서 test 또는 it은 테스트 케이스를 정의하고, expect는 실제 결과를 기대값과 비교한다.

자주 사용하는 Matcher는 다음과 같다.

  • toBe: 원시값이나 동일성 비교
  • toEqual: 객체와 배열의 구조 비교
  • toStrictEqual: 더 엄격한 구조 비교
  • toThrow: 예외 발생 검증
  • toContain: 배열이나 문자열의 포함 여부 검증
  • toHaveBeenCalledWith: Mock 함수에 전달된 인자 검증
  • toHaveBeenCalledTimes: Mock 함수의 호출 횟수 검증

테스트 이름은 구현 방식보다 검증하려는 동작을 설명하도록 작성하는 편이 좋다.

비동기 테스트

Node.js 서버는 데이터베이스 조회, 네트워크 요청, 메시지 처리 등 비동기 작업을 자주 수행한다.

Jest에서는 Promise를 반환하거나 async/await를 사용해 비동기 결과를 검증할 수 있다.

test("사용자 정보를 조회한다", async () => {
  const user = await userService.findById("user-a");

  expect(user.id).toBe("user-a");
});

비동기 테스트에서 Promise를 기다리지 않으면 작업이 완료되기 전에 테스트가 종료될 수 있다. 따라서 await를 사용하거나 Promise를 반환해 Jest가 완료 시점을 알 수 있도록 해야 한다.

예외 발생을 검증할 때도 비동기 작업이라면 다음과 같이 작성한다.

test("존재하지 않는 사용자는 오류를 반환한다", async () => {
  await expect(
    userService.findById("missing-user")
  ).rejects.toThrow();
});

Mock을 이용한 외부 의존성 분리

예를 들어 보상 지급 서비스가 알림 서비스를 호출한다고 가정하자.

보상 지급 자체를 검증하는 테스트에서 실제 이메일이나 푸시 알림을 발송할 필요는 없다. 알림 함수를 Mock으로 대체하면 외부 통신 없이 호출 여부와 인자를 검증할 수 있다.

test("보상 지급 후 알림을 전송한다", async () => {
  const sendNotification = jest.fn();

  const rewardService = createRewardService({
    sendNotification
  });

  await rewardService.grant("user-a", 100);

  expect(sendNotification).toHaveBeenCalledWith(
    "user-a",
    expect.any(String)
  );
});

위 예시는 createRewardService가 의존성을 주입받도록 구현되어 있다는 가정하에 작성했다. 실제 프로젝트에서는 서비스가 의존성을 전달받도록 설계하면 테스트 더블을 적용하기 쉽다.

코드 커버리지

코드 커버리지는 테스트가 코드의 어느 부분을 실행했는지 측정하는 지표다.

Jest에서는 다음 명령으로 커버리지를 확인할 수 있다.

npx jest --coverage

대표적인 측정 지표는 다음과 같다.

  • Statement Coverage: 실행된 코드 문장의 비율
  • Branch Coverage: 실행된 조건 분기의 비율
  • Function Coverage: 호출된 함수의 비율
  • Line Coverage: 실행된 코드 행의 비율

커버리지가 높더라도 테스트가 올바른 결과를 검증한다는 보장은 없다. 모든 코드가 실행되더라도 잘못된 결과를 기대값과 비교하거나, 중요한 예외 상황을 검증하지 않으면 테스트의 실효성이 떨어진다.

따라서 커버리지는 테스트 품질을 평가하는 참고 지표로 활용하고, 핵심 비즈니스 규칙과 실패 상황이 제대로 검증되는지 함께 확인해야 한다.

실무 테스트 전략

게임 서버나 웹 API를 개발할 때는 기능의 중요도와 장애 위험에 따라 테스트 수준을 선택해야 한다.

단위 테스트에 집중할 영역

  • 포인트 계산과 보상 지급 규칙
  • 승패 판정과 점수 계산
  • 입력값 검증 및 권한 판단
  • 멱등성 키와 중복 요청 판정 로직

통합 테스트에 집중할 영역

  • 포인트 원장과 사용자 잔액의 데이터 정합성
  • 데이터베이스 트랜잭션과 롤백
  • Redis 캐시와 데이터베이스 연동
  • 메시지 브로커를 통한 이벤트 전달
  • API와 인증 미들웨어의 연결

시스템 테스트에 집중할 영역

  • 로그인부터 게임 기능 실행까지의 사용자 흐름
  • 경기 결과 저장과 보상 지급의 전체 과정
  • 중복 요청에 대한 일관된 처리 결과
  • 장애 발생 이후의 복구 및 재처리 과정

테스트를 작성할 때는 정상적인 상황만 확인해서는 안 된다. 잘못된 입력, 데이터가 없는 상황, 외부 서비스의 실패, 중복 요청, 동시 실행 등 실제 장애로 이어질 수 있는 조건을 함께 검증해야 한다.

특히 게임 경제 시스템에서는 보상 지급 요청이 재시도되더라도 포인트가 중복 지급되지 않는지 확인하는 것이 중요하다. 이러한 요구사항은 단위 테스트에서 중복 판정 로직을 검증하고, 통합 테스트에서 데이터베이스 제약 조건과 트랜잭션을 검증하며, 시스템 테스트에서 전체 요청 흐름을 검증하는 방식으로 나눌 수 있다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다