junyeokk
Blog
Testing·2025. 11. 15

supertest

API 서버를 만들고 나면 엔드포인트가 제대로 동작하는지 테스트해야 한다. 단위 테스트로 서비스 로직을 검증할 수는 있지만, 실제 HTTP 요청-응답 흐름 전체를 검증하려면 통합 테스트가 필요하다.

가장 단순한 방법은 서버를 띄우고 curl이나 Postman으로 요청을 보내는 것이다. 하지만 이건 자동화가 안 되고, CI에서 돌릴 수도 없다. 그렇다고 axios나 node-fetch로 직접 HTTP 요청을 보내는 테스트를 짜면 서버를 수동으로 띄우고 포트를 관리해야 하는 번거로움이 생긴다.

javascript
// 이렇게 하면 서버를 직접 띄워야 한다
const res = await fetch('http://localhost:3000/api/users');
expect(res.status).toBe(200);

supertest는 이 문제를 해결한다. Express/NestJS 등의 app 인스턴스를 직접 받아서 내부적으로 임시 서버를 띄우고 요청을 보낸 뒤 자동으로 서버를 닫는다. 포트 충돌 걱정 없이, 서버 프로세스 관리 없이 HTTP 통합 테스트를 작성할 수 있다.


핵심 원리: 서버 없이 서버 테스트

supertest의 핵심은 request() 함수다. Express app 객체(또는 Node.js http.Server)를 인자로 받으면, 내부적으로 app.listen(0)을 호출해서 OS가 빈 포트를 자동 할당하게 한다. 테스트가 끝나면 서버를 닫는다.

javascript
import request from 'supertest';
import app from '../src/app';

// app.listen()을 호출하지 않아도 된다
const response = await request(app).get('/api/users');

포트 0으로 리스닝하는 트릭 덕분에 여러 테스트 파일이 동시에 실행되어도 포트가 충돌하지 않는다. 각 request(app) 호출마다 독립적인 서버 인스턴스가 생긴다.

이 방식이 가능한 이유는 Express app이 결국 http.createServer()에 전달할 수 있는 request handler 함수이기 때문이다. supertest는 이 함수를 받아서 임시 서버를 만들고, 요청을 보내고, 응답을 받은 뒤 정리한다.


기본 사용법

GET 요청

typescript
import request from 'supertest';
import { app } from '../src/app';

describe('GET /api/users', () => {
  it('사용자 목록을 반환한다', async () => {
    const response = await request(app)
      .get('/api/users')
      .expect(200)
      .expect('Content-Type', /json/);

    expect(response.body).toBeInstanceOf(Array);
    expect(response.body.length).toBeGreaterThan(0);
  });
});

.expect()는 assertion이자 체이닝 메서드다. 상태 코드, 헤더, 바디를 한 줄씩 체이닝해서 검증할 수 있다. 실패하면 어떤 기대와 달랐는지 명확한 에러 메시지를 준다.

POST 요청

typescript
it('새 사용자를 생성한다', async () => {
  const newUser = {
    name: '홍길동',
    email: 'hong@example.com',
  };

  const response = await request(app)
    .post('/api/users')
    .send(newUser)
    .expect(201);

  expect(response.body.name).toBe('홍길동');
  expect(response.body.id).toBeDefined();
});

.send()에 객체를 전달하면 자동으로 Content-Type: application/json이 설정되고 JSON으로 직렬화된다. 문자열을 전달하면 그대로 body에 들어간다.

PUT / PATCH / DELETE

typescript
// PUT - 전체 업데이트
await request(app)
  .put('/api/users/1')
  .send({ name: '김철수', email: 'kim@example.com' })
  .expect(200);

// PATCH - 부분 업데이트
await request(app)
  .patch('/api/users/1')
  .send({ name: '김영희' })
  .expect(200);

// DELETE
await request(app)
  .delete('/api/users/1')
  .expect(204);

HTTP 메서드 이름이 곧 함수 이름이라 직관적이다.


Assertion 체이닝

supertest의 .expect()는 여러 형태를 지원한다.

상태 코드

typescript
.expect(200)        // 정확히 200
.expect(201)        // Created
.expect(404)        // Not Found

헤더 검증

typescript
.expect('Content-Type', /json/)           // 정규식 매칭
.expect('Content-Type', 'text/html; charset=utf-8')  // 정확한 값
.expect('X-Custom-Header', 'value')       // 커스텀 헤더

바디 검증

typescript
// 문자열 바디
.expect('OK')

// 객체 바디 (deep equal)
.expect({ status: 'success', data: [] })

// 함수로 커스텀 검증
.expect((res) => {
  if (!res.body.id) throw new Error('id가 없다');
  if (res.body.status !== 'active') throw new Error('status가 active가 아니다');
})

함수 형태의 .expect()는 복잡한 검증 로직이 필요할 때 유용하다. 에러를 throw하면 테스트가 실패한다.

체이닝 조합

typescript
const response = await request(app)
  .get('/api/users/1')
  .expect(200)
  .expect('Content-Type', /json/)
  .expect((res) => {
    if (!res.body.email.includes('@')) {
      throw new Error('유효한 이메일이 아니다');
    }
  });

// response.body로 추가 검증 가능
expect(response.body.name).toBe('홍길동');

.expect() 체이닝과 Jest의 expect()를 혼용할 수 있다. supertest의 .expect()는 HTTP 관련 검증에, Jest의 expect()는 비즈니스 로직 검증에 사용하면 깔끔하다.


헤더와 인증

커스텀 헤더 설정

typescript
await request(app)
  .get('/api/protected')
  .set('Authorization', 'Bearer eyJhbGciOiJIUzI1NiJ9...')
  .set('Accept-Language', 'ko')
  .expect(200);

.set()으로 요청 헤더를 설정한다. 인증이 필요한 엔드포인트를 테스트할 때 필수다.

인증 토큰 재사용 패턴

typescript
describe('인증이 필요한 API', () => {
  let token: string;

  beforeAll(async () => {
    const res = await request(app)
      .post('/api/auth/login')
      .send({ email: 'test@example.com', password: 'password' });

    token = res.body.accessToken;
  });

  it('프로필을 조회한다', async () => {
    await request(app)
      .get('/api/users/me')
      .set('Authorization', `Bearer ${token}`)
      .expect(200);
  });

  it('인증 없이 접근하면 401', async () => {
    await request(app)
      .get('/api/users/me')
      .expect(401);
  });
});

beforeAll에서 로그인해서 토큰을 받아두고, 각 테스트에서 재사용하는 패턴이다. 실제 인증 흐름 전체를 테스트할 수 있다.


파일 업로드

typescript
await request(app)
  .post('/api/upload')
  .attach('file', Buffer.from('test content'), 'test.txt')
  .field('description', '테스트 파일')
  .expect(201);

.attach()로 파일을 첨부하고 .field()로 추가 필드를 보낸다. 내부적으로 multipart/form-data 형식으로 인코딩된다. 첫 번째 인자는 필드명, 두 번째는 파일(Buffer, 스트림, 또는 파일 경로), 세 번째는 파일명이다.

typescript
// 실제 파일 경로로도 가능
.attach('photo', path.join(__dirname, 'fixtures/sample.jpg'))

// 여러 파일
.attach('photos', 'file1.jpg')
.attach('photos', 'file2.jpg')

쿼리 파라미터

typescript
// 방법 1: URL에 직접
await request(app)
  .get('/api/users?page=1&limit=10')
  .expect(200);

// 방법 2: .query()
await request(app)
  .get('/api/users')
  .query({ page: 1, limit: 10, sort: 'name' })
  .expect(200);

.query()를 사용하면 쿼리 파라미터를 객체로 관리할 수 있어서 가독성이 좋다. 특수 문자 인코딩도 자동으로 처리된다.


NestJS에서 사용

NestJS는 Express 위에 구축되어 있지만, 모듈 시스템과 DI 컨테이너가 있기 때문에 app 인스턴스를 얻는 방식이 약간 다르다.

typescript
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { AppModule } from '../src/app.module';

describe('AppController (e2e)', () => {
  let app: INestApplication;

  beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    await app.init();
  });

  afterAll(async () => {
    await app.close();
  });

  it('/ (GET)', () => {
    return request(app.getHttpServer())
      .get('/')
      .expect(200)
      .expect('Hello World!');
  });
});

핵심은 app.getHttpServer()다. NestJS의 INestApplication은 직접 supertest에 전달할 수 없고, 내부의 HTTP 서버 인스턴스를 꺼내서 전달해야 한다.

모듈 오버라이드

테스트에서 특정 서비스를 모킹하고 싶을 때:

typescript
const moduleFixture = await Test.createTestingModule({
  imports: [AppModule],
})
  .overrideProvider(DatabaseService)
  .useValue({
    query: jest.fn().mockResolvedValue([]),
  })
  .compile();

.overrideProvider()로 실제 서비스 대신 mock을 주입할 수 있다. 외부 DB나 API에 의존하지 않는 격리된 테스트를 만들 때 유용하다.


supertest의 내부 구조

supertest는 superagent를 확장한 라이브러리다. superagent는 Node.js용 HTTP 클라이언트로, 체이닝 API를 제공한다. supertest는 여기에 다음을 추가한다:

  1. 서버 바인딩: app 인스턴스를 받아서 임시 서버를 생성
  2. assertion 메서드: .expect()로 응답을 검증
  3. 자동 정리: 요청 완료 후 서버 종료
text
supertest
  └── superagent (HTTP 클라이언트)
       └── Node.js http 모듈

이 계층 구조 덕분에 superagent의 모든 기능(쿠키, 리다이렉트 추적, 타임아웃 등)을 supertest에서도 사용할 수 있다.


지속적인 세션 (Agent)

기본적으로 request(app)은 매 호출마다 새로운 연결을 만든다. 쿠키 기반 세션을 테스트하려면 agent를 사용해야 한다.

typescript
const agent = request.agent(app);

// 로그인 - 세션 쿠키가 agent에 저장됨
await agent
  .post('/api/auth/login')
  .send({ email: 'test@example.com', password: 'password' })
  .expect(200);

// 이후 요청에서 쿠키가 자동으로 전송됨
await agent
  .get('/api/users/me')
  .expect(200);

request.agent(app)으로 생성한 agent는 쿠키 저장소를 유지한다. 세션 기반 인증, CSRF 토큰 등을 테스트할 때 필수적이다.


에러 응답 테스트

성공 케이스만 테스트하면 안 된다. 예외 상황도 의도한 대로 처리되는지 확인해야 한다.

typescript
describe('에러 핸들링', () => {
  it('존재하지 않는 리소스 → 404', async () => {
    const res = await request(app)
      .get('/api/users/99999')
      .expect(404);

    expect(res.body.message).toBe('사용자를 찾을 수 없습니다');
  });

  it('잘못된 입력 → 400', async () => {
    const res = await request(app)
      .post('/api/users')
      .send({ name: '' })  // 빈 이름
      .expect(400);

    expect(res.body.errors).toBeDefined();
  });

  it('중복 이메일 → 409', async () => {
    await request(app)
      .post('/api/users')
      .send({ name: '테스트', email: 'existing@example.com' })
      .expect(409);
  });
});

테스트 구조 모범 사례

테스트 파일 위치

text
project/
├── src/
│   ├── users/
│   │   ├── users.controller.ts
│   │   └── users.service.ts
│   └── app.module.ts
└── test/
    ├── users.e2e-spec.ts      # e2e 테스트
    └── jest-e2e.json           # e2e 전용 Jest 설정

NestJS 컨벤션에서는 test/ 폴더에 e2e 테스트를 두고, *.e2e-spec.ts 네이밍을 사용한다. jest-e2e.json으로 단위 테스트와 설정을 분리한다.

jest-e2e.json 설정

json
{
  "moduleFileExtensions": ["js", "json", "ts"],
  "rootDir": ".",
  "testEnvironment": "node",
  "testRegex": ".e2e-spec.ts$",
  "transform": {
    "^.+\\.(t|j)s$": "ts-jest"
  }
}

단위 테스트와 e2e 테스트를 분리해서 실행할 수 있다:

bash
# 단위 테스트
npx jest

# e2e 테스트
npx jest --config test/jest-e2e.json

supertest vs 대안

항목supertestaxios/fetch 직접 사용Pactum
서버 자동 관리✅ 포트 자동 할당❌ 수동 관리
assertion 내장✅ 체이닝❌ 별도 라이브러리✅ 더 풍부
Express 통합✅ app 직접 전달❌ URL 필요
러닝 커브낮음낮음중간
생태계/커뮤니티매우 넓음-작음

supertest의 가장 큰 강점은 Express/NestJS 앱을 서버로 띄우지 않고 직접 테스트할 수 있다는 점이다. 이게 CI 환경에서 특히 중요하다. 포트 충돌, 서버 프로세스 관리, 타이밍 이슈를 전부 supertest가 처리해준다.

Pactum은 더 많은 기능(스키마 검증, API 테스트 시나리오 등)을 제공하지만, supertest만큼 널리 쓰이지는 않는다. 대부분의 프로젝트에서 supertest + Jest 조합이면 충분하다.


자주 만나는 문제

EADDRINUSE (포트 충돌)

text
Error: listen EADDRINUSE: address already in use :::3000

app.listen()을 테스트 파일에서 직접 호출하면 발생한다. supertest에 app을 전달하면 내부적으로 포트 0으로 리스닝하므로, 테스트용 app에서는 listen()을 호출하지 않아야 한다. app 생성과 서버 시작을 분리하는 것이 핵심이다.

typescript
// app.ts - app만 export
const app = express();
app.use('/api/users', usersRouter);
export default app;

// server.ts - 서버 시작은 여기서
import app from './app';
app.listen(3000);

비동기 테스트 타임아웃

text
Timeout - Async callback was not invoked within the 5000ms timeout

DB 연결이나 외부 API 호출이 있는 테스트에서 발생할 수 있다. Jest의 타임아웃을 늘리거나, 느린 의존성을 모킹한다.

typescript
// 특정 테스트만 타임아웃 늘리기
it('느린 API 테스트', async () => {
  // ...
}, 15000);  // 15초

// 전체 describe 블록
beforeAll(async () => {
  // DB 연결 등 초기화
}, 30000);

afterAll에서 서버 정리

NestJS에서는 afterAll에서 반드시 app.close()를 호출해야 한다. DB 커넥션 풀이나 다른 리소스가 정리되지 않으면 Jest가 종료되지 않고 행(hang)에 걸린다.

typescript
afterAll(async () => {
  await app.close();  // DB 커넥션, 이벤트 리스너 등 정리
});

--forceExit 플래그로 강제 종료할 수도 있지만, 근본 원인을 해결하는 것이 맞다. --detectOpenHandles로 정리되지 않은 리소스를 찾을 수 있다.

정리

  • request(app)이 포트 0으로 임시 서버를 띄우고 자동 정리하므로, 포트 충돌이나 서버 프로세스 관리 없이 HTTP 통합 테스트를 작성할 수 있다.
  • .expect() 체이닝으로 상태 코드, 헤더, 바디를 한 줄씩 검증하고, Jest expect()와 혼용하면 HTTP 검증과 비즈니스 로직 검증을 깔끔하게 분리할 수 있다.
  • app 생성과 서버 시작을 분리(app.ts vs server.ts)하는 것이 핵심이고, NestJS에서는 app.getHttpServer()로 내부 서버를 꺼내 전달해야 한다.

관련 문서