Jest와 Supertest를 이용한 API 엔드투엔드 안정성 확보
Lukas Schneider
DevOps Engineer · Leapcell

소개: API 안정성의 중요성
오늘날 상호 연결된 세상에서 Node.js REST API는 수많은 애플리케이션의 중추 역할을 하며 데이터 교환을 촉진하고 사용자 경험을 강화합니다. 이러한 API의 복잡성과 규모가 커짐에 따라 안정성, 정확성 및 사양 준수를 보장하는 것이 가장 중요해집니다. 수동 테스트는 때때로 필요하지만 종종 비효율적이고 오류가 발생하기 쉬우며 빠른 개발 주기에서는 지속 가능하지 않습니다. 이때 자동화된 엔드투엔드(E2E) 테스트가 등장합니다. 이는 사용자 또는 클라이언트 애플리케이션이 상호 작용하는 방식 그대로 요청부터 응답까지 전체 애플리케이션 흐름을 검증하는 중요한 안전망을 제공합니다. 실제 시나리오를 시뮬레이션함으로써 E2E 테스트는 회귀를 조기에 발견하고 개발자 신뢰도를 높이며 궁극적으로 더 견고하고 안정적인 API에 기여합니다. 이 글에서는 Jest와 Supertest의 강력한 조합을 사용하여 Node.js REST API에 대한 엔드투엔드 테스트를 효과적으로 구현하는 방법을 살펴봅니다.
견고한 API 테스트를 위한 핵심 도구 이해
구현에 앞서 관련된 기본 도구에 대한 명확한 이해를 확립해 보겠습니다.
Jest: Jest는 Facebook에서 개발한 즐거운 JavaScript 테스트 프레임워크로, 단순성, 속도 및 포괄적인 기능으로 널리 채택되었습니다. 테스트 러너, 어설션 라이브러리 및 모킹 기능을 포함하는 올인원 솔루션입니다. E2E 테스트의 경우 Jest는 테스트 스위트를 정의, 실행 및 보고하는 구조를 제공하여 익숙하고 강력한 환경을 제공합니다.
Supertest: Supertest는 HTTP 요청 라이브러리인 Superagent를 기반으로 구축된 고수준 추상화로, HTTP 서버 테스트를 위해 특별히 설계되었습니다. 이를 통해 API 엔드포인트에 HTTP 요청을 보내고 응답을 유창하고 읽기 쉬운 방식으로 어설션할 수 있습니다. Supertest는 테스트를 위해 서버를 시작하고 중지하는 작업을 우아하게 처리하여 E2E 테스트 프로세스를 원활하게 만듭니다.
엔드투엔드(E2E) 테스트: 단위 테스트(격리된 구성 요소에 중점) 또는 통합 테스트(여러 단위 간의 상호 작용 검증)와 달리 E2E 테스트는 사용자 관점에서 애플리케이션의 전체 흐름을 검증합니다. API의 경우 이는 실행 중인 서버에 실제 HTTP 요청을 보내고, 데이터베이스와 상호 작용하고, 상태 코드, 데이터 형식 및 부작용을 포함한 예기된 응답을 검증하는 것을 의미합니다.
테스트 환경 설정
기본 Node.js Express REST API가 있다고 가정해 보겠습니다. 다음은 API에 대한 app.js
파일의 간단한 예입니다.
// app.js const express = require('express'); const bodyParser = require('body-parser'); const app = express(); const port = 3000; app.use(bodyParser.json()); let items = [ { id: '1', name: 'Item One', description: 'This is item one.' }, { id: '2', name: 'Item Two', description: 'This is item two.' }, ]; // 모든 항목 가져오기 app.get('/items', (req, res) => { res.status(200).json(items); }); // ID로 항목 가져오기 app.get('/items/:id', (req, res) => { const item = items.find(i => i.id === req.params.id); if (item) { res.status(200).json(item); } else { res.status(404).send('Item not found'); } }); // 새 항목 만들기 app.post('/items', (req, res) => { const { name, description } = req.body; if (!name || !description) { return res.status(400).send('Name and description are required'); } const newItem = { id: String(items.length + 1), name, description }; items.push(newItem); res.status(201).json(newItem); }); // 항목 업데이트 app.put('/items/:id', (req, res) => { const { name, description } = req.body; const itemIndex = items.findIndex(i => i.id === req.params.id); if (itemIndex > -1) { items[itemIndex] = { ...items[itemIndex], name: name || items[itemIndex].name, description: description || items[itemIndex].description }; res.status(200).json(items[itemIndex]); } else { res.status(404).send('Item not found'); } }); // 항목 삭제 app.delete('/items/:id', (req, res) => { const initialLength = items.length; items = items.filter(i => i.id !== req.params.id); if (items.length < initialLength) { res.status(204).send(); // No Content } else { res.status(404).send('Item not found'); } }); if (process.env.NODE_ENV !== 'test') { app.listen(port, () => { console.log(`Server running on http://localhost:${port}`); }); } module.exports = app; // Export the app for testing
먼저 필요한 패키지를 설치하겠습니다.
npm install jest supertest express body-parser
또는 yarn 사용:
yarn add jest supertest express body-parser
package.json
에 테스트 스크립트를 추가합니다.
"scripts": { "test": "jest" }
Jest와 Supertest로 엔드투엔드 테스트 작성하기
이제 테스트 파일을 만들어 보겠습니다. 예를 들어 __tests__/items.e2e.test.js
입니다.
// __tests__/items.e2e.test.js const request = require('supertest'); const app = require('../app'); // Express 앱 가져오기 let server; // 모든 테스트 전에 서버 시작 beforeAll(() => { server = app.listen(0); // 사용되지 않는 임의의 포트에서 수신 대기 }); // 모든 테스트 후에 서버 닫기 afterAll((done) => { server.close(done); }); describe('Items API E2E Tests', () => { let initialItems; // 각 테스트 전에 데이터 재설정(테스트 격리에 중요) beforeEach(() => { // 시연을 위한 간단한 데이터 재설정 방법. // 실제 애플리케이션에서는 테스트 데이터베이스를 시드할 수 있습니다. initialItems = [ { id: '1', name: 'Initial Item One', description: 'This is the first initial item.' }, { id: '2', name: 'Initial Item Two', description: 'This is the second initial item.' }, ]; // 이 부분은 시연을 위한 간소화된 재설정입니다. 실제 앱에서는 // 일반적으로 각 테스트 전에 데이터베이스를 재설정합니다. // 이 예제에서는 `app.js`의 `items` 배열을 직접 조작할 것입니다. // 이것은 이상적이지는 않지만 E2E 개념을 설명하는 데 작동합니다. // 적절한 설정에서는 테스트 데이터베이스 또는 모킹 전략이 필요합니다. // `app.js`의 'let' 변수(예: `items`)에서 데이터를 올바르게 재설정하려면 // 재설정 함수를 노출하거나 'items'가 명명된 내보내기로 내보내진 경우 모듈을 다시 가져와야 합니다. // 이 예제에서는 테스트가 어느 정도 격리되어 있다고 가정하거나 순차적으로 실행합니다. // 더 강력한 솔루션은 app.js에서 재설정 함수를 노출하거나 테스트 데이터베이스를 사용하는 것입니다. }); it('should get all items', async () => { const res = await request(app).get('/items'); expect(res.statusCode).toEqual(200); expect(Array.isArray(res.body)).toBeTruthy(); expect(res.body.length).toBeGreaterThanOrEqual(1); // 초기 데이터가 있다고 가정 expect(res.body[0]).toHaveProperty('id'); expect(res.body[0]).toHaveProperty('name'); }); it('should get an item by ID', async () => { const itemId = '1'; const res = await request(app).get(`/items/${itemId}`); expect(res.statusCode).toEqual(200); expect(res.body).toHaveProperty('id', itemId); expect(res.body).toHaveProperty('name', 'Item One'); // 항목의 현재 상태라고 가정 }); 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'); }); it('should create a new item', async () => { const newItem = { name: 'New Item', description: 'This is a brand new item.' }; const res = await request(app) .post('/items') .send(newItem); expect(res.statusCode).toEqual(201); expect(res.body).toHaveProperty('id'); expect(res.body).toHaveProperty('name', newItem.name); expect(res.body).toHaveProperty('description', newItem.description); // 가져와서 확인 const getRes = await request(app).get(`/items/${res.body.id}`); expect(getRes.statusCode).toEqual(200); expect(getRes.body).toEqual(expect.objectContaining(newItem)); }); it('should return 400 if name or description is missing when creating an item', async () => { const invalidItem = { description: 'Missing name' }; const res = await request(app) .post('/items') .send(invalidItem); expect(res.statusCode).toEqual(400); expect(res.text).toBe('Name and description are required'); }); it('should update an existing item', async () => { const itemIdToUpdate = '1'; const updatedData = { name: 'Updated Item One', description: 'Description has changed.' }; const res = await request(app) .put(`/items/${itemIdToUpdate}`) .send(updatedData); expect(res.statusCode).toEqual(200); expect(res.body).toHaveProperty('id', itemIdToUpdate); expect(res.body).toHaveProperty('name', updatedData.name); expect(res.body).toHaveProperty('description', updatedData.description); // 업데이트된 것을 가져와서 확인 const getRes = await request(app).get(`/items/${itemIdToUpdate}`); expect(getRes.statusCode).toEqual(200); expect(getRes.body).toEqual(expect.objectContaining(updatedData)); }); it('should delete an item', async () => { const itemIdToDelete = '2'; const res = await request(app).delete(`/items/${itemIdToDelete}`); expect(res.statusCode).toEqual(204); // 삭제되었는지 확인하기 위해 가져오기 시도 const getRes = await request(app).get(`/items/${itemIdToDelete}`); expect(getRes.statusCode).toEqual(404); }); it('should return 404 when trying to delete a non-existent item', async () => { const res = await request(app).delete('/items/999'); expect(res.statusCode).toEqual(404); }); });
설정 및 테스트 설명
require('supertest')
및require('../app')
:supertest
와 Express 애플리케이션을 가져옵니다. Supertest는 별도의 서버 프로세스를 실행할 필요 없이 이app
인스턴스를 사용하여 HTTP 요청을 합니다.beforeAll
및afterAll
: 이 Jest 수명 주기 후크는 중요합니다.beforeAll
은 모든 테스트가 실행되기 전에 Expressapp
을 시작합니다.app.listen(0)
을 사용하여 서버가 임의의 사용 가능한 포트에서 수신 대기하도록 하여 충돌을 방지합니다.afterAll
은 모든 테스트가 완료된 후 서버를 닫습니다. 이는 깔끔한 종료를 보장하고 리소스 누수를 방지합니다.
describe('Items API E2E Tests', ...)
: 관련 테스트를 함께 그룹화하는 테스트 스위트입니다.beforeEach
(데이터 재설정): 이 후크는 각 테스트 전에 실행됩니다. E2E 테스트, 특히 데이터베이스와 같은 영구 저장소를 다룰 때 각 테스트 전에 상태를 재설정하는 것이 중요합니다. 이는 각 테스트가 예측 가능하고 격리된 환경에서 실행되도록 보장하여 이전 테스트가 이후 테스트에 영향을 미치지 않도록 합니다. 간단한 인메모리 배열 예제의 경우 적절한beforeEach
에는items
배열을 직접 조작하거나 더 현실적으로 테스트 데이터베이스를 잘라내고 다시 시드하는 것이 포함됩니다. 현재 예는 필요성을 강조하지만 단순화를 인정합니다.it('should get all items', async () => { ... });
: 각it
블록은 고유한 테스트 사례를 정의합니다.await request(app).get('/items')
: Supertest가 빛나는 부분입니다.request(app)
을 호출하여 Express 애플리케이션을 대상으로 하고.get()
,.post()
,.put()
,.delete()
와 같은 HTTP 메서드를 연결합니다..send(data)
: 요청 본문을 보내기 위해POST
및PUT
요청에 사용됩니다..expect(status)
또는expect(res.statusCode).toEqual(status)
: Supertest는.expect()
어설션을 연결할 수 있도록 허용하며, Jest의expect
를await request(...)
에서 반환된res
객체에서 직접 사용할 수도 있습니다.expect(Array.isArray(res.body)).toBeTruthy()
: Jest 어설션을 사용하여 응답 본문의 구조와 내용을 확인합니다.- 비동기 특성: HTTP 요청은 비동기 작업입니다.
async/await
를 사용하여 이를 우아하게 처리하여 테스트 코드를 동기적이고 읽기 쉽게 만듭니다.
테스트 실행
터미널에서 npm test
또는 yarn test
를 실행하기만 하면 됩니다. Jest는 E2E 테스트를 검색하고 실행하여 성공 및 실패에 대한 명확한 보고서를 제공합니다.
모범 사례 및 추가 고려 사항
- 테스트 데이터베이스: 실제 애플리케이션의 경우 항상 전용 테스트 데이터베이스(예: SQLite 인메모리, 별도의 PostgreSQL/MongoDB 인스턴스)를 사용하십시오. 이 데이터베이스 내에서 각 테스트에 대한 데이터를 재설정하는 것이 격리에 중요합니다.
jest-mongodb
와 같은 도구나 사용자 지정 스크립트는 시딩 및 지우기에 도움이 될 수 있습니다. - 환경 변수: 테스트 중에 다른 구성을 조건부로 로드하거나 외부 서비스를 모킹하기 위해 환경 변수(예:
process.env.NODE_ENV = 'test'
)를 사용합니다. - 인증: API에 인증이 있는 경우 E2E 테스트는 로그인 프로세스를 시뮬레이션하여 토큰(JWT, 세션 ID)을 얻은 다음 후속 요청에 포함해야 합니다(예:
request(app).get('/secure').set('Authorization', 'Bearer <token>')
). - 외부 서비스 모킹: E2E 테스트는 이상적으로 모든 계층을 히트하지만, 테스트가 빠르고 안정적이며 결정적인지 확인하기 위해 외부 서비스(타사 API, 결제 게이트웨이)를 모킹해야 할 때도 있습니다. Jest의 강력한 모킹 기능이나
nock
과 같은 라이브러리를 사용할 수 있습니다. - 테스트 구성: API 라우트 또는 기능에 맞춰 테스트 파일을 논리적으로 구성합니다(예:
__tests__/users.e2e.test.js
,__tests__/products.e2e.test.js
). - 정리:
afterAll
또는afterEach
후크가 모든 리소스(데이터베이스 연결, 열린 파일, 실행 중인 서버)를 올바르게 정리하는지 항상 확인하십시오. - 로깅: 과도한 콘솔 출력을 방지하기 위해 테스트 중 API 로깅을 최소화하십시오.
결론: API 신뢰도를 위한 기반
Jest와 Supertest를 사용한 엔드투엔드 테스트 숙달은 Node.js REST API에 대한 강력한 안전망을 제공합니다. 전체 사용자 여정과 API와의 상호 작용을 검증함으로써 애플리케이션이 발전함에 따라 예상대로 작동한다는 엄청난 확신을 얻습니다. 이는 프로덕션의 버그를 줄이고, 개발 주기를 가속화하며, 궁극적으로 더 안정적이고 유지 관리 가능한 코드베이스로 이어집니다. 이 테스트 방법론을 채택하는 것은 애플리케이션 안정성과 개발자 안녕에 배당금을 지급하는 투자입니다.