Node.js 테스트: Jest 기본과 Mock, Supertest API 테스트, DB 테스트, Mocha+Chai
이 글의 핵심
Jest로 단위 테스트와 비동기 테스트 작성하기, Mock·Spy로 의존성 격리하기, Supertest로 Express API 검증하기, 데이터베이스가 걸린 테스트를 다루는 방법과 커버리지 측정을 정리합니다.
들어가며
테스트의 중요성
테스트는 코드가 의도대로 동작하는지 확인하는 과정입니다. 자동 테스트는 리팩터링할 때 안전벨트입니다. 같은 입력에 같은 출력이 나오는지 기계가 확인해 주면, 비동기·Express 라우트처럼 손으로 curl만 반복하기 어려운 경로도 회귀를 막을 수 있습니다. 단위 테스트는 모든 언어에서 중요합니다. Python에서 pytest·CI·테스트 디렉터리, C++의 Google Test, Go의 go test, Rust의 cargo test는 각각의 생태계에서 표준에 가깝습니다. CI/CD·배포와 연결하려면 GitHub Actions로 Node.js CI/CD, Docker Compose 프로덕션, C++ Docker·배포 이미지를 함께 보세요.
장점:
-
버그 조기 발견: 배포 전에 문제를 잡음
-
리팩토링 안전성: 변경 후에도 기존 동작이 유지되는지 확인
-
문서화: 테스트 코드가 함수의 사용법과 기대 동작을 보여줌 테스트 종류:
-
단위 테스트 (Unit Test): 개별 함수/메서드
-
통합 테스트 (Integration Test): 여러 모듈 조합
-
E2E 테스트 (End-to-End): 전체 시스템
Jest 설치와 Matcher
설치
npm install --save-dev jest
package.json 설정:
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}
첫 테스트
// math.js
function add(a, b) {
return a + b;
}
function subtract(a, b) {
return a - b;
}
module.exports = { add, subtract };
// math.test.js
const { add, subtract } = require('./math');
describe('Math 함수', () => {
test('add: 두 숫자를 더한다', () => {
expect(add(2, 3)).toBe(5);
expect(add(-1, 1)).toBe(0);
expect(add(0, 0)).toBe(0);
});
test('subtract: 두 숫자를 뺀다', () => {
expect(subtract(5, 3)).toBe(2);
expect(subtract(1, 1)).toBe(0);
expect(subtract(0, 5)).toBe(-5);
});
});
실행:
npm test
Matchers (검증)
describe('Matchers 예제', () => {
test('동등성', () => {
expect(2 + 2).toBe(4); // ===
expect({ name: '홍길동' }).toEqual({ name: '홍길동' }); // 깊은 비교
});
test('참/거짓', () => {
expect(true).toBeTruthy();
expect(false).toBeFalsy();
expect(null).toBeNull();
expect(undefined).toBeUndefined();
expect('hello').toBeDefined();
});
test('숫자', () => {
expect(10).toBeGreaterThan(5);
expect(10).toBeGreaterThanOrEqual(10);
expect(5).toBeLessThan(10);
expect(0.1 + 0.2).toBeCloseTo(0.3); // 부동소수점
});
test('문자열', () => {
expect('hello world').toMatch(/world/);
expect('hello').toContain('ell');
});
test('배열/객체', () => {
const arr = ['apple', 'banana', 'cherry'];
expect(arr).toContain('banana');
expect(arr).toHaveLength(3);
const obj = { name: '홍길동', age: 25 };
expect(obj).toHaveProperty('name');
expect(obj).toHaveProperty('age', 25);
});
test('예외', () => {
expect(() => {
throw new Error('에러!');
}).toThrow('에러!');
});
});
Promise 테스트와 Setup/Teardown
Promise 테스트
// api.js
async function fetchUser(id) {
const response = await fetch(`https://api.example.com/users/${id}`);
return response.json();
}
module.exports = { fetchUser };
// api.test.js
const { fetchUser } = require('./api');
describe('API 테스트', () => {
test('사용자를 가져온다', async () => {
const user = await fetchUser(1);
expect(user).toHaveProperty('id', 1);
expect(user).toHaveProperty('name');
});
test('존재하지 않는 사용자', async () => {
await expect(fetchUser(999)).rejects.toThrow();
});
});
이 예제는 비동기 테스트 문법을 보여 주기 위한 것이고, 그대로 쓰면 두 가지 문제가 있습니다. 첫째, fetch는 서버가 404를 돌려줘도 reject하지 않습니다. 네트워크 자체가 실패할 때만 reject하고, HTTP 에러 상태는 response.ok가 false인 정상 응답일 뿐입니다. 따라서 fetchUser가 if (!response.ok) throw new Error(...)로 직접 에러를 던지지 않는 한 두 번째 테스트는 실패합니다. 둘째, 실제 외부 API를 호출하는 테스트는 네트워크 상태, API 서버 장애, 요청 제한에 따라 결과가 바뀌는 불안정한(flaky) 테스트가 됩니다. 단위 테스트에서는 다음 절의 Mock으로 네트워크를 끊고, 실제 API와의 연동은 별도의 통합 테스트로 분리하는 것이 일반적입니다.
비동기 테스트에서 가장 흔한 실수는 await나 return을 빠뜨리는 것입니다. expect(fetchUser(999)).rejects.toThrow() 앞에 await이 없으면 Jest는 Promise가 끝나기 전에 테스트를 통과 처리하고, 실제 실패는 다른 테스트가 도는 중에 “Cannot log after tests are done” 같은 경고로만 드러납니다. 테스트가 이상하게 항상 통과한다면 expect.assertions(1)을 넣어 검증이 실제로 실행됐는지 확인해 보는 것도 방법입니다.
Setup/Teardown
describe('데이터베이스 테스트', () => {
// 모든 테스트 전에 한 번 실행
beforeAll(async () => {
await connectDatabase();
});
// 각 테스트 전에 실행
beforeEach(async () => {
await clearDatabase();
});
// 각 테스트 후에 실행
afterEach(async () => {
// 정리 작업
});
// 모든 테스트 후에 한 번 실행
afterAll(async () => {
await closeDatabase();
});
test('사용자 생성', async () => {
const user = await User.create({ name: '홍길동' });
expect(user).toHaveProperty('_id');
});
});
Mock 함수, 모듈 Mock, Spy
Mock 함수
describe('Mock 함수', () => {
test('함수 호출 확인', () => {
const mockFn = jest.fn();
mockFn('hello');
mockFn('world');
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenCalledWith('hello');
expect(mockFn).toHaveBeenLastCalledWith('world');
});
test('반환값 설정', () => {
const mockFn = jest.fn();
mockFn.mockReturnValue(42);
expect(mockFn()).toBe(42);
mockFn.mockReturnValueOnce(1)
.mockReturnValueOnce(2)
.mockReturnValue(3);
expect(mockFn()).toBe(1);
expect(mockFn()).toBe(2);
expect(mockFn()).toBe(3);
expect(mockFn()).toBe(3);
});
});
모듈 Mock
// user.js
const axios = require('axios');
async function getUser(id) {
const response = await axios.get(`https://api.example.com/users/${id}`);
return response.data;
}
module.exports = { getUser };
// user.test.js
const axios = require('axios');
const { getUser } = require('./user');
jest.mock('axios');
describe('getUser', () => {
test('사용자를 가져온다', async () => {
const mockUser = { id: 1, name: '홍길동' };
axios.get.mockResolvedValue({ data: mockUser });
const user = await getUser(1);
expect(axios.get).toHaveBeenCalledWith('https://api.example.com/users/1');
expect(user).toEqual(mockUser);
});
test('에러 처리', async () => {
axios.get.mockRejectedValue(new Error('Network Error'));
await expect(getUser(1)).rejects.toThrow('Network Error');
});
});
jest.mock('axios')는 코드상 require 아래에 있지만, Jest가 babel-jest 변환 과정에서 jest.mock 호출을 파일 맨 위로 끌어올리기(hoisting) 때문에 user.js가 불러오는 axios도 이미 가짜로 바뀐 상태가 됩니다. 이 호이스팅 때문에 jest.mock의 팩토리 함수 안에서는 바깥 변수를 참조할 수 없고, 참조하면 “The module factory of jest.mock() is not allowed to reference any out-of-scope variables” 에러가 납니다(이름이 mock으로 시작하는 변수만 예외). 또 모듈 Mock은 테스트 파일 전체에 적용되므로, 앞 테스트에서 설정한 mockResolvedValue가 뒤 테스트로 새어 나가지 않게 jest.config.js에 clearMocks: true나 restoreMocks: true를 켜 두는 편이 안전합니다.
프로젝트가 "type": "module"인 ES 모듈이라면 이 예제의 jest.mock이 기대대로 동작하지 않을 수 있습니다. Jest의 ESM 지원은 아직 실험적이라 import 문에는 호이스팅이 적용되지 않고, jest.unstable_mockModule과 동적 import()를 조합해야 합니다. ESM 프로젝트에서 Mock 설정이 계속 꼬인다면 ESM을 기본으로 지원하는 Vitest를 검토하는 것이 현실적인 선택입니다.
Spy
describe('Spy 예제', () => {
test('메서드 호출 감시', () => {
const obj = {
method: () => 'original'
};
const spy = jest.spyOn(obj, 'method');
obj.method();
expect(spy).toHaveBeenCalled();
spy.mockRestore(); // 원래대로 복원
});
});
Supertest로 Express API 테스트
설치
npm install --save-dev supertest
Express 앱 테스트
// app.js
const express = require('express');
const app = express();
app.use(express.json());
app.get('/api/users', (req, res) => {
res.json({ users: [] });
});
app.post('/api/users', (req, res) => {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({ error: '이름과 이메일이 필요합니다' });
}
res.status(201).json({ id: 1, name, email });
});
module.exports = app; // 서버 시작하지 않고 export
// app.test.js
const request = require('supertest');
const app = require('./app');
describe('API 테스트', () => {
describe('GET /api/users', () => {
test('모든 사용자를 반환한다', async () => {
const response = await request(app)
.get('/api/users')
.expect(200)
.expect('Content-Type', /json/);
expect(response.body).toHaveProperty('users');
expect(Array.isArray(response.body.users)).toBe(true);
});
});
describe('POST /api/users', () => {
test('사용자를 생성한다', async () => {
const newUser = {
name: '홍길동',
email: '[email protected]'
};
const response = await request(app)
.post('/api/users')
.send(newUser)
.expect(201)
.expect('Content-Type', /json/);
expect(response.body).toMatchObject(newUser);
expect(response.body).toHaveProperty('id');
});
test('유효하지 않은 입력은 400을 반환한다', async () => {
const response = await request(app)
.post('/api/users')
.send({ name: '홍길동' }) // email 누락
.expect(400);
expect(response.body).toHaveProperty('error');
});
});
});
Supertest가 편리한 이유는 서버를 실제 포트에 띄우지 않고 Express 앱 객체를 직접 받아 요청을 보낸다는 점입니다. 그래서 app.js는 app.listen()을 호출하지 않고 module.exports = app만 해야 하고, 서버 시작은 server.js 같은 별도 파일로 분리합니다. app.js 안에서 listen까지 호출하면 테스트 파일마다 같은 포트를 열려고 해 “EADDRINUSE: address already in use” 에러가 나거나, 테스트가 끝난 뒤에도 서버가 열려 있어 “Jest did not exit one second after the test run has completed” 경고와 함께 프로세스가 종료되지 않습니다. DB 연결이나 타이머를 afterAll에서 닫지 않았을 때도 같은 경고가 나며, jest --detectOpenHandles로 실행하면 무엇이 열려 있는지 알려 줍니다.
MongoDB 모델 테스트
MongoDB 테스트 설정
// test/setup.js
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
let mongoServer;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
afterEach(async () => {
const collections = mongoose.connection.collections;
for (const key in collections) {
await collections[key].deleteMany();
}
});
mongodb-memory-server는 테스트 시작 시 실제 MongoDB 바이너리를 임시로 띄우는 방식이라, 개발 DB를 건드리지 않고도 진짜 쿼리 동작을 검증할 수 있습니다. 대신 처음 실행할 때 MongoDB 바이너리를 내려받느라 수십 초가 걸려 기본 5초 타임아웃에 걸리기 쉽고, CI에서는 이 바이너리를 캐시해 두지 않으면 매번 다운로드합니다. Alpine 같은 일부 Linux 이미지에서는 해당 플랫폼용 바이너리가 없어 시작 자체가 실패하기도 합니다. 또 setupFilesAfterEnv에 둔 beforeAll은 테스트 파일마다 실행되므로, Jest가 파일을 병렬로 돌리면 파일 수만큼 MongoDB 인스턴스가 뜹니다. 메모리가 부족한 CI 러너에서 테스트가 무작위로 실패한다면 --runInBand로 순차 실행하거나 globalSetup에서 서버를 한 번만 띄우는 구조로 바꿔 볼 만합니다. 반대로 인메모리 서버는 운영 DB와 버전·인덱스 설정이 다를 수 있으므로, 트랜잭션이나 특정 버전 기능에 의존하는 로직은 Docker로 운영과 같은 버전의 DB를 띄워 한 번 더 검증하는 편이 안전합니다.
jest.config.js:
module.exports = {
testEnvironment: 'node',
setupFilesAfterEnv: ['<rootDir>/test/setup.js'],
coveragePathIgnorePatterns: ['/node_modules/']
};
모델 테스트
// models/User.test.js
const User = require('../models/User');
describe('User 모델', () => {
test('사용자를 생성한다', async () => {
const userData = {
name: '홍길동',
email: '[email protected]',
password: 'password123'
};
const user = await User.create(userData);
expect(user).toHaveProperty('_id');
expect(user.name).toBe(userData.name);
expect(user.email).toBe(userData.email);
});
test('이메일 중복은 에러를 발생시킨다', async () => {
await User.create({
name: '홍길동',
email: '[email protected]',
password: 'password123'
});
await expect(User.create({
name: '김철수',
email: '[email protected]', // 중복
password: 'password456'
})).rejects.toThrow();
});
test('필수 필드가 없으면 에러', async () => {
await expect(User.create({
name: '홍길동'
// email, password 누락
})).rejects.toThrow();
});
});
REST API와 인증 통합 테스트
REST API 통합 테스트
// test/integration/users.test.js
const request = require('supertest');
const app = require('../../app');
const User = require('../../models/User');
describe('Users API 통합 테스트', () => {
describe('POST /api/users', () => {
test('사용자를 생성한다', async () => {
const userData = {
name: '홍길동',
email: '[email protected]',
password: 'password123'
};
const response = await request(app)
.post('/api/users')
.send(userData)
.expect(201);
expect(response.body).toHaveProperty('id');
expect(response.body.name).toBe(userData.name);
// 데이터베이스 확인
const user = await User.findById(response.body.id);
expect(user).toBeTruthy();
expect(user.email).toBe(userData.email);
});
});
describe('GET /api/users/:id', () => {
test('사용자를 조회한다', async () => {
// 테스트 데이터 생성
const user = await User.create({
name: '홍길동',
email: '[email protected]',
password: 'password123'
});
const response = await request(app)
.get(`/api/users/${user._id}`)
.expect(200);
expect(response.body.name).toBe(user.name);
expect(response.body.email).toBe(user.email);
});
test('존재하지 않는 사용자는 404를 반환한다', async () => {
const response = await request(app)
.get('/api/users/507f1f77bcf86cd799439011')
.expect(404);
expect(response.body).toHaveProperty('error');
});
});
});
인증 테스트
// test/integration/auth.test.js
const request = require('supertest');
const app = require('../../app');
const User = require('../../models/User');
const bcrypt = require('bcrypt');
describe('Auth API', () => {
describe('POST /auth/login', () => {
test('올바른 자격증명으로 로그인', async () => {
// 테스트 사용자 생성
const password = 'password123';
const hashedPassword = await bcrypt.hash(password, 10);
await User.create({
name: '홍길동',
email: '[email protected]',
password: hashedPassword
});
const response = await request(app)
.post('/auth/login')
.send({
email: '[email protected]',
password: password
})
.expect(200);
expect(response.body).toHaveProperty('token');
expect(response.body.user.email).toBe('[email protected]');
});
test('잘못된 비밀번호는 401을 반환한다', async () => {
const hashedPassword = await bcrypt.hash('password123', 10);
await User.create({
name: '홍길동',
email: '[email protected]',
password: hashedPassword
});
await request(app)
.post('/auth/login')
.send({
email: '[email protected]',
password: 'wrongpassword'
})
.expect(401);
});
});
describe('GET /api/profile', () => {
test('인증된 사용자는 프로필을 조회할 수 있다', async () => {
// 사용자 생성 및 로그인
const user = await User.create({
name: '홍길동',
email: '[email protected]',
password: await bcrypt.hash('password123', 10)
});
const loginResponse = await request(app)
.post('/auth/login')
.send({
email: '[email protected]',
password: 'password123'
});
const token = loginResponse.body.token;
// 프로필 조회
const response = await request(app)
.get('/api/profile')
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(response.body.user.email).toBe('[email protected]');
});
test('토큰 없이는 401을 반환한다', async () => {
await request(app)
.get('/api/profile')
.expect(401);
});
});
});
Mocha + Chai
설치
npm install --save-dev mocha chai
package.json:
{
"scripts": {
"test": "mocha"
}
}
기본 테스트
// test/math.test.js
const { expect } = require('chai');
const { add, subtract } = require('../math');
describe('Math 함수', () => {
describe('add()', () => {
it('두 숫자를 더한다', () => {
expect(add(2, 3)).to.equal(5);
expect(add(-1, 1)).to.equal(0);
});
});
describe('subtract()', () => {
it('두 숫자를 뺀다', () => {
expect(subtract(5, 3)).to.equal(2);
});
});
});
Chai Assertions
const { expect } = require('chai');
describe('Chai Assertions', () => {
it('동등성', () => {
expect(2 + 2).to.equal(4);
expect({ name: '홍길동' }).to.deep.equal({ name: '홍길동' });
});
it('타입', () => {
expect('hello').to.be.a('string');
expect(123).to.be.a('number');
expect([]).to.be.an('array');
});
it('포함', () => {
expect([1, 2, 3]).to.include(2);
expect('hello world').to.include('world');
expect({ name: '홍길동', age: 25 }).to.include({ name: '홍길동' });
});
it('길이', () => {
expect([1, 2, 3]).to.have.lengthOf(3);
expect('hello').to.have.lengthOf(5);
});
it('속성', () => {
const obj = { name: '홍길동', age: 25 };
expect(obj).to.have.property('name');
expect(obj).to.have.property('age', 25);
});
});
Jest 커버리지 측정
Jest 커버리지
npm test -- --coverage
출력:
----------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files | 85.71 | 75 | 100 | 85.71 |
math.js | 85.71 | 75 | 100 | 85.71 | 12-15
----------|---------|----------|---------|---------|-------------------
jest.config.js:
module.exports = {
collectCoverageFrom: [
'src/**/*.js',
'!src/**/*.test.js',
'!src/index.js'
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
}
};
사용자 서비스와 인증 로직 테스트 예제
사용자 서비스 테스트
// services/userService.js
const User = require('../models/User');
const bcrypt = require('bcrypt');
class UserService {
async createUser(userData) {
const { email, password, name } = userData;
// 중복 확인
const existing = await User.findOne({ email });
if (existing) {
throw new Error('이미 존재하는 이메일입니다');
}
// 비밀번호 해싱
const hashedPassword = await bcrypt.hash(password, 10);
// 사용자 생성
const user = await User.create({
email,
password: hashedPassword,
name
});
return user;
}
async getUserById(id) {
const user = await User.findById(id).select('-password');
if (!user) {
throw new Error('사용자를 찾을 수 없습니다');
}
return user;
}
async updateUser(id, updates) {
const user = await User.findByIdAndUpdate(
id,
updates,
{ new: true, runValidators: true }
).select('-password');
if (!user) {
throw new Error('사용자를 찾을 수 없습니다');
}
return user;
}
async deleteUser(id) {
const user = await User.findByIdAndDelete(id);
if (!user) {
throw new Error('사용자를 찾을 수 없습니다');
}
return user;
}
}
module.exports = new UserService();
// services/userService.test.js
const userService = require('./userService');
const User = require('../models/User');
describe('UserService', () => {
describe('createUser', () => {
test('사용자를 생성한다', async () => {
const userData = {
name: '홍길동',
email: '[email protected]',
password: 'password123'
};
const user = await userService.createUser(userData);
expect(user).toHaveProperty('_id');
expect(user.name).toBe(userData.name);
expect(user.email).toBe(userData.email);
expect(user.password).not.toBe(userData.password); // 해싱됨
});
test('중복 이메일은 에러를 발생시킨다', async () => {
await userService.createUser({
name: '홍길동',
email: '[email protected]',
password: 'password123'
});
await expect(userService.createUser({
name: '김철수',
email: '[email protected]',
password: 'password456'
})).rejects.toThrow('이미 존재하는 이메일입니다');
});
});
describe('getUserById', () => {
test('사용자를 조회한다', async () => {
const created = await User.create({
name: '홍길동',
email: '[email protected]',
password: 'hashed'
});
const user = await userService.getUserById(created._id);
expect(user._id.toString()).toBe(created._id.toString());
expect(user).not.toHaveProperty('password'); // 비밀번호 제외
});
test('존재하지 않는 사용자는 에러', async () => {
await expect(
userService.getUserById('507f1f77bcf86cd799439011')
).rejects.toThrow('사용자를 찾을 수 없습니다');
});
});
});
인증 테스트
// test/integration/auth.test.js
const request = require('supertest');
const app = require('../../app');
const User = require('../../models/User');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
describe('인증 API', () => {
let testUser;
let authToken;
beforeEach(async () => {
// 테스트 사용자 생성
testUser = await User.create({
name: '홍길동',
email: '[email protected]',
password: await bcrypt.hash('password123', 10),
role: 'user'
});
});
describe('POST /auth/register', () => {
test('회원가입 성공', async () => {
const response = await request(app)
.post('/auth/register')
.send({
name: '김철수',
email: '[email protected]',
password: 'password123'
})
.expect(201);
expect(response.body).toHaveProperty('user');
expect(response.body.user.email).toBe('[email protected]');
// 비밀번호가 해싱되었는지 확인
const user = await User.findOne({ email: '[email protected]' });
expect(user.password).not.toBe('password123');
});
});
describe('POST /auth/login', () => {
test('로그인 성공', async () => {
const response = await request(app)
.post('/auth/login')
.send({
email: '[email protected]',
password: 'password123'
})
.expect(200);
expect(response.body).toHaveProperty('token');
expect(response.body.user.email).toBe('[email protected]');
// 토큰 검증
const decoded = jwt.verify(response.body.token, process.env.JWT_SECRET);
expect(decoded.email).toBe('[email protected]');
authToken = response.body.token;
});
test('잘못된 비밀번호', async () => {
await request(app)
.post('/auth/login')
.send({
email: '[email protected]',
password: 'wrongpassword'
})
.expect(401);
});
});
describe('GET /api/profile', () => {
beforeEach(async () => {
// 로그인하여 토큰 획득
const response = await request(app)
.post('/auth/login')
.send({
email: '[email protected]',
password: 'password123'
});
authToken = response.body.token;
});
test('인증된 사용자는 프로필 조회 가능', async () => {
const response = await request(app)
.get('/api/profile')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.user.email).toBe('[email protected]');
});
test('토큰 없이는 401', async () => {
await request(app)
.get('/api/profile')
.expect(401);
});
test('유효하지 않은 토큰은 401', async () => {
await request(app)
.get('/api/profile')
.set('Authorization', 'Bearer invalid-token')
.expect(401);
});
});
});
Mock과 Stub의 차이
Mock
// emailService.js
const nodemailer = require('nodemailer');
async function sendEmail(to, subject, text) {
const transporter = nodemailer.createTransport({
host: 'smtp.example.com',
port: 587,
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS
}
});
await transporter.sendMail({ to, subject, text });
}
module.exports = { sendEmail };
// emailService.test.js
const { sendEmail } = require('./emailService');
const nodemailer = require('nodemailer');
jest.mock('nodemailer');
describe('sendEmail', () => {
test('이메일을 발송한다', async () => {
const mockSendMail = jest.fn().mockResolvedValue({ messageId: '123' });
const mockTransporter = {
sendMail: mockSendMail
};
nodemailer.createTransport.mockReturnValue(mockTransporter);
await sendEmail('[email protected]', '제목', '내용');
expect(mockSendMail).toHaveBeenCalledWith({
to: '[email protected]',
subject: '제목',
text: '내용'
});
});
});
Stub
// paymentService.js
const axios = require('axios');
async function processPayment(amount) {
const response = await axios.post('https://payment-api.com/charge', {
amount
});
return response.data;
}
module.exports = { processPayment };
// paymentService.test.js
const { processPayment } = require('./paymentService');
const axios = require('axios');
jest.mock('axios');
describe('processPayment', () => {
test('결제를 처리한다', async () => {
const mockResponse = {
data: {
success: true,
transactionId: 'txn_123'
}
};
axios.post.mockResolvedValue(mockResponse);
const result = await processPayment(10000);
expect(result.success).toBe(true);
expect(result.transactionId).toBe('txn_123');
});
});
타임아웃, 테스트 간 간섭, DB 정리 누락
비동기 테스트 타임아웃
에러:
Timeout - Async callback was not invoked within the 5000 ms timeout
해결:
// 타임아웃 증가
test('느린 작업', async () => {
await slowOperation();
}, 10000); // 10초
// jest.config.js
module.exports = {
testTimeout: 10000
};
테스트 간 간섭
// ❌ 전역 상태 공유
let users = [];
test('사용자 추가', () => {
users.push({ name: '홍길동' });
expect(users).toHaveLength(1);
});
test('사용자 목록', () => {
expect(users).toHaveLength(0); // 실패! (이전 테스트 영향)
});
// ✅ beforeEach로 초기화
let users;
beforeEach(() => {
users = [];
});
test('사용자 추가', () => {
users.push({ name: '홍길동' });
expect(users).toHaveLength(1);
});
test('사용자 목록', () => {
expect(users).toHaveLength(0); // 성공!
});
데이터베이스 정리 누락
// ✅ afterEach로 정리
afterEach(async () => {
await User.deleteMany({});
await Post.deleteMany({});
});
// ✅ 또는 트랜잭션 롤백 (PostgreSQL)
let connection;
beforeEach(async () => {
connection = await pool.getConnection();
await connection.beginTransaction();
});
afterEach(async () => {
await connection.rollback();
connection.release();
});
테스트 구조화, 헬퍼, TDD
테스트 구조화
test/
├── unit/
│ ├── models/
│ │ └── User.test.js
│ ├── services/
│ │ └── userService.test.js
│ └── utils/
│ └── validator.test.js
├── integration/
│ ├── auth.test.js
│ └── users.test.js
├── e2e/
│ └── userFlow.test.js
└── setup.js
테스트 헬퍼
// test/helpers.js
// 변수 선언 및 초기화
const User = require('../models/User');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
async function createTestUser(overrides = {}) {
const defaultUser = {
name: '테스트 사용자',
email: '[email protected]',
password: await bcrypt.hash('password123', 10),
role: 'user'
};
return await User.create({ ...defaultUser, ...overrides });
}
function generateTestToken(user) {
return jwt.sign(
{ id: user._id, email: user.email, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
}
module.exports = {
createTestUser,
generateTestToken
};
// 사용
const { createTestUser, generateTestToken } = require('./helpers');
test('사용자 조회', async () => {
const user = await createTestUser({ name: '홍길동' });
const token = generateTestToken(user);
const response = await request(app)
.get('/api/profile')
.set('Authorization', `Bearer ${token}`)
.expect(200);
});
TDD (Test-Driven Development)
// 1. 실패하는 테스트 작성
describe('calculateDiscount', () => {
test('10% 할인을 계산한다', () => {
expect(calculateDiscount(10000, 10)).toBe(9000);
});
});
// 2. 최소한의 코드로 테스트 통과
function calculateDiscount(price, discountPercent) {
return price - (price * discountPercent / 100);
}
// 3. 리팩토링
function calculateDiscount(price, discountPercent) {
if (price < 0 || discountPercent < 0 || discountPercent > 100) {
throw new Error('유효하지 않은 입력');
}
return price * (1 - discountPercent / 100);
}
// 4. 추가 테스트
test('유효하지 않은 입력은 에러', () => {
expect(() => calculateDiscount(-100, 10)).toThrow();
expect(() => calculateDiscount(100, -10)).toThrow();
expect(() => calculateDiscount(100, 150)).toThrow();
});
Node.js 테스트 요약
- Jest: 올인원 테스트 프레임워크 (권장)
- Mocha + Chai: 유연한 조합
- Supertest: Express API 테스트
- Mock/Spy: 외부 의존성 격리
- 커버리지:
--coverage로 테스트가 닿지 않은 분기를 찾는 용도 - TDD: 테스트 먼저, 구현 나중
테스트 프레임워크 비교
| 프레임워크 | 장점 | 단점 |
|---|---|---|
| Jest | 올인원, 빠름, 스냅샷 | 무거움 |
| Mocha | 유연, 가벼움 | 추가 라이브러리 필요 |
| AVA | 병렬 실행 | 생태계 작음 |
테스트 작성 원칙
- 테스트는 독립적이어야 함
- 하나의 테스트는 하나만 검증
- 의미 있는 테스트 이름
- AAA 패턴 (Arrange, Act, Assert)
- 실패 케이스도 테스트
- 외부 의존성은 Mock
- 테스트 데이터는 최소화
다음 단계
추천 학습 자료
공식 문서:
자주 묻는 질문 (FAQ)
Q. 테스트를 단독으로 돌리면 통과하는데 전체로 돌리면 실패하는 이유는 무엇인가요?
A. 테스트 간에 전역 상태나 DB 데이터를 공유해서 앞 테스트의 결과가 뒤 테스트에 영향을 주는 경우가 대부분입니다. 공유 변수는 beforeEach에서 매번 초기화하고, DB 테스트는 afterEach나 afterAll에서 컬렉션·테이블을 정리해야 합니다. 비동기 테스트가 5000ms 타임아웃에 걸린다면 await 누락을 먼저 의심하고, 실제로 느린 작업일 때만 testTimeout을 늘립니다.