"테스트 없는 코드는 동작하는 코드가 아니라, 아직 버그를 발견하지 못한 코드다" — 테스트를 어디서부터 시작해야 할까요?

Express API를 배포한 뒤 "이 엔드포인트 고쳤는데 다른 데서 터지지 않을까?" 불안한 적이 있다면, 자동화된 테스트가 필요한 시점입니다. Jest와 Supertest의 조합이면 Express API 테스트의 대부분을 커버할 수 있습니다.

개념 정의

단위 테스트 는 함수나 모듈 하나를 격리하여 테스트하는 것입니다. 통합 테스트 는 여러 계층(라우트 → 미들웨어 → 핸들러)이 함께 동작하는지 테스트합니다. Express API에서는 Supertest를 사용한 HTTP 통합 테스트 가 가장 효과적입니다.

환경 설정

BASH
npm install -D jest supertest
JSON
// package.json
{
  "scripts": {
    "test": "jest --forceExit --detectOpenHandles"
  }
}

--forceExit은 테스트 완료 후 열린 핸들(DB 커넥션 등)이 있어도 프로세스를 종료합니다. --detectOpenHandles는 어떤 핸들이 열려 있는지 알려줍니다.

app과 server 분리 — 테스트의 전제 조건

Supertest가 Express 앱을 테스트하려면, app.listen()을 분리해야 합니다.

JS
// app.js — Express 앱 설정만
const express = require('express');
const app = express();
app.use(express.json());
// 라우트 등록...
module.exports = app;
JS
// server.js — 실제 서버 구동
const app = require('./app');
app.listen(3000);

이렇게 분리하면 테스트에서 apprequire해서 실제 포트 없이 테스트할 수 있습니다.

Supertest로 통합 테스트

Supertest는 Express 앱에 HTTP 요청을 보내고 응답을 검증합니다. 실제 서버를 띄우지 않아도 됩니다.

JS
const request = require('supertest');
const app = require('../app');

describe('GET /users', () => {
  it('200 상태 코드와 배열을 반환한다', async () => {
    const res = await request(app)
      .get('/users')
      .expect('Content-Type', /json/)
      .expect(200);

    expect(Array.isArray(res.body)).toBe(true);
  });
});

request(app)이 핵심입니다. Supertest가 내부적으로 임시 서버를 생성하고, 요청을 보내고, 응답을 받은 뒤 서버를 닫습니다.

POST 요청 테스트

JS
describe('POST /users', () => {
  it('새 사용자를 생성하고 201을 반환한다', async () => {
    const res = await request(app)
      .post('/users')
      .send({ email: 'test@example.com', name: 'Kim' })
      .expect(201);

    expect(res.body).toHaveProperty('id');
    expect(res.body.email).toBe('test@example.com');
  });

  it('이메일 없이 요청하면 400을 반환한다', async () => {
    await request(app)
      .post('/users')
      .send({ name: 'Kim' })
      .expect(400);
  });
});

.send()로 요청 바디를, .set()으로 헤더를 설정합니다.

인증이 필요한 엔드포인트

JS
describe('GET /profile', () => {
  it('토큰 없이 요청하면 401을 반환한다', async () => {
    await request(app).get('/profile').expect(401);
  });

  it('유효한 토큰으로 요청하면 사용자 정보를 반환한다', async () => {
    const token = jwt.sign({ id: 1 }, process.env.JWT_SECRET);
    const res = await request(app)
      .get('/profile')
      .set('Authorization', `Bearer ${token}`) // ← 헤더 설정
      .expect(200);

    expect(res.body).toHaveProperty('id', 1);
  });
});

단위 테스트와 Mock

서비스 로직을 데이터베이스 없이 테스트하려면 Mock이 필요합니다.

JS
// services/userService.js
class UserService {
  constructor(userRepository) {
    this.userRepository = userRepository;
  }

  async getUserById(id) {
    const user = await this.userRepository.findById(id);
    if (!user) throw new Error('User not found');
    return user;
  }
}
JS
// __tests__/userService.test.js
describe('UserService', () => {
  it('존재하는 사용자를 반환한다', async () => {
    const mockRepo = {
      findById: jest.fn().mockResolvedValue({ id: 1, name: 'Kim' }),
    };
    const service = new UserService(mockRepo);

    const user = await service.getUserById(1);

    expect(user.name).toBe('Kim');
    expect(mockRepo.findById).toHaveBeenCalledWith(1);
  });

  it('존재하지 않는 사용자는 에러를 던진다', async () => {
    const mockRepo = {
      findById: jest.fn().mockResolvedValue(null),
    };
    const service = new UserService(mockRepo);

    await expect(service.getUserById(999)).rejects.toThrow('User not found');
  });
});

jest.fn().mockResolvedValue()는 async 함수를 mock합니다. 실제 DB 없이 비즈니스 로직을 검증할 수 있습니다.

테스트 구조 — 어디에 무엇을 테스트하나

테스트 종류대상도구속도
단위 테스트서비스 로직, 유틸 함수Jest + Mock빠름
** 통합 테스트**API 엔드포인트 전체 흐름Jest + Supertest보통
E2E 테스트실제 DB + 서버Supertest + 테스트 DB느림

테스트 피라미드 원칙: 단위 테스트를 가장 많이, 통합 테스트를 적당히, E2E를 최소한으로. 하지만 Express API에서는 Supertest 통합 테스트의 가성비가 가장 높습니다.

주의할 점

테스트 간 데이터 격리

테스트가 서로의 데이터에 영향을 주면 실행 순서에 따라 결과가 달라집니다.

JS
beforeEach(async () => {
  await prisma.user.deleteMany(); // ← 각 테스트 전에 초기화
});

afterAll(async () => {
  await prisma.$disconnect(); // ← 커넥션 정리
});

app.listen()이 테스트 파일에 있으면 안 된다

app.listen()이 import 시점에 실행되면 포트 충돌이 발생합니다. 앞에서 설명한 app/server 분리 패턴을 반드시 지켜야 합니다.

Mock을 남용하지 않기

모든 것을 mock하면 "mock이 올바르게 호출되었다"는 것만 검증합니다. 실제 동작을 검증하지 못합니다. 핵심 비즈니스 로직은 가능하면 실제 테스트 DB를 사용하는 통합 테스트로 커버하는 것이 안전합니다.

정리

항목설명
SupertestExpress 앱에 HTTP 요청을 보내 응답 검증, 실제 서버 불필요
app/server 분리테스트를 위해 app.listen()을 별도 파일로 분리
jest.fn()Mock 함수 생성, mockResolvedValue로 async mock
데이터 격리beforeEach에서 DB 초기화, afterAll에서 커넥션 정리
테스트 우선순위Express API에서는 Supertest 통합 테스트가 가성비 최고
댓글 로딩 중...