Node.js 테스트 | Jest, Mocha, Supertest 완벽 가이드
이 글의 핵심
Node.js 테스트: Jest, Mocha, Supertest Jest·비동기 테스트.
들어가며
테스트의 중요성
테스트는 코드가 의도대로 동작하는지 확인하는 과정입니다. 자동 테스트는 리팩터링할 때 안전벨트입니다. 같은 입력에 같은 출력이 나오는지 기계가 확인해 주면, 비동기·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): 전체 시스템
실전 경험에서 배운 교훈
이 기술을 실무 프로젝트에 처음 도입했을 때, 공식 문서만으로는 알 수 없었던 많은 함정들이 있었습니다. 특히 프로덕션 환경에서 발생하는 엣지 케이스들은 로컬 개발 환경에서는 재현조차 되지 않았죠.
가장 어려웠던 점은 성능 최적화였습니다. 처음엔 “동작만 하면 되겠지”라고 생각했지만, 실제 사용자 트래픽이 몰리면서 병목 지점들이 하나씩 드러났습니다. 특히 데이터베이스 쿼리 최적화, 캐싱 전략, 에러 핸들링 구조 등은 여러 번의 장애를 겪으면서 개선해 나갔습니다.
이 글에서는 그런 시행착오를 통해 얻은 실전 노하우와, “이렇게 하면 안 된다”는 교훈들을 함께 정리했습니다. 특히 트러블슈팅 섹션은 실제 장애 대응 경험을 바탕으로 작성했으니, 비슷한 문제를 마주했을 때 참고하시면 도움이 될 것입니다.
1. Jest
설치
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('에러!');
});
});
2. 비동기 테스트
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();
});
});
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');
});
});
3. 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');
});
});
Spy
describe('Spy 예제', () => {
test('메서드 호출 감시', () => {
const obj = {
method: () => 'original'
};
const spy = jest.spyOn(obj, 'method');
obj.method();
expect(spy).toHaveBeenCalled();
spy.mockRestore(); // 원래대로 복원
});
});
4. API 테스트 (Supertest)
설치
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');
});
});
});
5. 데이터베이스 테스트
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();
}
});
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();
});
});
6. 통합 테스트
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);
});
});
});
7. 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);
});
});
8. 테스트 커버리지
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
}
}
};
9. 실전 예제
예제 1: 사용자 서비스 테스트
// 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('사용자를 찾을 수 없습니다');
});
});
});
예제 2: 인증 테스트
// 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);
});
});
});
10. 테스트 더블 (Test Doubles)
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');
});
});
11. 자주 발생하는 문제
문제 1: 비동기 테스트 타임아웃
에러:
Timeout - Async callback was not invoked within the 5000 ms timeout
해결:
// 타임아웃 증가
test('느린 작업', async () => {
await slowOperation();
}, 10000); // 10초
// jest.config.js
module.exports = {
testTimeout: 10000
};
문제 2: 테스트 간 간섭
// ❌ 전역 상태 공유
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); // 성공!
});
문제 3: 데이터베이스 정리 누락
// ✅ 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();
});
12. 실전 팁
테스트 구조화
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();
});
정리
핵심 요약
- Jest: 올인원 테스트 프레임워크 (권장)
- Mocha + Chai: 유연한 조합
- Supertest: Express API 테스트
- Mock/Spy: 외부 의존성 격리
- 커버리지: 80% 이상 목표
- TDD: 테스트 먼저, 구현 나중
테스트 프레임워크 비교
| 프레임워크 | 장점 | 단점 |
|---|---|---|
| Jest | 올인원, 빠름, 스냅샷 | 무거움 |
| Mocha | 유연, 가벼움 | 추가 라이브러리 필요 |
| AVA | 병렬 실행 | 생태계 작음 |
테스트 베스트 프랙티스
- ✅ 테스트는 독립적이어야 함
- ✅ 하나의 테스트는 하나만 검증
- ✅ 의미 있는 테스트 이름
- ✅ AAA 패턴 (Arrange, Act, Assert)
- ✅ 실패 케이스도 테스트
- ✅ 외부 의존성은 Mock
- ✅ 테스트 데이터는 최소화
다음 단계
- Node.js 배포
- Node.js 성능 최적화
- Node.js CI/CD
추천 학습 자료
공식 문서:
관련 글
심화 부록: 구현·운영 관점
이 부록은 앞선 본문에서 다룬 주제(「Node.js 테스트 | Jest, Mocha, Supertest 완벽 가이드」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.
내부 동작과 핵심 메커니즘
flowchart TD A[입력·요청·이벤트] --> B[파싱·검증·디코딩] B --> C[핵심 연산·상태 전이] C --> D[부작용: I/O·네트워크·동시성] D --> E[결과·관측·저장]
sequenceDiagram participant C as 클라이언트/호출자 participant B as 경계(런타임·게이트웨이·프로세스) participant D as 의존성(API·DB·큐·파일) C->>B: 요청/이벤트 B->>D: 조회·쓰기·RPC D-->>B: 지연·부분 실패·재시도 가능 B-->>C: 응답 또는 오류(코드·상관 ID)
- 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
- 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
- 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
- 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.
프로덕션 운영 패턴
| 영역 | 운영 관점 질문 |
|---|---|
| 관측성 | 요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가 |
| 안전성 | 입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가 |
| 신뢰성 | 재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가 |
| 성능 | 캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가 |
| 배포 | 롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가 |
| 용량 | 피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가 |
스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.
확장 예시: 엔드투엔드 미니 시나리오
앞선 본문 주제(「Node.js 테스트 | Jest, Mocha, Supertest 완벽 가이드」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.
- 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
- 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
- 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
- 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
- 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
ctx = newCorrelationId()
validated = validateSchema(request)
authorize(validated, ctx)
result = domainCore(validated)
persistOrEmit(result, idempotentKey)
recordMetrics(ctx, latency, outcome)
return result
문제 해결(Troubleshooting)
| 증상 | 가능 원인 | 조치 |
|---|---|---|
| 간헐적 실패 | 레이스, 타임아웃, 외부 의존성, DNS | 최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검 |
| 성능 저하 | N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스 | 프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거 |
| 메모리 증가 | 캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납 | 상한·TTL·힙/FD 스냅샷 비교 |
| 빌드·배포만 실패 | 환경 변수, 권한, 플랫폼 차이, lockfile | CI 로그와 로컬 diff, 런타임·이미지 버전 핀 |
| 설정 불일치 | 프로필·시크릿·기본값, 리전 | 스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화 |
| 데이터 불일치 | 비멱등 재시도, 부분 쓰기, 캐시 무효화 누락 | 멱등 키·아웃박스·트랜잭션 경계 재검토 |
권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.
배포 전에는 git add → git commit → git push 후 npm run deploy 순서를 권장합니다.
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. Node.js 테스트: Jest, Mocha, Supertest 완벽 가이드. Jest·비동기 테스트로 흐름을 잡고 원리·코드·실무 적용을 한글로 정리합니다. Node.js·테스트·Jest 중심으로 설명합니다. St… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- Kotlin 테스팅 | JUnit, MockK, 테스트 작성법
- Node.js 배포 가이드 | PM2, Docker, AWS, Nginx
- 투 포인터 | O(n²) → O(n) 최적화 기법 완벽 정리
이 글에서 다루는 키워드 (관련 검색어)
Node.js, 테스트, Jest, Mocha, Supertest, TDD, 단위테스트 등으로 검색하시면 이 글이 도움이 됩니다.