🤖

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

⚠️

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

A

AI Generated

2026. 1. 31. · 13 Views

테스트 전략 완벽 가이드

초급 개발자를 위한 테스트 전략의 모든 것을 다룹니다. Vitest부터 단위 테스트, 통합 테스트, E2E 테스트까지 실무에서 바로 적용할 수 있는 테스트 작성법을 배워봅니다.


목차

  1. 테스트의_중요성
  2. Vitest_프레임워크
  3. 단위_테스트_작성법
  4. 통합_테스트_전략
  5. E2E_테스트_설계
  6. Docker로_테스트_환경_구축

1. 테스트의 중요성

김개발 씨는 어느 금요일 저녁, 자신 있게 배포 버튼을 눌렀습니다. 주말 내내 마음 편히 쉴 생각이었죠.

그런데 토요일 새벽 3시, 긴급 전화가 울렸습니다. "김 대리님, 결제 시스템이 전부 멈췄어요!"

테스트란 코드가 의도한 대로 동작하는지 자동으로 검증하는 코드입니다. 마치 비행기 이륙 전 조종사가 체크리스트를 확인하는 것과 같습니다.

테스트가 없다면 매번 수동으로 모든 기능을 확인해야 하고, 사람의 실수로 버그가 사용자에게 그대로 전달될 수 있습니다.

다음 코드를 살펴봅시다.

// 테스트가 없는 코드의 위험성을 보여주는 예제
function calculateDiscount(price: number, discountRate: number): number {
  // 실수로 곱하기 대신 나누기를 했다면?
  return price * (1 - discountRate / 100);
}

// 테스트 코드가 있다면 즉시 발견 가능
import { expect, test } from 'vitest';

test('10% 할인이 올바르게 계산되어야 한다', () => {
  const result = calculateDiscount(10000, 10);
  // 10000원의 10% 할인 = 9000원
  expect(result).toBe(9000);
});

김개발 씨는 입사 6개월 차 주니어 개발자입니다. 처음에는 테스트 코드 작성이 시간 낭비라고 생각했습니다.

"기능 개발도 바쁜데 테스트까지 작성해야 하나요?" 선배인 박시니어 씨에게 이렇게 물었던 적도 있습니다. 그러나 그 금요일 밤 사건 이후, 김개발 씨의 생각은 완전히 바뀌었습니다.

문제는 단 한 줄의 코드 수정이었습니다. 할인율 계산 로직을 살짝 건드렸는데, 그것이 결제 시스템 전체를 마비시킨 것입니다.

테스트란 무엇일까요? 쉽게 비유하자면, 테스트는 마치 자동차 계기판의 경고등과 같습니다.

엔진에 문제가 생기면 경고등이 켜지듯이, 코드에 문제가 생기면 테스트가 실패합니다. 운전 중에 매번 차에서 내려 엔진을 열어볼 수는 없잖아요.

경고등이 그 역할을 대신해주는 것입니다. 테스트가 없던 시절의 개발은 어땠을까요?

개발자들은 코드를 수정할 때마다 직접 브라우저를 열고, 버튼을 클릭하고, 폼을 입력하며 일일이 확인해야 했습니다. 화면이 10개면 10번, 100개면 100번 반복해야 했습니다.

더 큰 문제는 사람은 실수를 한다는 것입니다. 피곤하거나 급할 때는 확인을 건너뛰기도 합니다.

바로 이런 문제를 해결하기 위해 자동화된 테스트가 등장했습니다. 테스트 코드를 한 번 작성해두면, 버튼 하나로 수백 개의 검증을 몇 초 만에 끝낼 수 있습니다.

사람이 직접 확인하면 하루 종일 걸릴 작업을 컴퓨터가 대신해주는 것입니다. 위의 코드를 살펴보겠습니다.

calculateDiscount 함수는 가격과 할인율을 받아 할인된 가격을 반환합니다. 만약 누군가 실수로 이 로직을 잘못 수정한다면 어떻게 될까요?

테스트가 있다면 즉시 "10000원의 10% 할인이 9000원이 아니라 다른 값이 나왔다"고 알려줍니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 쇼핑몰에서 프로모션 할인 기능을 개발한다고 가정해봅시다. 중복 할인, 최대 할인 한도, VIP 추가 할인 등 수십 가지 케이스가 있습니다.

이 모든 경우를 매번 수동으로 테스트하는 것은 불가능에 가깝습니다. 하지만 주의할 점도 있습니다.

테스트를 작성했다고 해서 모든 버그를 잡을 수 있는 것은 아닙니다. 테스트는 작성한 케이스만 검증합니다.

따라서 어떤 케이스를 테스트할지 잘 선택하는 것이 중요합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.

그 사건 이후 김개발 씨는 모든 결제 관련 코드에 테스트를 추가했습니다. 이제는 코드를 수정할 때마다 테스트를 먼저 실행합니다.

더 이상 새벽에 긴급 전화를 받을 일은 없을 것입니다. 테스트는 개발 속도를 늦추는 것이 아니라, 오히려 장기적으로 개발 속도를 높여줍니다.

버그 수정에 드는 시간을 줄이고, 자신 있게 코드를 수정할 수 있게 해주기 때문입니다.

실전 팁

💡 - 테스트 작성은 초기 비용이지만, 유지보수 비용을 크게 절감합니다

  • 버그가 발생하면 먼저 해당 버그를 재현하는 테스트를 작성하세요

2. Vitest 프레임워크

박시니어 씨가 김개발 씨에게 물었습니다. "요즘 테스트 프레임워크는 뭘 쓰세요?" 김개발 씨는 예전에 Jest를 잠깐 써봤지만, 설정이 복잡했던 기억이 있습니다.

"Vitest 한번 써보세요. 엄청 빠르고 설정도 간단해요."

Vitest는 Vite 기반의 차세대 테스트 프레임워크입니다. Jest와 거의 동일한 API를 제공하면서도, 빌드 도구 Vite의 속도 장점을 그대로 가져왔습니다.

ESM, TypeScript를 별도 설정 없이 바로 지원하며, 핫 모듈 리로드로 테스트 파일 수정 시 즉시 재실행됩니다.

다음 코드를 살펴봅시다.

// vitest.config.ts - 기본 설정
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    // 테스트 환경 설정 (브라우저 API 사용 시 jsdom)
    environment: 'node',
    // 전역 테스트 함수 사용 (describe, it, expect)
    globals: true,
    // 테스트 파일 패턴
    include: ['src/**/*.{test,spec}.{js,ts}'],
    // 커버리지 설정
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html'],
    },
  },
});

테스트 프레임워크를 선택하는 것은 마치 요리 도구를 고르는 것과 같습니다. 좋은 프라이팬이 있으면 요리가 훨씬 수월해지듯이, 좋은 테스트 프레임워크는 테스트 작성을 즐겁게 만들어줍니다.

오랫동안 JavaScript 테스트의 대명사는 Jest였습니다. 페이스북이 만들고, React 프로젝트에서 기본으로 사용되며, 거의 모든 튜토리얼이 Jest를 기준으로 작성되어 있습니다.

그런데 왜 갑자기 Vitest가 등장한 걸까요? 문제는 속도였습니다.

Jest는 CommonJS 기반으로 설계되었고, TypeScript나 ESM을 사용하려면 복잡한 설정과 변환 과정이 필요했습니다. 프로젝트가 커질수록 테스트 실행 시간도 길어졌습니다.

테스트가 느리면 개발자들은 점점 테스트 실행을 미루게 됩니다. Vitest는 이 문제를 해결하기 위해 탄생했습니다.

Vite라는 초고속 빌드 도구 위에서 동작하기 때문에, 필요한 파일만 즉시 변환하고 실행합니다. 처음 실행할 때부터 빠르고, 파일 수정 시에는 변경된 테스트만 다시 실행하는 watch 모드가 특히 뛰어납니다.

위의 설정 파일을 살펴보겠습니다. defineConfig 함수로 타입 안전한 설정을 작성합니다.

environment는 테스트 실행 환경을 지정합니다. Node.js API만 사용한다면 'node', 브라우저 DOM API가 필요하다면 'jsdom'이나 'happy-dom'을 선택합니다.

globals: true 설정은 매 파일마다 import 문을 작성하지 않아도 describe, it, expect 같은 함수를 바로 사용할 수 있게 해줍니다. Jest에서 넘어온 개발자라면 이 설정이 친숙할 것입니다.

include 패턴은 어떤 파일을 테스트 파일로 인식할지 정합니다. 보통 .test.ts 또는 .spec.ts 확장자를 사용합니다.

팀 내에서 하나의 규칙을 정하고 일관되게 사용하는 것이 좋습니다. 실제 현업에서 Vitest의 진가는 개발자 경험에서 드러납니다.

테스트를 수정하면 1초도 안 되어 결과가 나타납니다. 이렇게 빠른 피드백 루프는 테스트 주도 개발(TDD)을 실천할 때 큰 차이를 만듭니다.

김개발 씨도 Vitest를 도입한 후 테스트 작성이 즐거워졌다고 합니다. 예전에는 테스트 실행 버튼을 누르고 커피를 마시러 갔는데, 이제는 눈 깜짝할 사이에 결과가 나옵니다.

하나 더 주목할 점은 Jest와의 호환성입니다. Vitest는 Jest의 API를 거의 그대로 지원합니다.

기존 Jest 테스트를 Vitest로 마이그레이션할 때 import 문만 바꾸면 되는 경우가 대부분입니다.

실전 팁

💡 - package.json에 "test": "vitest"를 추가하면 npm test로 바로 실행 가능합니다

  • --ui 플래그로 브라우저 기반 테스트 UI를 띄울 수 있습니다

3. 단위 테스트 작성법

"테스트를 어떻게 시작해야 할지 모르겠어요." 김개발 씨가 박시니어 씨에게 솔직히 털어놓았습니다. "일단 가장 작은 단위부터 시작해보세요.

함수 하나, 클래스 하나를 테스트하는 거예요." 이것이 바로 단위 테스트입니다.

**단위 테스트(Unit Test)**는 코드의 가장 작은 단위인 함수나 메서드가 올바르게 동작하는지 검증합니다. 외부 의존성 없이 독립적으로 실행되며, 실행 속도가 매우 빠릅니다.

버그의 정확한 위치를 찾기 쉽고, 리팩토링 시 안전망 역할을 합니다.

다음 코드를 살펴봅시다.

// utils/validator.ts
export function isValidEmail(email: string): boolean {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return emailRegex.test(email);
}

// utils/validator.test.ts
import { describe, it, expect } from 'vitest';
import { isValidEmail } from './validator';

describe('isValidEmail', () => {
  it('올바른 이메일 형식을 통과시킨다', () => {
    expect(isValidEmail('user@example.com')).toBe(true);
    expect(isValidEmail('test.user@domain.co.kr')).toBe(true);
  });

  it('잘못된 이메일 형식을 거부한다', () => {
    expect(isValidEmail('invalid-email')).toBe(false);
    expect(isValidEmail('@nodomain.com')).toBe(false);
    expect(isValidEmail('spaces in@email.com')).toBe(false);
  });
});

단위 테스트를 이해하기 위해 레고 블록을 떠올려 보겠습니다. 거대한 레고 성을 만들기 전에, 각각의 블록이 제대로 끼워지는지 먼저 확인하는 것이 단위 테스트입니다.

블록 하나가 불량이면 성 전체가 무너질 수 있으니까요. 김개발 씨는 회원가입 기능을 개발하고 있었습니다.

이메일 검증, 비밀번호 강도 체크, 중복 확인 등 여러 단계가 있습니다. 처음에는 전체 회원가입 플로우를 한 번에 테스트하려고 했습니다.

그런데 테스트가 실패하면 어디가 문제인지 찾기가 너무 어려웠습니다. 박시니어 씨가 조언했습니다.

"이메일 검증 함수만 따로 테스트해보세요. 그다음 비밀번호 검증, 그다음 중복 확인.

이렇게 작은 단위로 쪼개면 문제를 빨리 찾을 수 있어요." 위의 코드에서 describe 블록은 관련된 테스트들을 그룹으로 묶습니다. 'isValidEmail'이라는 함수에 대한 테스트 모음이라는 뜻입니다.

it 블록은 개별 테스트 케이스를 정의합니다. 첫 번째 인자로 이 테스트가 무엇을 검증하는지 한글로 명확하게 적습니다.

expect는 실제 결과와 기대 결과를 비교합니다. toBe는 정확히 일치하는지 확인하는 매처(matcher)입니다.

결과가 true여야 하면 expect(...).toBe(true), false여야 하면 expect(...).toBe(false)를 사용합니다. 좋은 단위 테스트의 특징은 AAA 패턴을 따르는 것입니다.

Arrange(준비) - 테스트에 필요한 데이터를 설정합니다. Act(실행) - 테스트 대상 함수를 호출합니다.

Assert(검증) - 결과가 예상과 일치하는지 확인합니다. 실무에서 단위 테스트는 특히 유틸리티 함수에 유용합니다.

날짜 포맷팅, 금액 계산, 문자열 변환 같은 순수 함수들은 입력과 출력이 명확하기 때문에 테스트하기 쉽습니다. 주의할 점은 테스트 케이스 선정입니다.

모든 가능한 입력을 테스트할 수는 없습니다. 대신 경계값예외 상황을 집중적으로 테스트합니다.

이메일 검증이라면 정상 이메일, 골뱅이 없는 경우, 도메인 없는 경우, 공백이 포함된 경우 등을 확인합니다. 김개발 씨는 이제 새로운 함수를 만들 때마다 테스트부터 작성합니다.

처음에는 번거로웠지만, 이제는 테스트 없이 코드를 작성하면 오히려 불안해집니다. 테스트가 있으면 자신 있게 리팩토링할 수 있기 때문입니다.

실전 팁

💡 - 하나의 테스트는 하나의 동작만 검증하세요 (Single Responsibility)

  • 테스트 이름은 "~해야 한다" 형식으로 명확하게 작성하세요

4. 통합 테스트 전략

김개발 씨가 자신 있게 말했습니다. "단위 테스트는 다 통과했어요!" 그런데 막상 API를 호출하니 에러가 발생했습니다.

함수 하나하나는 잘 동작하는데, 왜 합치면 문제가 생기는 걸까요? 이것이 바로 통합 테스트가 필요한 이유입니다.

**통합 테스트(Integration Test)**는 여러 모듈이 함께 동작할 때 올바르게 협력하는지 검증합니다. 데이터베이스 연결, API 호출, 서비스 간 통신 등 실제 환경과 유사한 조건에서 테스트합니다.

단위 테스트가 부품 검사라면, 통합 테스트는 조립 후 작동 검사입니다.

다음 코드를 살펴봅시다.

// services/userService.ts
import { db } from '../db';
import { hashPassword } from '../utils/crypto';

export async function createUser(email: string, password: string) {
  // 중복 확인
  const existing = await db.user.findUnique({ where: { email } });
  if (existing) throw new Error('이미 존재하는 이메일입니다');

  // 비밀번호 해싱 후 저장
  const hashedPassword = await hashPassword(password);
  return db.user.create({
    data: { email, password: hashedPassword },
  });
}

// services/userService.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { createUser } from './userService';
import { db } from '../db';

describe('createUser 통합 테스트', () => {
  beforeEach(async () => {
    // 테스트 전 데이터 초기화
    await db.user.deleteMany();
  });

  it('새 사용자를 성공적으로 생성한다', async () => {
    const user = await createUser('test@example.com', 'password123');
    expect(user.email).toBe('test@example.com');
    expect(user.password).not.toBe('password123'); // 해싱됨
  });

  it('중복 이메일은 에러를 발생시킨다', async () => {
    await createUser('dup@example.com', 'pass1');
    await expect(createUser('dup@example.com', 'pass2'))
      .rejects.toThrow('이미 존재하는 이메일입니다');
  });
});

단위 테스트와 통합 테스트의 차이를 요리로 비유해 보겠습니다. 단위 테스트는 재료 검수입니다.

당근이 신선한지, 고기가 상하지 않았는지 개별적으로 확인합니다. 통합 테스트는 실제로 요리를 만들어보는 것입니다.

재료가 모두 좋아도 레시피가 잘못되면 맛없는 요리가 나올 수 있으니까요. 김개발 씨의 회원가입 기능을 다시 살펴봅시다.

이메일 검증 함수, 비밀번호 해싱 함수, 데이터베이스 저장 함수가 각각 단위 테스트를 통과했습니다. 하지만 이 함수들이 순서대로 잘 연결되는지는 별개의 문제입니다.

위의 코드에서 createUser 함수는 여러 작업을 조합합니다. 먼저 데이터베이스에서 중복을 확인하고, 비밀번호를 해싱하고, 최종적으로 사용자를 저장합니다.

통합 테스트는 이 전체 흐름이 제대로 동작하는지 검증합니다. beforeEach 훅을 주목해주세요.

각 테스트가 실행되기 전에 데이터베이스를 초기화합니다. 통합 테스트에서 가장 중요한 것 중 하나가 테스트 격리입니다.

이전 테스트의 데이터가 다음 테스트에 영향을 주면 안 됩니다. 두 번째 테스트 케이스를 보면 rejects.toThrow를 사용했습니다.

이것은 비동기 함수가 에러를 던지는지 확인하는 방법입니다. 중복 이메일로 가입을 시도하면 "이미 존재하는 이메일입니다" 에러가 발생해야 합니다.

통합 테스트의 단점도 있습니다. 실제 데이터베이스를 사용하기 때문에 단위 테스트보다 느립니다.

또한 데이터베이스 서버가 실행 중이어야 테스트할 수 있습니다. 이런 이유로 CI/CD 파이프라인에서는 Docker로 테스트용 데이터베이스를 띄우는 경우가 많습니다.

실무에서는 테스트 피라미드 개념을 따릅니다. 맨 아래에 단위 테스트를 많이 두고, 그 위에 통합 테스트를 적당히, 맨 위에 E2E 테스트를 조금 둡니다.

단위 테스트가 가장 빠르고 안정적이기 때문입니다. 박시니어 씨가 말했습니다.

"단위 테스트만으로는 부족하고, 통합 테스트만으로도 부족해요. 둘 다 있어야 진짜 안전한 코드예요." 김개발 씨는 고개를 끄덕였습니다.

이제 왜 테스트 종류가 여러 가지인지 이해가 되었습니다.

실전 팁

💡 - 테스트용 데이터베이스는 운영 DB와 분리하세요 (환경변수로 구분)

  • beforeEach에서 데이터를 초기화하여 테스트 간 독립성을 보장하세요

5. E2E 테스트 설계

"테스트가 다 통과하는데 왜 고객이 로그인을 못 한대요?" 김개발 씨가 당황했습니다. 알고 보니 로그인 버튼이 화면에서 가려져 클릭이 안 되는 문제였습니다.

서버는 멀쩡한데 프론트엔드 UI에서 문제가 생긴 것입니다. 이런 문제를 잡으려면 E2E 테스트가 필요합니다.

**E2E 테스트(End-to-End Test)**는 실제 사용자의 시나리오를 그대로 재현하여 시스템 전체를 검증합니다. 브라우저를 자동으로 조작하여 버튼 클릭, 폼 입력, 페이지 이동 등을 시뮬레이션합니다.

Playwright나 Cypress 같은 도구를 사용하며, 프론트엔드부터 백엔드까지 전체 스택을 테스트합니다.

다음 코드를 살펴봅시다.

// e2e/login.spec.ts (Playwright 사용)
import { test, expect } from '@playwright/test';

test.describe('로그인 기능', () => {
  test('올바른 정보로 로그인하면 대시보드로 이동한다', async ({ page }) => {
    // 로그인 페이지 접속
    await page.goto('/login');

    // 이메일과 비밀번호 입력
    await page.fill('[data-testid="email-input"]', 'user@example.com');
    await page.fill('[data-testid="password-input"]', 'correctPassword');

    // 로그인 버튼 클릭
    await page.click('[data-testid="login-button"]');

    // 대시보드 페이지로 이동했는지 확인
    await expect(page).toHaveURL('/dashboard');
    await expect(page.locator('h1')).toContainText('환영합니다');
  });

  test('잘못된 비밀번호로 로그인하면 에러 메시지가 표시된다', async ({ page }) => {
    await page.goto('/login');
    await page.fill('[data-testid="email-input"]', 'user@example.com');
    await page.fill('[data-testid="password-input"]', 'wrongPassword');
    await page.click('[data-testid="login-button"]');

    // 에러 메시지 확인
    await expect(page.locator('[data-testid="error-message"]'))
      .toContainText('비밀번호가 일치하지 않습니다');
  });
});

E2E 테스트를 이해하기 위해 자동차 시험 주행을 떠올려 보겠습니다. 엔진 테스트, 브레이크 테스트를 각각 통과했다고 해서 바로 출고하지 않습니다.

실제로 도로를 달려보며 모든 부품이 조화롭게 동작하는지 확인합니다. E2E 테스트가 바로 이 역할입니다.

김개발 씨의 로그인 버튼 문제를 다시 생각해봅시다. 백엔드 API는 정상이었습니다.

단위 테스트도 통합 테스트도 통과했습니다. 하지만 실제 사용자 환경에서는 CSS 충돌로 버튼이 다른 요소에 가려져 있었습니다.

이런 문제는 실제로 브라우저를 띄워서 클릭해봐야 발견할 수 있습니다. 위의 코드에서 Playwright를 사용했습니다.

Playwright는 Microsoft가 만든 E2E 테스트 도구로, Chrome, Firefox, Safari를 모두 지원합니다. page.goto로 페이지에 접속하고, page.fill로 입력 필드에 텍스트를 입력하고, page.click으로 버튼을 클릭합니다.

data-testid 속성을 주목해주세요. E2E 테스트에서는 요소를 선택하는 방법이 중요합니다.

클래스명이나 텍스트로 선택하면 디자인 변경 시 테스트가 깨질 수 있습니다. data-testid처럼 테스트 전용 속성을 사용하면 UI가 변경되어도 테스트가 안정적으로 동작합니다.

expect(page).toHaveURL은 현재 페이지의 URL을 검증합니다. 로그인 성공 후 대시보드 페이지로 제대로 이동했는지 확인하는 것입니다.

page.locator는 특정 요소를 찾고, toContainText는 그 요소에 특정 텍스트가 포함되어 있는지 확인합니다. E2E 테스트의 장점은 명확합니다.

사용자가 실제로 겪을 문제를 미리 발견할 수 있습니다. 하지만 단점도 분명합니다.

실행 속도가 느리고, 네트워크 상태나 브라우저 버전에 따라 불안정할 수 있습니다. 이런 특성 때문에 E2E 테스트는 핵심 시나리오에만 집중하는 것이 좋습니다.

실무에서는 E2E 테스트를 **회귀 테스트(Regression Test)**로 많이 활용합니다. 새로운 기능을 추가할 때 기존 기능이 깨지지 않았는지 확인하는 용도입니다.

로그인, 결제, 회원가입 같은 핵심 플로우는 반드시 E2E 테스트로 보호해야 합니다. 박시니어 씨가 조언했습니다.

"E2E 테스트는 양날의 검이에요. 너무 많으면 유지보수가 힘들고, 너무 적으면 버그가 새어나가요.

중요한 시나리오 10개 정도만 철저하게 관리하는 게 좋아요."

실전 팁

💡 - data-testid 속성을 사용하여 테스트 선택자를 안정적으로 유지하세요

  • 핵심 사용자 시나리오(로그인, 결제 등)에만 E2E 테스트를 집중하세요

6. Docker로 테스트 환경 구축

"제 컴퓨터에서는 테스트가 통과하는데요..." 김개발 씨의 이 말에 팀원들이 한숨을 쉬었습니다. 개발 환경마다 Node 버전이 다르고, 데이터베이스 설정이 달라서 생기는 문제였습니다.

박시니어 씨가 말했습니다. "Docker로 테스트 환경을 통일하면 이런 일이 없어요."

Docker는 애플리케이션과 실행 환경을 컨테이너로 패키징하는 기술입니다. 테스트 환경을 Docker로 구성하면 모든 팀원이 동일한 환경에서 테스트할 수 있습니다.

CI/CD 파이프라인에서도 로컬과 똑같은 환경을 재현할 수 있어 "내 컴퓨터에서는 되는데" 문제를 해결합니다.

다음 코드를 살펴봅시다.

// docker-compose.test.yml
version: '3.8'
services:
  # 테스트용 PostgreSQL
  test-db:
    image: postgres:15
    environment:
      POSTGRES_USER: test
      POSTGRES_PASSWORD: test
      POSTGRES_DB: testdb
    ports:
      - "5433:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U test"]
      interval: 5s
      timeout: 5s
      retries: 5

  # 테스트 실행 컨테이너
  test-runner:
    build:
      context: .
      dockerfile: Dockerfile.test
    environment:
      DATABASE_URL: postgresql://test:test@test-db:5432/testdb
    depends_on:
      test-db:
        condition: service_healthy
    command: npm run test:ci

# package.json scripts
# "test:ci": "vitest run --coverage",
# "test:docker": "docker-compose -f docker-compose.test.yml up --build --abort-on-container-exit"

"내 컴퓨터에서는 되는데" 현상을 Works On My Machine 증후군이라고 부릅니다. 이 문제가 발생하는 이유는 간단합니다.

개발자마다 Node.js 버전이 다르고, 데이터베이스 버전이 다르고, 운영체제도 다릅니다. 미묘한 환경 차이가 예상치 못한 버그를 만들어냅니다.

Docker는 마치 밀폐된 실험실과 같습니다. 실험실 안의 온도, 습도, 기압을 똑같이 맞추면 누가 실험해도 같은 결과가 나오듯이, Docker 컨테이너 안에서는 모든 환경이 동일합니다.

위의 docker-compose 파일을 살펴보겠습니다. test-db 서비스는 테스트용 PostgreSQL 데이터베이스입니다.

운영 DB와 완전히 분리되어 있어 테스트 데이터가 실제 데이터를 오염시킬 걱정이 없습니다. 포트도 5433으로 다르게 설정하여 로컬에서 실행 중인 다른 DB와 충돌하지 않습니다.

healthcheck는 중요한 설정입니다. 데이터베이스가 완전히 시작되기 전에 테스트가 실행되면 연결 에러가 발생합니다.

healthcheck는 데이터베이스가 준비될 때까지 기다렸다가 테스트를 시작하게 해줍니다. test-runner 서비스는 실제 테스트를 실행하는 컨테이너입니다.

depends_oncondition: service_healthy 설정으로 test-db가 완전히 준비된 후에만 시작됩니다. DATABASE_URL 환경변수로 테스트 DB에 연결합니다.

이 구성의 강력한 점은 일회용 환경이라는 것입니다. 테스트가 끝나면 컨테이너와 함께 모든 데이터가 사라집니다.

다음 테스트는 항상 깨끗한 상태에서 시작합니다. 이전 테스트의 잔재가 남아서 생기는 유령 버그를 방지할 수 있습니다.

CI/CD 파이프라인에서도 같은 docker-compose 파일을 사용합니다. GitHub Actions나 GitLab CI에서 docker-compose up 명령 하나로 동일한 테스트 환경을 구축할 수 있습니다.

로컬에서 통과한 테스트가 CI에서 실패하는 일이 없어집니다. 김개발 씨는 Docker 도입 후 더 이상 환경 문제로 고생하지 않습니다.

새 팀원이 합류해도 docker-compose up 한 번이면 테스트 환경이 준비됩니다. 온보딩 시간이 절반으로 줄었습니다.

마지막으로 한 가지 팁을 드리자면, 테스트용 Docker 이미지는 가능한 가볍게 만드는 것이 좋습니다. 불필요한 의존성을 제거하고, 멀티스테이지 빌드를 활용하면 테스트 실행 시간을 크게 단축할 수 있습니다.

실전 팁

💡 - docker-compose down -v로 볼륨까지 삭제하여 완전히 깨끗한 상태로 시작하세요

  • CI에서는 --abort-on-container-exit 옵션으로 테스트 완료 후 자동 종료시키세요

이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!

#TypeScript#Vitest#UnitTest#IntegrationTest#E2ETest#Testing,TypeScript

댓글 (0)

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