🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

Testing로 만드는 실전 프로젝트 완벽 가이드 - 슬라이드 1/9
A

AI Generated

2025. 11. 5. · 15 Views

Testing로 만드는 실전 프로젝트 완벽 가이드

실무에서 바로 활용할 수 있는 테스팅 기법부터 TDD, 통합 테스트, E2E 테스트까지 완벽하게 다루는 가이드입니다. 초급 개발자도 쉽게 따라하며 테스트 코드 작성의 모든 것을 배울 수 있습니다.


목차

  1. 단위_테스트_기초
  2. 테스트_주도_개발_TDD
  3. Mocking과_Stubbing
  4. 통합_테스트_Integration_Test
  5. E2E_테스트
  6. 테스트_커버리지
  7. 비동기_코드_테스팅
  8. 테스트_더블_Test_Double

1. 단위 테스트 기초

시작하며

여러분이 새로운 기능을 개발하고 배포했는데, 며칠 후 사용자로부터 "로그인이 안 돼요"라는 제보를 받은 적 있나요? 급하게 코드를 확인해보니 다른 기능을 수정하면서 로그인 로직이 깨진 것을 발견하게 됩니다.

이런 상황은 정말 당황스럽고, 팀 전체의 신뢰도에도 영향을 미칩니다. 이런 문제는 실제 개발 현장에서 너무나 자주 발생합니다.

코드를 수정할 때마다 기존 기능이 잘 작동하는지 일일이 수동으로 확인하기는 불가능하기 때문입니다. 작은 수정 하나가 예상치 못한 곳에서 버그를 만들어내는 것이죠.

바로 이럴 때 필요한 것이 단위 테스트입니다. 함수나 메서드 하나하나를 자동으로 검증하여, 코드 수정 후에도 모든 기능이 정상 작동하는지 몇 초 만에 확인할 수 있습니다.

개요

간단히 말해서, 단위 테스트는 프로그램의 가장 작은 단위(주로 함수나 메서드)가 의도한 대로 작동하는지 검증하는 자동화된 테스트입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 개발자가 코드를 수정할 때마다 전체 애플리케이션을 실행하고 모든 기능을 클릭해보는 것은 비효율적이고 실수하기 쉽습니다.

단위 테스트가 있으면 코드 변경 후 몇 초 만에 자동으로 검증할 수 있어 개발 속도가 크게 향상됩니다. 예를 들어, 결제 금액 계산 함수를 수정했을 때 테스트 코드를 실행하면 즉시 모든 케이스가 정상 작동하는지 확인할 수 있습니다.

기존에는 개발자가 직접 브라우저를 열고 여러 시나리오를 하나씩 테스트했다면, 이제는 자동화된 테스트가 수백 개의 시나리오를 1-2초 만에 검증해줍니다. 단위 테스트의 핵심 특징은 세 가지입니다.

첫째, 독립성 - 각 테스트는 다른 테스트에 영향을 받지 않습니다. 둘째, 빠른 실행 속도 - 수백 개의 테스트도 몇 초 안에 완료됩니다.

셋째, 명확한 실패 원인 - 테스트가 실패하면 정확히 어떤 함수의 어떤 부분이 문제인지 바로 알 수 있습니다. 이러한 특징들이 개발자가 자신감을 가지고 코드를 리팩토링하고 새 기능을 추가할 수 있게 해줍니다.

코드 예제

// 테스트할 함수: 할인가 계산
function calculateDiscount(price, discountPercent) {
  if (price < 0 || discountPercent < 0 || discountPercent > 100) {
    throw new Error('Invalid input');
  }
  return price * (1 - discountPercent / 100);
}

// Jest를 사용한 단위 테스트
describe('calculateDiscount', () => {
  test('정상적인 할인율 적용', () => {
    expect(calculateDiscount(10000, 20)).toBe(8000);
  });

  test('할인율 0%일 때 원가 반환', () => {
    expect(calculateDiscount(10000, 0)).toBe(10000);
  });

  test('음수 가격은 에러 발생', () => {
    expect(() => calculateDiscount(-100, 10)).toThrow('Invalid input');
  });

  test('100% 초과 할인율은 에러 발생', () => {
    expect(() => calculateDiscount(10000, 150)).toThrow('Invalid input');
  });
});

설명

이것이 하는 일: 위 코드는 할인가를 계산하는 함수와 그 함수가 올바르게 작동하는지 검증하는 테스트 코드입니다. 실무에서 가격 계산은 매우 중요한 로직이므로 반드시 테스트해야 합니다.

첫 번째로, calculateDiscount 함수는 원가와 할인율을 받아 최종 가격을 계산합니다. 여기서 중요한 점은 잘못된 입력값(음수 가격, 100% 초과 할인율)을 검증하는 로직이 포함되어 있다는 것입니다.

실무에서는 이런 엣지 케이스를 반드시 처리해야 예상치 못한 버그를 방지할 수 있습니다. 두 번째로, describe 블록은 테스트들을 그룹화합니다.

'calculateDiscount'라는 이름으로 관련된 모든 테스트를 묶어서 관리하기 쉽게 만듭니다. 각 test 함수는 하나의 시나리오를 검증합니다.

예를 들어 '정상적인 할인율 적용' 테스트는 10,000원 상품에 20% 할인을 적용하면 8,000원이 나와야 한다는 것을 검증합니다. 세 번째로, expect와 toBe는 Jest의 매처(matcher) 함수입니다.

expect 안에 실제 실행 결과를 넣고, toBe나 toThrow로 기대값을 지정합니다. 만약 결과가 기대값과 다르면 테스트는 실패하고 정확히 어떤 값이 기대되었는지, 실제로는 어떤 값이 나왔는지 보여줍니다.

여러분이 이 코드를 사용하면 calculateDiscount 함수를 수정할 때마다 자동으로 모든 케이스가 정상 작동하는지 확인할 수 있습니다. 예를 들어 할인 계산 로직을 최적화하거나 새로운 검증 로직을 추가할 때, 기존 기능이 깨지지 않았는지 1초 만에 확인할 수 있어 개발 생산성이 크게 향상됩니다.

또한 팀원이 이 함수를 수정해도 테스트가 안전망 역할을 하여 버그를 사전에 방지할 수 있습니다.

실전 팁

💡 테스트 이름은 "무엇을 테스트하는지" 명확하게 작성하세요. "test1", "test2"보다는 "정상적인 할인율 적용"처럼 구체적으로 쓰면 나중에 테스트가 실패했을 때 원인을 바로 파악할 수 있습니다.

💡 하나의 테스트는 하나의 기능만 검증하세요. 여러 개를 한꺼번에 테스트하면 실패했을 때 정확히 어느 부분이 문제인지 알기 어렵습니다.

💡 엣지 케이스를 꼭 테스트하세요. 정상 케이스만 테스트하면 실무에서 예상치 못한 버그가 발생합니다. 음수, 0, 매우 큰 수, null, undefined 등을 모두 확인하세요.

💡 테스트를 먼저 실패시켜보세요. 테스트 코드를 작성한 후 함수 구현을 일부러 잘못 작성해서 테스트가 제대로 실패하는지 확인하면, 테스트 코드 자체가 올바른지 검증할 수 있습니다.

💡 AAA 패턴을 따르세요. Arrange(준비), Act(실행), Assert(검증) 순서로 테스트를 구성하면 가독성이 높아집니다. 먼저 데이터를 준비하고, 함수를 실행하고, 결과를 검증하는 순서입니다.


2. 테스트 주도 개발 TDD

시작하며

여러분이 복잡한 기능을 개발할 때 이런 경험 있나요? 코드를 다 작성한 후에 테스트를 해보니 여기저기서 버그가 터지고, 어디서부터 고쳐야 할지 막막한 상황.

결국 코드를 처음부터 다시 작성하게 되는 경우도 생깁니다. 이런 문제는 코드 설계를 제대로 하지 않고 일단 "작동하게" 만드는 데 집중했기 때문에 발생합니다.

나중에 보니 함수가 너무 크고, 의존성이 복잡하게 얽혀있어서 수정하기도, 테스트하기도 어려운 코드가 되어버린 것입니다. 바로 이럴 때 필요한 것이 테스트 주도 개발(TDD)입니다.

코드를 작성하기 전에 테스트를 먼저 작성하면, 자연스럽게 좋은 설계가 나오고 버그도 훨씬 적어집니다.

개요

간단히 말해서, TDD는 "실패하는 테스트를 먼저 작성하고, 그 테스트를 통과하는 최소한의 코드를 작성한 후, 리팩토링하는" 개발 방법론입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 테스트를 나중에 작성하면 이미 완성된 코드에 맞춰 테스트를 작성하게 되어 실제로는 제대로 검증하지 못하는 경우가 많습니다.

반면 테스트를 먼저 작성하면 "이 함수가 무엇을 해야 하는가"를 먼저 정의하게 되어 설계가 명확해집니다. 예를 들어, 사용자 등록 기능을 만들 때 "중복 이메일은 거부되어야 한다"는 테스트를 먼저 작성하면, 자연스럽게 이메일 중복 체크 로직을 설계하게 됩니다.

기존에는 코드를 다 작성한 후 "이 코드를 어떻게 테스트할까?"를 고민했다면, 이제는 "어떤 테스트가 통과되어야 할까?"를 먼저 정의하고 그에 맞춰 코드를 작성합니다. TDD의 핵심 특징은 Red-Green-Refactor 사이클입니다.

첫째, Red - 실패하는 테스트를 작성합니다. 둘째, Green - 테스트를 통과하는 최소한의 코드를 작성합니다.

셋째, Refactor - 코드를 개선하되 테스트는 계속 통과해야 합니다. 이 사이클을 반복하면 자연스럽게 테스트 가능하고 유지보수하기 쉬운 코드가 만들어집니다.

이러한 특징들이 장기적으로 개발 속도를 높이고 버그를 줄여줍니다.

코드 예제

// Step 1: Red - 실패하는 테스트 먼저 작성
describe('UserValidator', () => {
  test('유효한 이메일 형식은 true 반환', () => {
    const validator = new UserValidator();
    expect(validator.isValidEmail('test@example.com')).toBe(true);
  });

  test('잘못된 이메일 형식은 false 반환', () => {
    const validator = new UserValidator();
    expect(validator.isValidEmail('invalid-email')).toBe(false);
  });
});

// Step 2: Green - 테스트를 통과하는 최소 코드 작성
class UserValidator {
  isValidEmail(email) {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return emailRegex.test(email);
  }
}

// Step 3: Refactor - 코드 개선 (테스트는 계속 통과)
class UserValidator {
  isValidEmail(email) {
    if (!email || typeof email !== 'string') return false;
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return emailRegex.test(email.trim());
  }
}

설명

이것이 하는 일: 위 코드는 TDD의 Red-Green-Refactor 사이클을 실제로 보여주는 예시입니다. 이메일 검증 기능을 테스트부터 작성하여 점진적으로 완성합니다.

첫 번째로, Red 단계에서는 아직 존재하지 않는 UserValidator 클래스와 isValidEmail 메서드에 대한 테스트를 작성합니다. 이 시점에서 코드를 실행하면 당연히 실패합니다.

하지만 이것이 중요한 이유는 "이 기능이 어떻게 작동해야 하는가"를 먼저 정의했기 때문입니다. 테스트를 보면 이 메서드가 유효한 이메일에 true를, 잘못된 이메일에 false를 반환해야 한다는 것을 명확히 알 수 있습니다.

두 번째로, Green 단계에서는 테스트를 통과하는 최소한의 코드만 작성합니다. 정규표현식을 사용하여 이메일 형식을 검증하는 간단한 로직을 구현했습니다.

이 시점에서 테스트를 실행하면 모두 통과합니다. 여기서 중요한 원칙은 "과도한 구현을 하지 않는다"는 것입니다.

테스트가 요구하지 않는 기능은 추가하지 않습니다. 세 번째로, Refactor 단계에서는 코드를 개선합니다.

null 체크와 타입 체크, 공백 제거 기능을 추가했습니다. 하지만 테스트는 여전히 통과합니다.

이것이 TDD의 강력한 점입니다 - 테스트가 안전망 역할을 하여 리팩토링 중에 기능이 깨지지 않았는지 즉시 확인할 수 있습니다. 여러분이 이 방법을 사용하면 설계를 먼저 고민하게 되어 더 깔끔한 코드가 나옵니다.

테스트를 먼저 작성하면 "이 함수는 테스트하기 쉬운가?"를 자연스럽게 고민하게 되고, 그 결과 함수가 작고 독립적이며 의존성이 적은 구조로 만들어집니다. 실무에서 TDD를 적용하면 초반에는 느린 것 같지만, 장기적으로는 버그가 줄고 리팩토링이 쉬워져 전체 개발 속도가 빨라집니다.

실전 팁

💡 작은 단위로 시작하세요. 처음부터 복잡한 기능에 TDD를 적용하면 어렵습니다. 간단한 유틸리티 함수나 검증 로직부터 시작해서 익숙해지세요.

💡 테스트가 실패하는 것을 확인하세요. 테스트를 작성했다면 반드시 한 번은 실패하는 것을 봐야 합니다. 처음부터 통과한다면 테스트가 제대로 작성되지 않았을 가능성이 높습니다.

💡 한 번에 하나의 테스트만 작성하세요. 여러 테스트를 한꺼번에 작성하면 집중력이 분산됩니다. 하나의 테스트를 작성하고, 통과시키고, 리팩토링한 후 다음 테스트로 넘어가세요.

💡 리팩토링 단계를 건너뛰지 마세요. 테스트가 통과했다고 바로 다음으로 넘어가면 코드가 점점 복잡해집니다. 반드시 코드를 개선하는 시간을 가지세요.

💡 TDD가 모든 것을 해결하지는 않습니다. UI 로직이나 외부 API 연동처럼 TDD가 어려운 영역도 있습니다. 핵심 비즈니스 로직에 집중하세요.


3. Mocking과 Stubbing

시작하며

여러분이 결제 처리 함수를 테스트하려는데, 실제로 결제 API를 호출할 수는 없는 상황을 상상해보세요. 테스트할 때마다 진짜 돈이 빠져나가거나, 외부 서버에 부하를 주거나, API 호출 제한에 걸릴 수 있습니다.

이런 상황에서 어떻게 테스트해야 할까요? 이런 문제는 실제 개발 현장에서 매우 흔합니다.

데이터베이스 연결, 외부 API 호출, 파일 시스템 접근 등 외부 의존성이 있는 코드는 테스트하기가 매우 까다롭습니다. 느리고, 비용이 들고, 때로는 불가능하기도 합니다.

바로 이럴 때 필요한 것이 Mocking과 Stubbing입니다. 실제 의존성을 가짜 객체로 대체하여 빠르고 안정적으로 테스트할 수 있습니다.

개요

간단히 말해서, Mock은 외부 의존성을 가짜로 만들어 "어떤 메서드가 호출되었는지" 검증하는 객체이고, Stub은 "미리 정해진 값을 반환"하도록 만든 가짜 객체입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 실제 데이터베이스나 API를 사용하면 테스트가 느려지고 불안정해집니다.

네트워크가 끊기거나 외부 서비스가 다운되면 우리 코드는 문제없는데도 테스트가 실패합니다. Mock과 Stub을 사용하면 외부 의존성과 완전히 독립적으로 테스트할 수 있습니다.

예를 들어, 이메일 발송 기능을 테스트할 때 실제로 이메일을 보내지 않고도 "이메일 발송 함수가 올바른 파라미터로 호출되었는지"를 검증할 수 있습니다. 기존에는 테스트 데이터베이스를 구축하거나 실제 API를 호출했다면, 이제는 가짜 객체를 사용하여 1초도 안 걸리는 빠른 테스트를 작성할 수 있습니다.

Mocking과 Stubbing의 핵심 차이는 검증 대상입니다. Stub은 "상태 검증" - 함수가 반환하는 값이 올바른지 확인합니다.

Mock은 "행위 검증" - 특정 메서드가 몇 번 호출되었는지, 어떤 인자로 호출되었는지 확인합니다. 이러한 구분을 이해하면 각 상황에 맞는 적절한 테스트 기법을 선택할 수 있습니다.

코드 예제

// 실제 코드: 사용자 등록 시 이메일 발송
class UserService {
  constructor(emailService) {
    this.emailService = emailService;
  }

  async registerUser(email, name) {
    // 사용자 저장 로직...
    const user = { id: 1, email, name };

    // 환영 이메일 발송
    await this.emailService.sendWelcomeEmail(email, name);

    return user;
  }
}

// Mock을 사용한 테스트 - 행위 검증
describe('UserService', () => {
  test('사용자 등록 시 환영 이메일 발송', async () => {
    // Mock 객체 생성
    const mockEmailService = {
      sendWelcomeEmail: jest.fn().mockResolvedValue(true)
    };

    const userService = new UserService(mockEmailService);
    await userService.registerUser('test@example.com', 'John');

    // Mock이 올바른 인자로 호출되었는지 검증
    expect(mockEmailService.sendWelcomeEmail)
      .toHaveBeenCalledWith('test@example.com', 'John');
    expect(mockEmailService.sendWelcomeEmail).toHaveBeenCalledTimes(1);
  });
});

설명

이것이 하는 일: 위 코드는 사용자 등록 시 이메일 발송 기능을 실제 이메일 서비스 없이 테스트하는 방법을 보여줍니다. Mock 객체를 사용하여 외부 의존성을 제거했습니다.

첫 번째로, UserService 클래스는 생성자를 통해 emailService를 주입받습니다. 이것을 의존성 주입(Dependency Injection)이라고 하며, 테스트 가능한 코드를 만드는 핵심 패턴입니다.

실제 운영 환경에서는 진짜 EmailService를 주입하고, 테스트 환경에서는 Mock 객체를 주입할 수 있습니다. 이렇게 설계하면 코드를 수정하지 않고도 테스트할 수 있습니다.

두 번째로, registerUser 메서드는 사용자를 등록한 후 환영 이메일을 발송합니다. 이 로직을 테스트하려면 실제로 이메일이 발송되는지 확인해야 하는데, 실제 이메일을 보낼 수는 없습니다.

여기서 Mock이 필요합니다. 세 번째로, 테스트 코드에서는 jest.fn()을 사용하여 Mock 함수를 만들었습니다.

mockResolvedValue(true)는 이 함수가 호출되면 Promise로 true를 반환하도록 설정합니다. 이렇게 하면 실제 이메일을 보내지 않고도 "이메일 발송 성공" 상황을 시뮬레이션할 수 있습니다.

네 번째로, 가장 중요한 부분은 검증입니다. toHaveBeenCalledWith는 Mock 함수가 정확히 어떤 인자로 호출되었는지 확인합니다.

만약 개발자가 실수로 email과 name 순서를 바꿔서 호출했다면 이 테스트는 실패합니다. toHaveBeenCalledTimes(1)은 함수가 정확히 한 번만 호출되었는지 확인합니다.

두 번 호출되거나 호출되지 않았다면 테스트가 실패합니다. 여러분이 이 방법을 사용하면 외부 서비스에 의존하지 않고 빠르게 테스트할 수 있습니다.

실제 이메일 서비스는 몇 초가 걸릴 수 있지만, Mock을 사용하면 밀리초 단위로 테스트가 완료됩니다. 또한 네트워크 문제나 외부 서비스 장애와 무관하게 안정적으로 테스트를 실행할 수 있습니다.

실무에서는 결제, 파일 업로드, 데이터베이스 연결 등 다양한 외부 의존성을 Mock으로 대체하여 테스트합니다.

실전 팁

💡 의존성 주입을 사용하세요. 클래스 내부에서 직접 new EmailService()를 하면 Mock으로 대체할 수 없습니다. 생성자나 메서드 파라미터로 주입받아야 합니다.

💡 너무 많은 것을 Mock하지 마세요. Mock이 너무 많으면 테스트가 실제 동작과 동떨어집니다. 핵심 외부 의존성만 Mock하고 나머지는 실제 코드를 사용하세요.

💡 Mock의 반환값을 실제와 비슷하게 만드세요. mockResolvedValue({ success: true })처럼 실제 API 응답과 유사한 구조로 만들면 테스트가 더 현실적입니다.

💡 Spy를 활용하세요. jest.spyOn()을 사용하면 실제 메서드를 호출하면서도 호출 여부를 추적할 수 있습니다. Stub과 Mock의 중간 형태로 유용합니다.

💡 각 테스트마다 Mock을 초기화하세요. beforeEach에서 jest.clearAllMocks()를 호출하여 이전 테스트의 호출 기록이 남아있지 않도록 하세요.


4. 통합 테스트 Integration Test

시작하며

여러분이 개발한 결제 모듈의 모든 함수를 단위 테스트로 검증했는데, 실제 서비스에 배포하니 결제가 안 되는 경험을 해본 적 있나요? 각 함수는 완벽하게 작동하는데, 함수들이 서로 연결될 때 문제가 발생하는 것입니다.

이런 문제는 단위 테스트만으로는 발견할 수 없는 통합 시점의 버그입니다. 결제 모듈이 데이터베이스와 통신하고, API를 호출하고, 로깅 시스템에 기록하는 전체 흐름에서 문제가 생길 수 있습니다.

함수 A와 함수 B는 각각 완벽해도, A의 출력 형식이 B가 기대하는 입력 형식과 맞지 않을 수 있습니다. 바로 이럴 때 필요한 것이 통합 테스트입니다.

여러 모듈이 함께 작동할 때 올바르게 상호작용하는지 검증합니다.

개요

간단히 말해서, 통합 테스트는 여러 모듈이나 컴포넌트가 함께 작동할 때 올바르게 동작하는지 검증하는 테스트입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 실제 소프트웨어는 수많은 모듈이 서로 협력하여 작동합니다.

데이터베이스에서 데이터를 읽고, 비즈니스 로직을 처리하고, 결과를 API로 반환하는 전체 흐름이 정상적으로 작동해야 합니다. 단위 테스트로는 각 함수가 올바른지 확인할 수 있지만, 전체 시스템이 통합되었을 때 발생하는 문제는 찾을 수 없습니다.

예를 들어, 사용자 등록 API를 테스트할 때 실제 데이터베이스에 저장되고, 이메일이 발송되고, 로그가 기록되는 전체 프로세스를 검증해야 진짜 동작을 확인할 수 있습니다. 기존에는 각 함수를 독립적으로 테스트했다면, 이제는 실제 환경과 유사하게 여러 컴포넌트를 함께 실행하여 테스트합니다.

통합 테스트의 핵심 특징은 세 가지입니다. 첫째, 실제 의존성 사용 - Mock이 아닌 실제 데이터베이스, 실제 파일 시스템 등을 사용합니다(테스트 환경 버전).

둘째, 더 높은 수준의 검증 - 함수 하나가 아닌 전체 사용자 시나리오를 검증합니다. 셋째, 단위 테스트보다 느림 - 실제 의존성을 사용하므로 실행 시간이 더 걸립니다.

이러한 특징들이 실제 운영 환경에서 발생할 수 있는 문제를 사전에 발견하게 해줍니다.

코드 예제

// Express API 통합 테스트 예시
const request = require('supertest');
const app = require('../app'); // Express 앱
const db = require('../database'); // 실제 테스트 DB

describe('POST /api/users', () => {
  // 각 테스트 전에 DB 초기화
  beforeEach(async () => {
    await db.query('DELETE FROM users');
  });

  // 모든 테스트 후 DB 연결 종료
  afterAll(async () => {
    await db.close();
  });

  test('새로운 사용자 등록 성공', async () => {
    const response = await request(app)
      .post('/api/users')
      .send({
        email: 'test@example.com',
        name: 'John Doe',
        password: 'secure123'
      });

    // API 응답 검증
    expect(response.status).toBe(201);
    expect(response.body.email).toBe('test@example.com');
    expect(response.body).toHaveProperty('id');

    // DB에 실제로 저장되었는지 검증
    const user = await db.query('SELECT * FROM users WHERE email = ?',
                                 ['test@example.com']);
    expect(user[0].name).toBe('John Doe');
    expect(user[0].password).not.toBe('secure123'); // 암호화되었는지
  });

  test('중복 이메일은 400 에러 반환', async () => {
    // 먼저 사용자 등록
    await request(app).post('/api/users').send({
      email: 'test@example.com',
      name: 'John Doe',
      password: 'secure123'
    });

    // 같은 이메일로 재등록 시도
    const response = await request(app).post('/api/users').send({
      email: 'test@example.com',
      name: 'Jane Doe',
      password: 'another123'
    });

    expect(response.status).toBe(400);
    expect(response.body.error).toContain('이미 존재');
  });
});

설명

이것이 하는 일: 위 코드는 사용자 등록 API의 전체 흐름을 실제 데이터베이스와 함께 테스트합니다. HTTP 요청부터 DB 저장, 응답 반환까지 모든 단계를 검증합니다.

첫 번째로, supertest 라이브러리를 사용하여 실제 HTTP 요청을 시뮬레이션합니다. request(app).post('/api/users')는 Express 앱의 POST 엔드포인트를 호출합니다.

이것은 실제 사용자가 브라우저에서 요청하는 것과 거의 동일합니다. 서버를 실제로 띄우지 않고도 전체 요청-응답 사이클을 테스트할 수 있습니다.

두 번째로, beforeEach와 afterAll 같은 설정 함수들이 중요합니다. beforeEach는 각 테스트 전에 데이터베이스를 깨끗하게 초기화합니다.

이렇게 하면 테스트 간에 서로 영향을 주지 않습니다. 만약 이전 테스트에서 생성한 데이터가 남아있으면 다음 테스트가 실패할 수 있기 때문입니다.

afterAll은 모든 테스트가 끝난 후 DB 연결을 정리합니다. 세 번째로, 첫 번째 테스트는 정상적인 사용자 등록 시나리오를 검증합니다.

API 응답 상태 코드가 201인지, 반환된 이메일이 올바른지 확인한 후, 가장 중요한 부분이 나옵니다 - 실제로 데이터베이스에 쿼리를 날려서 데이터가 저장되었는지 확인합니다. 또한 비밀번호가 평문으로 저장되지 않고 암호화되었는지도 검증합니다.

이런 검증은 단위 테스트로는 불가능하며, 전체 시스템이 통합된 상태에서만 확인할 수 있습니다. 네 번째로, 두 번째 테스트는 중복 이메일 처리를 검증합니다.

먼저 한 명의 사용자를 등록한 후, 같은 이메일로 다시 등록을 시도합니다. 이때 400 에러가 반환되고 적절한 에러 메시지가 포함되는지 확인합니다.

이 테스트는 API 엔드포인트, 비즈니스 로직, 데이터베이스 유니크 제약조건이 모두 올바르게 협력하는지 검증합니다. 여러분이 이 방법을 사용하면 실제 운영 환경에서 발생할 수 있는 문제를 사전에 발견할 수 있습니다.

예를 들어 데이터베이스 스키마가 코드와 맞지 않거나, API 응답 형식이 프론트엔드 기대와 다르거나, 트랜잭션 처리가 잘못되었을 때 통합 테스트에서 발견됩니다. 실무에서는 CI/CD 파이프라인에서 통합 테스트를 실행하여 배포 전에 통합 이슈를 자동으로 검출합니다.

실전 팁

💡 테스트 데이터베이스를 별도로 구축하세요. 운영 DB를 절대 사용하면 안 됩니다. Docker를 활용하면 테스트용 DB를 쉽게 만들고 제거할 수 있습니다.

💡 테스트 데이터를 격리하세요. 각 테스트는 독립적이어야 합니다. beforeEach에서 데이터를 초기화하거나, 각 테스트마다 고유한 데이터를 사용하세요.

💡 통합 테스트는 단위 테스트보다 적게 작성하세요. 모든 경우의 수를 통합 테스트로 커버하면 너무 느려집니다. 핵심 시나리오만 통합 테스트로 작성하고, 세부 케이스는 단위 테스트로 커버하세요.

💡 실제 외부 API는 Mock하세요. 결제 게이트웨이나 SMS 발송 같은 유료 외부 서비스는 통합 테스트에서도 Mock을 사용하는 것이 좋습니다.

💡 병렬 실행에 주의하세요. 통합 테스트를 병렬로 실행하면 DB 충돌이 발생할 수 있습니다. 각 테스트가 다른 데이터를 사용하거나, 순차 실행하도록 설정하세요.


5. E2E 테스트

시작하며

여러분이 쇼핑몰 사이트를 개발했다고 상상해보세요. 모든 단위 테스트와 통합 테스트가 통과했는데, 실제 사용자가 "장바구니에 상품을 담았는데 결제 페이지로 넘어가지 않아요"라는 버그를 제보합니다.

각각의 기능은 완벽하지만, 실제 사용자의 클릭 흐름에서 문제가 발생한 것입니다. 이런 문제는 백엔드 API와 프론트엔드 UI가 만나는 지점에서 자주 발생합니다.

API는 올바른 데이터를 반환하는데, UI에서 그 데이터를 잘못 파싱하거나, 버튼 클릭 이벤트가 제대로 연결되지 않은 경우입니다. 또는 페이지 로딩 타이밍 문제로 버튼이 클릭되지 않는 경우도 있습니다.

바로 이럴 때 필요한 것이 E2E(End-to-End) 테스트입니다. 실제 사용자처럼 브라우저를 조작하여 전체 시나리오를 검증합니다.

개요

간단히 말해서, E2E 테스트는 실제 사용자의 관점에서 애플리케이션 전체를 처음부터 끝까지 테스트하는 것입니다. 실제 브라우저를 띄워서 클릭하고, 입력하고, 결과를 확인합니다.

왜 이 개념이 필요한지 실무 관점에서 설명하면, 아무리 훌륭한 단위 테스트와 통합 테스트가 있어도 실제 사용자 경험을 완전히 검증할 수는 없습니다. 프론트엔드와 백엔드가 통합되고, 실제 브라우저에서 렌더링되고, CSS와 JavaScript가 상호작용하는 전체 환경에서만 발견할 수 있는 버그들이 있습니다.

E2E 테스트는 "로그인 버튼을 클릭하면 정말 로그인이 되는가?"처럼 사용자가 실제로 겪는 시나리오를 검증합니다. 예를 들어, 전자상거래 사이트에서 "상품 검색 → 상품 선택 → 장바구니 담기 → 결제하기" 전체 흐름이 정상 작동하는지 확인할 수 있습니다.

기존에는 QA 팀원이 수동으로 브라우저에서 클릭하며 테스트했다면, 이제는 자동화된 스크립트가 같은 작업을 몇 분 만에 수행합니다. E2E 테스트의 핵심 특징은 세 가지입니다.

첫째, 실제 브라우저 사용 - Chrome, Firefox 등 실제 브라우저를 제어합니다. 둘째, 사용자 시나리오 기반 - 클릭, 입력, 네비게이션 등 실제 사용자 행동을 시뮬레이션합니다.

셋째, 가장 느리고 비용이 높음 - 브라우저를 띄우고 전체 앱을 실행하므로 시간이 오래 걸립니다. 이러한 특징들이 출시 전 최종 품질을 보장하는 안전망 역할을 합니다.

코드 예제

// Playwright를 사용한 E2E 테스트
const { test, expect } = require('@playwright/test');

test.describe('사용자 로그인 및 프로필 조회', () => {
  test('정상적인 로그인 플로우', async ({ page }) => {
    // 1. 로그인 페이지 방문
    await page.goto('https://example.com/login');

    // 2. 페이지가 완전히 로드될 때까지 대기
    await expect(page.locator('h1')).toContainText('로그인');

    // 3. 이메일과 비밀번호 입력
    await page.fill('input[name="email"]', 'test@example.com');
    await page.fill('input[name="password"]', 'securePass123');

    // 4. 로그인 버튼 클릭
    await page.click('button[type="submit"]');

    // 5. 대시보드로 리다이렉트되었는지 확인
    await expect(page).toHaveURL(/.*dashboard/);

    // 6. 환영 메시지 확인
    await expect(page.locator('.welcome-message'))
      .toContainText('test@example.com님 환영합니다');

    // 7. 프로필 페이지로 이동
    await page.click('a[href="/profile"]');

    // 8. 사용자 정보가 올바르게 표시되는지 확인
    await expect(page.locator('.user-email'))
      .toHaveText('test@example.com');
  });

  test('잘못된 비밀번호로 로그인 시도', async ({ page }) => {
    await page.goto('https://example.com/login');

    await page.fill('input[name="email"]', 'test@example.com');
    await page.fill('input[name="password"]', 'wrongPassword');
    await page.click('button[type="submit"]');

    // 에러 메시지 표시 확인
    await expect(page.locator('.error-message'))
      .toContainText('비밀번호가 올바르지 않습니다');

    // URL이 여전히 로그인 페이지인지 확인
    await expect(page).toHaveURL(/.*login/);
  });
});

설명

이것이 하는 일: 위 코드는 Playwright를 사용하여 실제 브라우저를 제어하면서 로그인부터 프로필 조회까지 전체 사용자 여정을 자동으로 테스트합니다. 첫 번째로, page.goto()는 실제 브라우저를 열고 지정된 URL로 이동합니다.

이것은 사용자가 주소창에 URL을 입력하는 것과 똑같습니다. Playwright는 Chromium, Firefox, WebKit 등 실제 브라우저 엔진을 사용하므로, CSS 렌더링, JavaScript 실행, 네트워크 요청 등 모든 것이 실제와 동일하게 작동합니다.

두 번째로, page.fill()과 page.click()은 사용자의 행동을 시뮬레이션합니다. fill은 키보드로 입력하는 것을, click은 마우스 클릭을 재현합니다.

중요한 점은 이 메서드들이 "사람처럼" 동작한다는 것입니다. 요소가 화면에 보이지 않으면 자동으로 스크롤하고, 클릭 가능한 상태가 될 때까지 기다립니다.

이렇게 하면 실제 사용자가 겪을 수 있는 타이밍 이슈도 감지할 수 있습니다. 세 번째로, expect와 toContainText, toHaveURL 같은 assertion들은 실제로 원하는 결과가 나타났는지 검증합니다.

예를 들어 toHaveURL(/.*dashboard/)는 로그인 후 대시보드 페이지로 정상적으로 리다이렉트되었는지 확인합니다. 만약 리다이렉트가 실패하거나 잘못된 페이지로 이동하면 테스트가 즉시 실패하고 스크린샷을 캡처하여 무엇이 잘못되었는지 보여줍니다.

네 번째로, 첫 번째 테스트는 "해피 패스(Happy Path)"라고 불리는 정상 시나리오를 검증합니다. 로그인 → 대시보드 확인 → 프로필 이동이라는 전체 흐름이 끊김 없이 작동하는지 확인합니다.

두 번째 테스트는 에러 처리를 검증합니다. 잘못된 비밀번호를 입력했을 때 적절한 에러 메시지가 표시되고, 로그인 페이지에 그대로 남아있는지 확인합니다.

여러분이 이 방법을 사용하면 배포 전에 실제 사용자가 겪을 문제를 미리 발견할 수 있습니다. 예를 들어 CSS 변경으로 버튼이 화면 밖으로 밀려나거나, API 응답 형식 변경으로 데이터가 표시되지 않는 문제를 자동으로 감지합니다.

실무에서는 주요 사용자 플로우(회원가입, 로그인, 결제, 주문 등)를 E2E 테스트로 작성하여 매 배포마다 자동으로 실행합니다. 테스트가 실패하면 배포를 중단하고 문제를 먼저 해결합니다.

실전 팁

💡 E2E 테스트는 최소한으로 작성하세요. 브라우저를 띄우는 것은 느리고 비용이 높습니다. 핵심 사용자 시나리오만 E2E로 커버하고, 나머지는 단위/통합 테스트로 처리하세요.

💡 데이터 격리에 신경 쓰세요. E2E 테스트는 실제 DB를 사용하므로 각 테스트마다 고유한 이메일이나 ID를 사용하거나, 테스트 후 데이터를 정리하세요.

💡 명시적 대기를 사용하세요. page.waitForSelector()로 특정 요소가 나타날 때까지 기다리면 타이밍 이슈를 방지할 수 있습니다. sleep()이나 임의의 시간 대기는 피하세요.

💡 헤드리스 모드를 활용하세요. 로컬에서는 브라우저를 보면서 디버깅하고, CI/CD에서는 headless: true로 설정하여 빠르게 실행하세요.

💡 스크린샷과 비디오를 활용하세요. Playwright는 테스트 실패 시 자동으로 스크린샷을 찍습니다. 이것을 CI 아티팩트로 저장하면 실패 원인을 빠르게 파악할 수 있습니다.


6. 테스트 커버리지

시작하며

여러분이 열심히 테스트 코드를 작성했는데, 팀장님이 "우리 코드의 몇 퍼센트가 테스트되고 있나요?"라고 물어보는 상황을 상상해보세요. 테스트는 많이 작성했지만, 실제로 전체 코드 중 얼마나 커버하고 있는지 모르는 경우가 많습니다.

이런 문제는 체계적인 측정 없이 테스트를 작성할 때 발생합니다. 중요한 핵심 로직은 테스트하지 않고 쉬운 부분만 테스트하거나, 특정 엣지 케이스를 놓칠 수 있습니다.

"테스트를 많이 작성했으니 괜찮겠지"라는 막연한 생각으로는 품질을 보장할 수 없습니다. 바로 이럴 때 필요한 것이 테스트 커버리지입니다.

코드의 어느 부분이 테스트되고 있고, 어느 부분이 빠져있는지 정확히 측정하고 시각화합니다.

개요

간단히 말해서, 테스트 커버리지는 전체 코드 중 테스트에 의해 실행되는 코드의 비율을 측정하는 지표입니다. 주로 라인 커버리지, 브랜치 커버리지, 함수 커버리지로 나뉩니다.

왜 이 개념이 필요한지 실무 관점에서 설명하면, 단순히 테스트 개수가 많다고 품질이 높은 것은 아닙니다. 100개의 테스트를 작성해도 핵심 로직을 하나도 테스트하지 않았다면 의미가 없습니다.

테스트 커버리지를 측정하면 어느 부분이 테스트되지 않았는지 한눈에 볼 수 있어, 빠진 부분을 보완할 수 있습니다. 예를 들어, 결제 실패 처리 로직이 전혀 테스트되지 않았다면 커버리지 리포트에서 빨간색으로 표시되어 즉시 알 수 있습니다.

이를 보고 해당 부분의 테스트를 추가하여 품질을 높일 수 있습니다. 기존에는 "이 정도면 충분히 테스트한 것 같다"는 주관적 판단에 의존했다면, 이제는 80%, 90% 같은 객관적인 수치로 테스트 수준을 측정할 수 있습니다.

테스트 커버리지의 핵심 유형은 네 가지입니다. 첫째, 라인 커버리지 - 전체 코드 라인 중 실행된 라인의 비율.

둘째, 브랜치 커버리지 - if/else, switch 같은 분기문의 모든 경로가 실행되었는지. 셋째, 함수 커버리지 - 전체 함수 중 호출된 함수의 비율.

넷째, 구문 커버리지 - 각 구문이 실행되었는지. 이러한 지표들을 종합하여 테스트의 완성도를 평가합니다.

코드 예제

// package.json에 Jest 커버리지 설정
{
  "scripts": {
    "test": "jest",
    "test:coverage": "jest --coverage --coverageThreshold='{\"global\":{\"lines\":80,\"branches\":80,\"functions\":80}}'"
  },
  "jest": {
    "collectCoverageFrom": [
      "src/**/*.js",
      "!src/**/*.test.js",
      "!src/index.js"
    ]
  }
}

// 테스트할 함수: 사용자 등급 계산
function calculateUserTier(points) {
  if (points < 0) {
    throw new Error('Points cannot be negative');
  }

  if (points < 100) {
    return 'Bronze';
  } else if (points < 500) {
    return 'Silver';
  } else if (points < 1000) {
    return 'Gold';
  } else {
    return 'Platinum';
  }
}

// 불완전한 테스트 (커버리지 50%)
describe('calculateUserTier', () => {
  test('Bronze 등급', () => {
    expect(calculateUserTier(50)).toBe('Bronze');
  });

  test('Gold 등급', () => {
    expect(calculateUserTier(700)).toBe('Gold');
  });
});

// 완전한 테스트 (커버리지 100%)
describe('calculateUserTier - Full Coverage', () => {
  test('음수는 에러 발생', () => {
    expect(() => calculateUserTier(-10)).toThrow('Points cannot be negative');
  });

  test('Bronze 등급 (0-99)', () => {
    expect(calculateUserTier(0)).toBe('Bronze');
    expect(calculateUserTier(99)).toBe('Bronze');
  });

  test('Silver 등급 (100-499)', () => {
    expect(calculateUserTier(100)).toBe('Silver');
    expect(calculateUserTier(499)).toBe('Silver');
  });

  test('Gold 등급 (500-999)', () => {
    expect(calculateUserTier(500)).toBe('Gold');
    expect(calculateUserTier(999)).toBe('Gold');
  });

  test('Platinum 등급 (1000+)', () => {
    expect(calculateUserTier(1000)).toBe('Platinum');
    expect(calculateUserTier(5000)).toBe('Platinum');
  });
});

설명

이것이 하는 일: 위 코드는 테스트 커버리지를 측정하는 설정과, 불완전한 테스트와 완전한 테스트의 차이를 보여줍니다. 첫 번째로, package.json의 test:coverage 스크립트는 Jest에게 커버리지를 측정하고 리포트를 생성하라고 명령합니다.

--coverageThreshold 옵션은 최소 커버리지 기준을 설정합니다. 예제에서는 라인, 브랜치, 함수 커버리지가 모두 80% 이상이어야 합니다.

만약 기준에 미달하면 테스트가 실패하여 CI/CD에서 배포를 막을 수 있습니다. 이렇게 하면 팀 전체가 일정 수준 이상의 테스트 품질을 유지하게 됩니다.

두 번째로, collectCoverageFrom 설정은 어떤 파일의 커버리지를 측정할지 지정합니다. src 폴더의 모든 .js 파일을 포함하되, 테스트 파일(.test.js)과 엔트리 포인트(index.js)는 제외합니다.

이렇게 설정하면 실제 비즈니스 로직만 커버리지에 포함됩니다. 세 번째로, 불완전한 테스트는 Bronze와 Gold만 테스트합니다.

이 테스트를 실행하고 커버리지를 보면 약 50%로 나타납니다. 왜냐하면 음수 검증, Silver, Platinum 분기가 전혀 실행되지 않았기 때문입니다.

커버리지 리포트를 HTML로 생성하면 어느 라인이 실행되지 않았는지 빨간색으로 강조되어 한눈에 알 수 있습니다. 네 번째로, 완전한 테스트는 모든 분기를 커버합니다.

음수 에러부터 모든 등급(Bronze, Silver, Gold, Platinum)까지 테스트하고, 각 등급의 경계값(0, 99, 100, 499 등)도 검증합니다. 경계값 테스트는 매우 중요한데, off-by-one 에러(부등호를 잘못 써서 발생하는 버그)를 찾아낼 수 있기 때문입니다.

이 테스트는 100% 커버리지를 달성합니다. 여러분이 이 방법을 사용하면 테스트되지 않은 코드를 쉽게 발견할 수 있습니다.

npm run test:coverage를 실행하면 터미널에 표 형태로 커버리지가 표시되고, coverage/lcov-report/index.html 파일을 열면 시각적으로 어느 파일, 어느 라인이 테스트되지 않았는지 볼 수 있습니다. 실무에서는 PR을 올릴 때 커버리지가 떨어지면 자동으로 알림을 주도록 설정하여 코드 품질을 유지합니다.

하지만 주의할 점은 100% 커버리지가 버그 없음을 보장하지는 않는다는 것입니다. 커버리지는 도구일 뿐, 진짜 중요한 것은 의미 있는 테스트 케이스를 작성하는 것입니다.

실전 팁

💡 80% 커버리지를 목표로 하세요. 100%를 달성하려면 비용이 너무 많이 들고, 때로는 불가능합니다. 실무에서는 70-80%가 적절한 목표입니다.

💡 커버리지 숫자에 집착하지 마세요. 의미 없는 테스트로 커버리지만 높이는 것보다, 중요한 로직을 제대로 테스트하는 것이 훨씬 가치 있습니다.

💡 브랜치 커버리지를 중시하세요. 라인 커버리지가 높아도 if/else의 한쪽만 테스트했다면 불완전합니다. 브랜치 커버리지가 더 정확한 지표입니다.

💡 커버리지 리포트를 정기적으로 확인하세요. CI/CD에 통합하여 매 PR마다 커버리지 변화를 추적하면, 새로운 코드가 테스트 없이 추가되는 것을 방지할 수 있습니다.

💡 제외 파일을 명시하세요. 설정 파일, 상수 파일, 타입 정의 파일 등 테스트가 불필요한 파일은 coveragePathIgnorePatterns로 제외하여 의미 있는 커버리지를 측정하세요.


7. 비동기 코드 테스팅

시작하며

여러분이 API 호출 함수를 테스트하려는데, 테스트가 API 응답을 기다리지 않고 바로 통과해버려서 당황한 경험 있나요? 또는 테스트가 간헐적으로 성공하다가 실패하는 "플래키(flaky) 테스트" 문제를 겪어본 적 있나요?

이런 문제는 비동기 코드를 동기 코드처럼 테스트하려고 할 때 발생합니다. JavaScript는 기본적으로 비동기로 작동하는 언어이고, API 호출, 파일 읽기, 타이머 등 대부분의 I/O 작업은 비동기입니다.

테스트가 비동기 작업이 완료되기 전에 종료되면 제대로 검증할 수 없습니다. 바로 이럴 때 필요한 것이 비동기 코드 테스팅 기법입니다.

async/await, Promise, done 콜백 등을 활용하여 비동기 작업을 올바르게 테스트합니다.

개요

간단히 말해서, 비동기 코드 테스팅은 Promise, async/await, 콜백 등 비동기 작업이 완료될 때까지 기다렸다가 결과를 검증하는 테스트 기법입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 현대 웹 애플리케이션은 대부분 비동기로 작동합니다.

사용자 정보를 가져오는 API, 데이터베이스 쿼리, 이미지 업로드 등 모든 것이 비동기입니다. 이런 코드를 제대로 테스트하지 않으면 "로컬에서는 잘 되는데 배포하면 안 돼요" 같은 문제가 발생합니다.

비동기 테스팅을 제대로 하면 타이밍 이슈, 에러 핸들링, 경쟁 조건(race condition) 같은 복잡한 문제를 사전에 발견할 수 있습니다. 예를 들어, 사용자가 로그인 버튼을 연속으로 두 번 클릭했을 때 중복 요청이 발생하지 않는지 테스트할 수 있습니다.

기존에는 setTimeout으로 임의의 시간을 기다리거나 비동기 완료를 제대로 검증하지 못했다면, 이제는 async/await로 정확히 작업 완료를 기다렸다가 결과를 검증할 수 있습니다. 비동기 테스팅의 핵심 패턴은 세 가지입니다.

첫째, async/await 사용 - 가장 직관적이고 현대적인 방법입니다. 둘째, Promise 반환 - 테스트 함수가 Promise를 반환하면 Jest가 자동으로 기다립니다.

셋째, done 콜백 - 레거시 콜백 스타일 코드를 테스트할 때 사용합니다. 이러한 패턴들을 상황에 맞게 선택하여 사용합니다.

코드 예제

// 테스트할 비동기 함수들
async function fetchUser(userId) {
  const response = await fetch(`https://api.example.com/users/${userId}`);
  if (!response.ok) {
    throw new Error('User not found');
  }
  return response.json();
}

function getUserWithCallback(userId, callback) {
  setTimeout(() => {
    if (userId === 1) {
      callback(null, { id: 1, name: 'John' });
    } else {
      callback(new Error('User not found'), null);
    }
  }, 100);
}

// 1. async/await를 사용한 테스트 (권장)
describe('fetchUser with async/await', () => {
  test('사용자 정보를 성공적으로 가져옴', async () => {
    // fetch를 Mock으로 대체
    global.fetch = jest.fn(() =>
      Promise.resolve({
        ok: true,
        json: async () => ({ id: 1, name: 'John', email: 'john@example.com' })
      })
    );

    const user = await fetchUser(1);

    expect(user.name).toBe('John');
    expect(user.email).toBe('john@example.com');
    expect(fetch).toHaveBeenCalledWith('https://api.example.com/users/1');
  });

  test('존재하지 않는 사용자는 에러 발생', async () => {
    global.fetch = jest.fn(() =>
      Promise.resolve({
        ok: false
      })
    );

    // 비동기 함수의 에러는 rejects로 검증
    await expect(fetchUser(999)).rejects.toThrow('User not found');
  });
});

// 2. Promise를 반환하는 테스트
describe('fetchUser with Promise', () => {
  test('Promise를 반환하여 테스트', () => {
    global.fetch = jest.fn(() =>
      Promise.resolve({
        ok: true,
        json: async () => ({ id: 1, name: 'John' })
      })
    );

    // return을 꼭 붙여야 Jest가 Promise를 기다림
    return fetchUser(1).then(user => {
      expect(user.name).toBe('John');
    });
  });
});

// 3. done 콜백을 사용한 테스트 (레거시)
describe('getUserWithCallback', () => {
  test('콜백으로 사용자 정보 반환', (done) => {
    getUserWithCallback(1, (error, user) => {
      expect(error).toBeNull();
      expect(user.name).toBe('John');
      done(); // 테스트 완료를 Jest에게 알림
    });
  });

  test('존재하지 않는 사용자는 에러 콜백', (done) => {
    getUserWithCallback(999, (error, user) => {
      expect(error).toBeTruthy();
      expect(error.message).toBe('User not found');
      expect(user).toBeNull();
      done();
    });
  });
});

설명

이것이 하는 일: 위 코드는 API 호출, 콜백 등 다양한 비동기 패턴을 올바르게 테스트하는 방법을 보여줍니다. 각 패턴마다 적절한 테스팅 방법이 다릅니다.

첫 번째로, async/await 패턴이 가장 권장됩니다. 테스트 함수 앞에 async를 붙이고, 비동기 함수 호출 앞에 await를 붙입니다.

이렇게 하면 코드가 동기 코드처럼 순차적으로 읽히면서도 비동기 작업을 제대로 기다립니다. fetchUser(1) 호출이 완료될 때까지 기다렸다가 결과를 검증하므로, 타이밍 이슈가 발생하지 않습니다.

fetch를 Mock으로 대체한 이유는 실제 네트워크 요청을 하지 않고 빠르게 테스트하기 위함입니다. 두 번째로, 비동기 에러 처리는 rejects 매처를 사용합니다.

await expect(fetchUser(999)).rejects.toThrow()는 "이 Promise가 거부(reject)되어야 하고, 특정 에러를 던져야 한다"는 것을 검증합니다. 만약 에러가 발생하지 않거나 다른 에러가 발생하면 테스트가 실패합니다.

이것은 에러 핸들링이 올바르게 작동하는지 확인하는 중요한 테스트입니다. 세 번째로, Promise를 직접 반환하는 패턴도 사용할 수 있습니다.

return fetchUser(1).then(...)처럼 return을 꼭 붙여야 Jest가 이 Promise가 완료될 때까지 기다립니다. 만약 return을 빼먹으면 테스트가 Promise를 기다리지 않고 바로 통과해버려서 실제로는 테스트가 실행되지 않습니다.

이것이 초보자가 자주 하는 실수입니다. 네 번째로, 레거시 콜백 스타일 코드는 done 파라미터를 사용합니다.

Jest는 done이 호출될 때까지 테스트를 끝내지 않고 기다립니다. getUserWithCallback의 콜백 안에서 검증을 수행한 후 done()을 호출하여 "이제 테스트가 끝났다"고 알립니다.

만약 done()을 호출하지 않으면 테스트는 타임아웃으로 실패합니다. done을 사용할 때 주의할 점은, 에러가 발생해도 done()을 반드시 호출해야 한다는 것입니다.

그렇지 않으면 테스트가 영원히 기다립니다. 여러분이 이 방법들을 사용하면 비동기 코드를 안정적으로 테스트할 수 있습니다.

특히 async/await 패턴을 사용하면 코드가 간결하고 이해하기 쉬우며, 플래키 테스트 문제도 거의 발생하지 않습니다. 실무에서는 API 호출, 데이터베이스 쿼리, 파일 I/O 등 모든 비동기 작업에 이 패턴들을 적용합니다.

또한 jest.setTimeout()으로 타임아웃을 조정하여 느린 API도 테스트할 수 있습니다.

실전 팁

💡 항상 async/await를 우선 사용하세요. 코드가 가장 읽기 쉽고 에러 처리도 간단합니다. Promise나 done은 특별한 이유가 있을 때만 사용하세요.

💡 Mock을 적극 활용하세요. 실제 API를 호출하면 테스트가 느리고 불안정합니다. jest.fn()으로 Mock을 만들어 빠르고 예측 가능한 테스트를 작성하세요.

💡 에러 케이스를 꼭 테스트하세요. 네트워크 실패, 타임아웃, 잘못된 응답 등 예외 상황이 제대로 처리되는지 확인해야 실무에서 안정적입니다.

💡 타임아웃을 적절히 설정하세요. 기본 5초로 충분하지 않으면 test('...', async () => {...}, 10000)처럼 마지막 인자로 타임아웃을 지정하세요.

💡 병렬 요청을 테스트하세요. Promise.all을 사용한 동시 다발적 요청이나 경쟁 조건을 테스트하여 동시성 버그를 찾아내세요.


8. 테스트 더블 Test Double

시작하며

여러분이 복잡한 시스템을 테스트하려는데, 이 시스템이 데이터베이스, 이메일 서비스, 결제 게이트웨이, 로깅 시스템 등 수많은 외부 의존성과 연결되어 있다면 어떻게 테스트하시겠어요? 모든 의존성을 실제로 연결하면 느리고, 비용이 들고, 불안정한 테스트가 됩니다.

이런 문제는 복잡한 실제 시스템을 단순하게 대체할 방법이 필요하다는 것을 의미합니다. 영화 촬영에서 위험한 장면은 스턴트 더블(stunt double)이 대신하는 것처럼, 테스트에서도 실제 객체를 대신할 "더블"이 필요합니다.

바로 이럴 때 필요한 것이 테스트 더블입니다. Dummy, Stub, Spy, Mock, Fake 등 다양한 종류의 가짜 객체를 상황에 맞게 사용하여 효율적인 테스트를 작성합니다.

개요

간단히 말해서, 테스트 더블은 테스트에서 실제 객체를 대신하는 가짜 객체의 총칭입니다. Dummy, Stub, Spy, Mock, Fake 다섯 가지 유형이 있으며 각각 다른 목적으로 사용됩니다.

왜 이 개념이 필요한지 실무 관점에서 설명하면, 각 테스트 더블은 서로 다른 테스트 시나리오에 최적화되어 있습니다. Dummy는 파라미터를 채우기 위해, Stub은 미리 정해진 값을 반환하기 위해, Spy는 호출을 추적하기 위해, Mock은 행위를 검증하기 위해, Fake는 실제 동작하는 간단한 구현을 제공하기 위해 사용됩니다.

이들을 구분하여 사용하면 테스트 의도가 명확해지고, 테스트 코드가 간결해집니다. 예를 들어, 로깅 함수가 호출되었는지만 확인하고 싶다면 Spy를, 실제 데이터베이스 없이 간단한 메모리 저장소를 쓰고 싶다면 Fake를 사용합니다.

기존에는 모든 것을 "Mock"이라고 불렀다면, 이제는 각 테스트 더블의 차이를 이해하고 적절히 선택하여 사용합니다. 테스트 더블의 다섯 가지 유형은 다음과 같습니다.

첫째, Dummy - 전달되지만 실제로 사용되지 않는 객체. 둘째, Stub - 미리 준비된 답변을 제공.

셋째, Spy - 실제 객체를 감싸서 호출을 기록. 넷째, Mock - 호출 방법에 대한 기대값을 미리 설정하고 검증.

다섯째, Fake - 실제로 동작하지만 단순화된 구현. 이러한 구분을 이해하면 테스트를 더 효과적으로 작성할 수 있습니다.

코드 예제

// 1. Dummy - 전달되지만 사용되지 않음
class Logger {
  log(message) {
    console.log(message);
  }
}

function processOrder(order, logger) {
  // logger는 받지만 이 경로에서는 사용하지 않음
  if (order.items.length === 0) {
    return { status: 'empty' };
  }
  logger.log(`Processing order ${order.id}`);
  return { status: 'processed' };
}

test('빈 주문은 로거 없이 처리', () => {
  const dummyLogger = {}; // 사용되지 않으므로 빈 객체로 충분
  const result = processOrder({ items: [] }, dummyLogger);
  expect(result.status).toBe('empty');
});

// 2. Stub - 미리 정해진 값 반환
class PaymentGateway {
  charge(amount) {
    // 실제로는 외부 API 호출
  }
}

test('Stub으로 결제 성공 시뮬레이션', () => {
  const stubPaymentGateway = {
    charge: () => ({ success: true, transactionId: 'abc123' })
  };

  const result = stubPaymentGateway.charge(10000);
  expect(result.success).toBe(true);
  expect(result.transactionId).toBe('abc123');
});

// 3. Spy - 호출 추적
test('Spy로 로깅 호출 추적', () => {
  const spyLogger = {
    log: jest.fn() // Spy 함수 생성
  };

  processOrder({ id: 1, items: ['item1'] }, spyLogger);

  // 호출되었는지 검증
  expect(spyLogger.log).toHaveBeenCalled();
  expect(spyLogger.log).toHaveBeenCalledWith('Processing order 1');
});

// 4. Mock - 행위 기대값 설정 및 검증
test('Mock으로 이메일 발송 검증', () => {
  const mockEmailService = {
    send: jest.fn().mockReturnValue(true)
  };

  // 미리 기대값 설정
  mockEmailService.send('test@example.com', 'Welcome');

  // 특정 방식으로 호출되었는지 검증
  expect(mockEmailService.send).toHaveBeenCalledWith('test@example.com', 'Welcome');
  expect(mockEmailService.send).toHaveBeenCalledTimes(1);
});

// 5. Fake - 간단한 실제 구현
class FakeDatabase {
  constructor() {
    this.data = [];
  }

  save(item) {
    this.data.push(item);
    return { id: this.data.length, ...item };
  }

  findById(id) {
    return this.data[id - 1];
  }
}

test('Fake DB로 저장 및 조회', () => {
  const fakeDb = new FakeDatabase();

  const saved = fakeDb.save({ name: 'John' });
  expect(saved.id).toBe(1);

  const found = fakeDb.findById(1);
  expect(found.name).toBe('John');
});

설명

이것이 하는 일: 위 코드는 다섯 가지 테스트 더블의 차이와 각각을 언제 사용해야 하는지 보여줍니다. 첫 번째로, Dummy는 가장 단순합니다.

processOrder 함수가 logger 파라미터를 받지만, 빈 주문의 경우 logger를 실제로 사용하지 않습니다. 이럴 때는 빈 객체 {}를 전달하면 충분합니다.

Dummy는 "문법적으로 필요하지만 실제로는 사용되지 않는" 경우에 사용합니다. 진짜 Logger 객체를 만드는 것은 불필요한 낭비입니다.

두 번째로, Stub은 질문에 대답을 제공합니다. "이 금액을 결제하면 어떻게 되나요?"라는 질문에 "성공합니다"라고 미리 정해진 답을 줍니다.

실제 결제 API를 호출하지 않고도 성공/실패 시나리오를 테스트할 수 있습니다. Stub의 핵심은 "상태 검증" - 반환된 값이 올바른지 확인하는 것입니다.

메서드가 몇 번 호출되었는지는 관심 없고, 단지 올바른 값을 반환하는지만 확인합니다. 세 번째로, Spy는 실제 동작을 수행하면서 동시에 호출을 기록합니다.

jest.fn()은 Spy 함수를 만듭니다. processOrder가 실행되면 spyLogger.log가 호출되고, 그 호출이 기록됩니다.

나중에 "로그 함수가 호출되었는가?", "몇 번 호출되었는가?", "어떤 인자로 호출되었는가?"를 검증할 수 있습니다. Spy는 Mock과 비슷하지만, 주로 실제 메서드를 래핑하여 관찰하는 용도로 사용됩니다.

네 번째로, Mock은 행위 검증에 특화되어 있습니다. "이메일 발송 함수가 정확히 한 번, 올바른 인자로 호출되어야 한다"는 기대값을 설정하고 검증합니다.

Mock의 핵심은 "행위 검증" - 특정 메서드가 예상대로 호출되었는지 확인하는 것입니다. 반환값보다는 "어떻게 호출되었는가"에 집중합니다.

toHaveBeenCalledWith로 정확한 인자를 검증하고, toHaveBeenCalledTimes로 호출 횟수를 검증합니다. 다섯 번째로, Fake는 실제로 작동하는 간단한 구현입니다.

FakeDatabase는 진짜 데이터베이스처럼 데이터를 저장하고 조회할 수 있지만, 실제 DB 대신 메모리 배열을 사용합니다. Fake는 다른 테스트 더블들과 달리 "진짜처럼" 동작합니다.

복잡한 로직이 필요하지만 실제 구현을 사용할 수 없을 때 유용합니다. 예를 들어 실제 파일 시스템 대신 메모리 기반 파일 시스템을 사용하는 것입니다.

여러분이 이 구분을 이해하면 테스트 코드가 명확해집니다. "로그가 호출되었는지 확인해야 하니 Spy를 써야겠다", "API 응답을 시뮬레이션해야 하니 Stub을 써야겠다"처럼 의도에 맞는 도구를 선택할 수 있습니다.

실무에서는 대부분 Stub, Spy, Mock을 많이 사용하고, Dummy와 Fake는 특정 상황에서 사용합니다. Jest 같은 프레임워크는 이 모든 기능을 jest.fn()과 jest.spyOn()으로 제공하여 쉽게 사용할 수 있습니다.

실전 팁

💡 용어를 정확히 구분하세요. 모든 걸 "Mock"이라고 부르면 팀원과 소통할 때 혼란이 생깁니다. "Stub으로 API 응답을 시뮬레이션했어요"처럼 정확한 용어를 사용하세요.

💡 가능한 한 Stub을 사용하세요. Mock보다 Stub이 더 간단하고 유지보수하기 쉽습니다. 행위 검증이 꼭 필요한 경우에만 Mock을 사용하세요.

💡 Fake는 재사용하세요. 잘 만든 FakeDatabase나 FakeFileSystem은 여러 테스트에서 재사용할 수 있습니다. 테스트 유틸리티로 분리하세요.

💡 과도한 Mock을 피하세요. 모든 의존성을 Mock하면 테스트가 실제 동작과 동떨어집니다. 핵심 외부 의존성만 Mock하고 나머지는 실제 객체를 사용하세요.

💡 Spy를 사용해 레거시 코드를 테스트하세요. 수정할 수 없는 레거시 코드를 테스트할 때 jest.spyOn()으로 기존 메서드를 래핑하여 호출을 추적할 수 있습니다.


#JavaScript#Testing#Jest#TDD#Integration

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.