Jest와 Supertest를 사용한 견고한 Node.js API 구축
Daniel Hayes
Full-Stack Engineer · Leapcell

소개
빠르게 변화하는 웹 개발 세계에서 견고하고 안정적인 Node.js API를 구축하는 것은 매우 중요합니다. 애플리케이션이 복잡해짐에 따라 정확성과 안정성을 보장하는 것의 중요성은 점점 더 커지고 있습니다. 수동 테스트는 때때로 필요하지만 시간이 많이 걸리고 오류가 발생하기 쉬우며 장기적으로 지속 가능하지 않습니다. 여기서 자동화된 테스트가 시스템 동작을 체계적이고 반복 가능한 방식으로 검증하는 방법을 제공합니다. 특히 Node.js API의 경우 단위 테스트와 통합 테스트의 조합은 포괄적인 안전망을 제공하여 개발 주기 초기에 버그를 잡고 자신감 있는 리팩토링 및 배포를 가능하게 합니다. 이 글에서는 강력하고 널리 채택된 두 가지 도구인 Jest(다재다능한 테스트 프레임워크)와 Supertest(HTTP 단언을 위한 우아한 처리)를 사용하여 Node.js API에 대한 효과적인 단위 및 통합 테스트를 작성하는 과정을 안내합니다.
API 테스트의 기반
실질적인 구현에 앞서 Node.js API 테스트와 관련된 몇 가지 핵심 개념을 정의해 보겠습니다.
주요 용어
- 단위 테스트: 이 수준의 테스트는 개별 코드 구성 요소 또는 "단위"를 격리하여 집중합니다. API의 경우 단위는 단일 함수, 모듈 또는 클래스일 수 있습니다. 목표는 각 단위가 시스템의 다른 부분과 독립적으로 의도된 동작을 올바르게 수행하는지 확인하는 것입니다. 이를 위해서는 종종 진정한 격리를 보장하기 위해 외부 종속성을 모킹합니다.
- 통합 테스트: 이는 애플리케이션의 다른 단위 또는 구성 요소 간의 상호 작용을 테스트합니다. API의 경우 통합 테스트는 일반적으로 끝점에 실제 HTTP 요청을 하고 응답을 확인하는 것을 포함합니다. 이는 컨트롤러, 서비스, 데이터베이스와 같이 API의 다른 부분이 예상대로 함께 작동하는지 확인합니다.
- Jest: Facebook에서 개발한 인기 있는 JavaScript 테스트 프레임워크입니다. Jest는 속도, 단순성 및 내장된 단언 라이브러리, 모킹 기능, 뛰어난 테스트 러너를 포함한 포괄적인 기능으로 유명합니다. JavaScript 테스트를 위한 올인원 솔루션입니다.
- Supertest: Node.js HTTP 서버를 테스트하기 위한 super-agent 기반 라이브러리입니다. Supertest를 사용하면 API에 HTTP 요청을 보내고 응답, 헤더, 상태 코드 및 본문 내용을 단언하는 것이 매우 쉽습니다. 테스트를 위해 HTTP 서버를 설정하고 해체하는 복잡성을 추상화합니다.
Jest와 Supertest를 사용하는 이유?
Jest는 테스트 작성을 위한 강력하고 확고한 환경을 제공하며, 단언 메서드부터 테스트 러너 및 보고까지 모든 것을 제공합니다. 스냅샷 테스트, 강력한 모킹 도구 및 뛰어난 성능은 JavaScript 개발자가 선택하는 주요 도구입니다. 반면 Supertest는 Node.js API에 HTTP 요청을 보내고 응답을 단언하는 프로세스를 단순화하여 Jest를 완벽하게 보완합니다. 함께 작동하여 API 품질을 보장하는 강력한 도구 키트를 형성합니다.
프로젝트 설정
기본 Node.js 프로젝트를 설정하고 필요한 패키지를 설치하는 것부터 시작하겠습니다.
먼저 새 Node.js 프로젝트를 초기화합니다.
mkdir my-api-tests cd my-api-tests npm init -y
이제 Jest와 Supertest를 설치합니다.
npm install --save-dev jest supertest
간단한 API 예제를 위해 Express도 설치합시다.
npm install express
간단한 API 구축
Express API를 위한 app.js
파일을 만듭니다.
// app.js const express = require('express'); const app = express(); const port = 3000; app.use(express.json()); // JSON 본문 파싱 활성화 let items = [ { id: 1, name: 'Item A' }, { id: 2, name: 'Item B' } ]; // 모든 항목 가져오기 app.get('/items', (req, res) => { res.json(items); }); // ID별 항목 가져오기 app.get('/items/:id', (req, res) => { const id = parseInt(req.params.id); const item = items.find(item => item.id === id); if (item) { res.json(item); } else { res.status(404).send('Item not found'); } }); // 새 항목 추가 app.post('/items', (req, res) => { const newItem = { id: items.length > 0 ? Math.max(...items.map(item => item.id)) + 1 : 1, name: req.body.name }; if (!newItem.name) { return res.status(400).send('Name is required'); } items.push(newItem); res.status(201).json(newItem); }); // 테스트를 위해 앱을 내보냅니다. module.exports = app; // 모듈이 직접 실행된 경우 서버 시작 (선택 사항) if (require.main === module) { app.listen(port, () => { console.log(`API running on http://localhost:${port}`); }); }
이제 package.json
에서 Jest를 구성하여 test
스크립트를 추가합니다.
{ "name": "my-api-tests", "version": "1.0.0", "description": "", "main": "app.js", "scripts": { "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { "express": "^4.19.2" }, "devDependencies": { "jest": "^29.7.0", "supertest": "^6.3.4" } }
Jest를 사용한 단위 테스트 구현
app.js
에 항목 로직을 처리하는 별도의 유틸리티 함수가 포함되어 있다고 가정해 봅시다. 단위 테스트의 경우 해당 함수를 격리하여 테스트합니다. 예제 app.js
에는 로직이 직접 포함되어 있으므로 단위 테스트를 설명하기 위해 가상의 itemService.js
를 만들어 보겠습니다.
// services/itemService.js let itemsData = [ { id: 1, name: 'Initial Item A' }, { id: 2, name: 'Initial Item B' } ]; const getAllItems = () => { return itemsData; }; const getItemById = (id) => { return itemsData.find(item => item.id === id); }; const addItem = (name) => { if (!name) { throw new Error('Name cannot be empty'); } const newItem = { id: itemsData.length > 0 ? Math.max(...itemsData.map(item => item.id)) + 1 : 1, name: name }; itemsData.push(newItem); return newItem; }; // 테스트 목적으로 데이터 재설정을 허용합니다. const resetItems = () => { itemsData = [ { id: 1, name: 'Initial Item A' }, { id: 2, name: 'Initial Item B' } ]; }; module.exports = { getAllItems, getItemById, addItem, resetItems // 테스트 설정/해체에 대해 내보냅니다. };
이제 __tests__/unit/itemService.test.js
파일을 만듭니다.
// __tests__/unit/itemService.test.js const itemService = require('../../services/itemService'); describe('itemService', () => { beforeEach(() => { // 각 테스트 전에 격리를 보장하기 위해 데이터를 재설정합니다. itemService.resetItems(); }); test('should return all items', () => { const items = itemService.getAllItems(); expect(items).toHaveLength(2); expect(items[0]).toHaveProperty('name', 'Initial Item A'); }); test('should return an item by ID', () => { const item = itemService.getItemById(1); expect(item).toHaveProperty('name', 'Initial Item A'); }); test('should return undefined if item ID does not exist', () => { const item = itemService.getItemById(99); expect(item).toBeUndefined(); }); test('should add a new item', () => { const newItem = itemService.addItem('New Test Item'); expect(newItem).toHaveProperty('name', 'New Test Item'); expect(newItem.id).toBeGreaterThan(0); expect(itemService.getAllItems()).toHaveLength(3); }); test('should throw error if name is empty when adding item', () => { expect(() => itemService.addItem('')).toThrow('Name cannot be empty'); }); test('should generate correct ID for new items', () => { itemService.addItem('One'); const item2 = itemService.addItem('Two'); expect(item2.id).toBe(4); // 항목 1, 2, One은 3, Two는 4라고 가정합니다. }); });
이러한 테스트를 실행하려면 다음을 실행하면 됩니다.
npm test
Jest는 모든 테스트 파일을 찾아 실행합니다.
Jest와 Supertest를 사용한 통합 테스트 구현
통합 테스트는 API 엔드포인트 자체를 검증하는 데 중점을 둡니다. 여기서 Supertest가 빛을 발합니다. __tests__/integration/items.test.js
파일을 만듭니다.
// __tests__/integration/items.test.js const request = require('supertest'); const app = require('../../app'); // Express 앱을 가져옵니다. // 테스트가 격리되도록 하려면 데이터 상태를 관리해야 합니다. // 실제 앱에서는 테스트 데이터베이스에 연결하고 데이터를 지우거나 시드하는 것이 필요합니다. // 이 간단한 예제에서는 각 테스트 스위트/테스트 전에 항목 배열이 재설정될 것이라고 가정합니다. // 또는 각 테스트 전에 앱을 다시 시작해야 할 수도 있습니다. // app.js에 인메모리 배열이 있으므로 각 테스트 실행 전에 신선한지 확인해야 합니다. // 일반적인 접근 방식은 app.js에서 항목을 초기화하거나 재설정하는 함수를 내보내는 것입니다. // 이 예제의 단순성을 위해 각 테스트 실행에 대한 신선한 상태를 가정합니다. // 프로덕션 앱의 경우 적절한 테스트 데이터베이스 전략을 사용하세요. describe('Items API Integration Tests', () => { let server; // 모든 테스트 전에 서버를 시작합니다. beforeAll((done) => { // 이 통합 테스트를 모두 실행하려면 앱을 한 번만 시작하면 됩니다. // 개별 테스트 간에 재설정해야 하는 공유되는 변경 가능한 상태가 없다고 가정합니다. // 그러나 앱이 전역 상태(예: `items` 배열)를 수정하는 경우 // 각 테스트 전에 `app`을 다시 가져오거나 해당 상태를 재설정해야 할 수 있습니다. // 이 예제에서는 Supertest의 서버 연결 모킹 기능을 활용합니다. done(); // Supertest는 Express 앱을 내부적으로 시작/중지합니다. }); // Test GET /items it('should return all items', async () => { const res = await request(app).get('/items'); expect(res.statusCode).toEqual(200); expect(res.body).toBeInstanceOf(Array); expect(res.body.length).toBeGreaterThan(0); expect(res.body[0]).toHaveProperty('name', 'Item A'); }); // Test GET /items/:id it('should return a specific item by ID', async () => { const res = await request(app).get('/items/1'); expect(res.statusCode).toEqual(200); expect(res.body).toHaveProperty('id', 1); expect(res.body).toHaveProperty('name', 'Item A'); }); it('should return 404 for a non-existent item', async () => { const res = await request(app).get('/items/999'); expect(res.statusCode).toEqual(404); expect(res.text).toBe('Item not found'); }); // Test POST /items it('should add a new item', async () => { // 이 테스트 전에 `app.js`의 `items` 배열에 이미 이전 테스트의 항목이 포함되어 있을 수 있습니다. // 이를 강력하게 만들려면 변경 가능한 상태가 있는 통합 테스트의 경우 // 일반적으로 데이터를 재설정하거나 데이터베이스를 모킹하는 방법이 필요합니다. // 이 간단한 예제의 경우 테스트 목적으로 이름을 고유하게 만듭니다. const newItemName = `New Item ${Date.now()}`; const res = await request(app) .post('/items') .send({ name: newItemName }) .set('Accept', 'application/json'); expect(res.statusCode).toEqual(201); expect(res.body).toHaveProperty('id'); expect(res.body).toHaveProperty('name', newItemName); // 선택 사항: GET을 통해 액세스할 수 있는지 확인합니다. const getRes = await request(app).get(`/items/${res.body.id}`); expect(getRes.statusCode).toEqual(200); expect(getRes.body).toHaveProperty('name', newItemName); }); it('should return 400 if name is missing when adding item', async () => { const res = await request(app) .post('/items') .send({}) // 빈 본문 .set('Accept', 'application/json'); expect(res.statusCode).toEqual(400); expect(res.text).toBe('Name is required'); }); });
npm test
를 사용하여 이러한 통합 테스트를 실행합니다.
실제 테스트 적용
- 지속적 통합: 테스트를 CI/CD 파이프라인(예: GitHub Actions, GitLab CI, Jenkins)에 통합합니다. 이렇게 하면 모든 코드 푸시에 테스트가 자동으로 실행되어 회귀를 방지할 수 있습니다.
- 테스트 주도 개발(TDD): 실제 코드를 작성하기 전에 테스트를 먼저 작성하는 TDD 접근 방식을 고려하십시오. 이는 더 나은 API를 설계하고, 테스트 용이성을 보장하며, 버그 가능성을 줄이는 데 도움이 됩니다.
- 데이터베이스 및 외부 서비스 모킹: 더 복잡한 통합 테스트의 경우 데이터베이스 상호 작용을 모킹하거나 외부 API에 대한 호출을 모킹해야 하는 경우가 많습니다. Jest의 강력한 모킹 기능을 사용하여 이러한 종속성에서 반환되는 데이터를 제어할 수 있으며 실제 외부 서비스를 호출하지 않아도 됩니다.
jest-mock-extended
와 같은 도구를 사용하면 인터페이스 모킹을 단순화할 수 있습니다. 데이터베이스의 경우 테스트용 인메모리 데이터베이스(예:sqlite
)를 사용하거나 각 테스트 전에 재설정되는 전용 테스트 데이터베이스를 사용하는 것을 고려하십시오.
결론
견고한 Node.js API를 구축하는 것은 기능적인 코드를 작성하는 것뿐만 아니라 포괄적인 테스트를 통해 안정성과 유지보수성을 보장하는 것이기도 합니다. 강력한 테스트 프레임워크인 Jest와 우아한 HTTP 단언 기능인 Supertest를 활용함으로써 개발자는 안전망 역할을 하는 효과적인 단위 및 통합 테스트를 만들 수 있습니다. 이는 개별 구성 요소의 정확성뿐만 아니라 API의 다양한 부분의 원활한 상호 작용을 보장하여 궁극적으로 더 안정적이고 확장 가능하며 신뢰할 수 있는 애플리케이션으로 이어집니다. Jest와 Supertest를 사용하여 자동화된 테스트를 채택하는 것은 자신 있게 고품질 Node.js API를 제공하기 위한 중요한 단계입니다.