백엔드 로직 하나 수정하고 포스트맨으로 일일이 엔드포인트를 호출하며 테스트했던 경험이 있을 것이다. 코드가 복잡해질수록 수정 위치와 멀리 떨어진 다른 API가 갑자기 터지는 사이드 이펙트를 일일이 손으로 찾아내기는 사실상 불가능에 가깝다. 다만 정확한 테스트 환경 구축 방법이나 데이터베이스 Mocking 처리 기준을 모르는 경우가 많다. 이번에는 Jest와 Supertest가 정확히 뭔지, 왜 필요한지, 실무에서 어떻게 구성하는지 완벽하게 정리해서 소개하겠다.
단위 테스트가 함수 단위의 작은 로직을 검증한다면, API 통합 테스트는 HTTP 요청부터 라우팅, 미들웨어, 데이터베이스 연동까지 전체 흐름이 의도대로 동작하는지 확인하는 작업이다. 포스트맨이나 인섬니아 같은 GUI 도구로 검증하던 과정을 코드로 자동화하는 셈이다.
Jest는 페이스북에서 만든 자바스크립트 테스트 프레임워크로 테스트 실행기, 단정문, Mocking 라이브러리를 모두 제공한다. 여기에 Supertest를 조합하면 실제 HTTP 서버를 실행하지 않고도 Express나 Fastify 애플리케이션에 가상의 HTTP 요청을 보내서 응답 코드, 헤더, 본문을 검증할 수 있다.
| 구분 | 단위 테스트 (Unit Test) | API 통합 테스트 (Integration Test) |
|---|---|---|
| 검증 대상 | 개별 함수 또는 클래스 메서드 | HTTP 엔드포인트 전체 요청/응답 흐름 |
| 의존성 처리 | 외부 DB, API 등 모든 의존성을 Mock으로 대체 | 실제 Express 앱 객체 및 (테스트용) DB 연동 검증 |
| 실행 속도 | 매우 빠름 (밀리초 단위) | 단위 테스트 대비 상대적으로 느림 |
| 주요 사용 도구 | Jest, Vitest | Jest + Supertest |
Jest와 Supertest로 Express API를 테스트하려면 애플리케이션의 서버 리스닝 로직과 Express app 객체를 분리해야 한다. app.listen()이 호출된 파일 자체를 테스트에 불러오면 테스트를 실행할 때마다 포트 충돌(EADDRINUSE) 에러가 발생하기 때문이다.
프로젝트 루트에 테스트 패키지를 설치하고 환경을 설정하는 절차는 다음과 같다.
첫째, 필수 패키지 설치
npm install --save-dev jest supertest
둘째, package.json에 테스트 스크립트 추가
"test": "jest --runInBand --detectOpenHandles"
이제 실수하기 쉬운 코드와 올바른 통합 테스트 코드를 비교해서 살펴보자.
✗ 잘못된 코드: app.js 안에서 app.listen()을 직접 호출하여 exports하는 구조
// app.js - 잘못된 예시
const express = require('express');
const app = express();
app.use(express.json());
app.post('/api/users', (req, res) => {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({ error: '필수 값이 누락되었습니다.' });
}
return res.status(201).json({ id: 1, name, email });
});
// 테스트 실행 시 포트 충돌 및 프로세스 멈춤 원인이 됨
app.listen(3000, () => {
console.log('Server running on port 3000');
});
module.exports = app;왜 안 되는가: 이 구조로 테스트를 돌리면 Jest가 실행될 때마다 3000번 포트가 이미 사용 중이라며 EADDRINUSE 에러를 뱉는다. 테스트 종료 후에도 서버 소켓이 닫히지 않아 Jest 프로세스가 종료되지 않고 멈춰버린다.
✓ 올바른 코드: Express 앱 객체만 내보내는 app.js와 Supertest를 활용한 user.test.js 작성
// app.js (애플리케이션 모듈 분리)
const express = require('express');
const app = express();
app.use(express.json());
app.post('/api/users', (req, res) => {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({ error: '필수 값이 누락되었습니다.' });
}
return res.status(201).json({ id: 1, name, email });
});
module.exports = app;
// server.js (실제 서버 실행 파일)
// const app = require('./app');
// app.listen(3000, () => console.log('Server running on port 3000'));왜 이게 맞는가: app.js에서는 서버를 listen하지 않고 Express 인스턴스만 내보낸다. 이렇게 구성하면 Supertest가 내부적으로 임시 포트를 할당하여 요청을 처리하므로 포트 충돌이 전혀 일어나지 않는다.
이제 이를 검증하는 테스트 파일 작성 예제다.
// user.test.js
const request = require('supertest');
const app = require('./app');
describe('POST /api/users - 사용자 생성 API', () => {
test('올바른 요청 파라미터 전달 시 201 상태코드와 생성된 객체를 반환한다', async () => {
const payload = { name: '홍길동', email: 'hong@example.com' };
const response = await request(app)
.post('/api/users')
.send(payload)
.set('Accept', 'application/json');
expect(response.statusCode).toBe(201);
expect(response.body).toHaveProperty('id');
expect(response.body.name).toBe('홍길동');
expect(response.body.email).toBe('hong@example.com');
});
test('필수 값이 누락되면 400 에러를 반환한다', async () => {
const response = await request(app)
.post('/api/users')
.send({ name: '홍길동' });
expect(response.statusCode).toBe(400);
expect(response.body.error).toBe('필수 값이 누락되었습니다.');
});
});결과/출력값:
PASS ./user.test.js
POST /api/users - 사용자 생성 API
✓ 올바른 요청 파라미터 전달 시 201 상태코드와 생성된 객체를 반환한다 (24 ms)
✓ 필수 값이 누락되면 400 에러를 반환한다 (8 ms)
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 1.12 s
실무에서 API 통합 테스트를 도입할 때 가장 자주 부딪히는 3가지 문제다.
1. DB 트랜잭션 롤백 누락
테스트 실행 시 실제 데이터베이스에 데이터가 계속 누적되면 다음 테스트 실행 때 비동기 순서나 데이터 중복 에러로 실패한다. beforeEach나 afterEach에서 테스트용 데이터베이스를 초기화하거나 트랜잭션을 롤백하는 로직을 반드시 포함해야 한다.
2. 비동기 핸들 닫기 누락 (Jest가 멈추는 현상)
DB 커넥션 풀이나 Redis 연결이 닫히지 않으면 테스트 스위트가 종료된 후에도 Jest 프로세스가 떠 있는 현상이 일어난다. afterAll 블록에서 await db.destroy()처럼 모든 커넥션을 명시적으로 해제해야 한다.
3. 병렬 실행 시 데이터 충돌
Jest는 기본적으로 테스트 파일을 병렬로 실행한다. 동일한 데이터베이스를 공유하는 통합 테스트의 경우 --runInBand 옵션을 붙여 직렬로 실행해야 테스트 간 데이터 오염을 막을 수 있다.
Node.js API 통합 테스트는 서버의 신뢰성을 확보하는 가장 확실한 방어선이다. 코드 변경 시 매번 수동으로 테스트하던 소모적 습관이 모여서 배포 자동화와 안정적인 리팩토링이라는 큰 효과를 만든다는 점을 잊지 말자. 이 글의 3단계 애플리케이션 분리 및 테스트 작성 예제를 참고해 현재 진행 중인 프로젝트에 핵심 엔드포인트 1개부터 적용해 보자. 회귀 버그 걱정 없이 당당하게 배포하는 백엔드 환경을 구축할 수 있을 것이다.