Node.js 인터뷰 질문 67
질문: Node.js 애플리케이션 테스팅 전략과 주요 테스트 도구에 대해 설명해주세요.
답변:
Node.js 애플리케이션의 효과적인 테스팅은 안정적이고 유지보수 가능한 코드베이스를 구축하는 데 필수적입니다. 다양한 테스팅 전략과 도구를 통해 코드의 품질을 보장하고 버그를 조기에 발견할 수 있습니다.
1. 테스팅 유형과 전략
1.1 유닛 테스트(Unit Testing)
개별 함수, 모듈 또는 클래스의 독립적인 기능을 테스트합니다.
// 테스트할 함수
function sum(a, b) {
return a + b;
}
// Jest를 사용한 유닛 테스트
describe("sum function", () => {
test("adds 1 + 2 to equal 3", () => {
expect(sum(1, 2)).toBe(3);
});
test("adds -1 + 1 to equal 0", () => {
expect(sum(-1, 1)).toBe(0);
});
test("adds 0 + 0 to equal 0", () => {
expect(sum(0, 0)).toBe(0);
});
});
1.2 통합 테스트(Integration Testing)
여러 모듈이나 서비스가 함께 작동하는 방식을 테스트합니다.
// 사용자 서비스와 데이터베이스 통합 테스트 (Jest + Supertest)
const request = require("supertest");
const app = require("../app");
const mongoose = require("mongoose");
const User = require("../models/User");
describe("User API", () => {
beforeAll(async () => {
// 테스트 데이터베이스 연결
await mongoose.connect(process.env.TEST_MONGODB_URI);
});
afterAll(async () => {
// 데이터베이스 연결 종료
await mongoose.connection.close();
});
beforeEach(async () => {
// 테스트 데이터베이스 초기화
await User.deleteMany({});
});
test("should create a new user", async () => {
const newUser = {
username: "testuser",
email: "test@example.com",
password: "password123",
};
const response = await request(app)
.post("/api/users")
.send(newUser)
.expect(201);
// 응답 확인
expect(response.body.username).toBe(newUser.username);
expect(response.body.email).toBe(newUser.email);
// 데이터베이스에 사용자가 생성되었는지 확인
const userInDb = await User.findOne({ email: newUser.email });
expect(userInDb).toBeTruthy();
expect(userInDb.username).toBe(newUser.username);
});
});
1.3 엔드-투-엔드 테스트(End-to-End Testing)
실제 사용자 시나리오를 시뮬레이션하여 전체 애플리케이션 흐름을 테스트합니다.
// Cypress를 사용한 E2E 테스트 예시 (cypress/integration/login.spec.js)
describe("Login Page", () => {
beforeEach(() => {
cy.visit("/login");
});
it("should login with valid credentials", () => {
cy.get("[data-cy=username]").type("user@example.com");
cy.get("[data-cy=password]").type("password123");
cy.get("[data-cy=login-button]").click();
// 로그인 성공 후 대시보드로 리디렉션되는지 확인
cy.url().should("include", "/dashboard");
cy.get("[data-cy=welcome-message]").should("contain", "Welcome, User");
});
it("should show error with invalid credentials", () => {
cy.get("[data-cy=username]").type("wrong@example.com");
cy.get("[data-cy=password]").type("wrongpassword");
cy.get("[data-cy=login-button]").click();
// 오류 메시지 표시 확인
cy.get("[data-cy=error-message]")
.should("be.visible")
.and("contain", "Invalid credentials");
// URL이 여전히 로그인 페이지인지 확인
cy.url().should("include", "/login");
});
});
1.4 부하 테스트(Load Testing)
애플리케이션이 높은 트래픽이나 데이터 처리량을 처리할 수 있는지 테스트합니다.
// k6를 사용한 부하 테스트 예시 (load-test.js)
import http from "k6/http";
import { sleep, check } from "k6";
export const options = {
vus: 100, // 가상 사용자 수
duration: "30s", // 테스트 지속 시간
};
export default function () {
// API 엔드포인트에 GET 요청
const res = http.get("http://localhost:3000/api/products");
// 응답 확인
check(res, {
"status is 200": (r) => r.status === 200,
"response time < 200ms": (r) => r.timings.duration < 200,
});
sleep(1);
}
2. 주요 테스트 도구 및 프레임워크
2.1 Jest
Facebook에서 개발한 JavaScript 테스트 프레임워크로, 간단한 설정과 강력한 기능을 제공합니다.
// Jest 설치
// npm install --save-dev jest
// package.json 구성
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}
// Jest 구성 (jest.config.js)
module.exports = {
testEnvironment: 'node',
coveragePathIgnorePatterns: ['/node_modules/', '/dist/'],
testMatch: ['**/__tests__/**/*.js', '**/?(*.)+(spec|test).js'],
verbose: true
};
// 비동기 코드 테스트
test('async data fetching', async () => {
const data = await fetchUserData(1);
expect(data).toEqual({ id: 1, name: 'User 1' });
});
// 모킹 예시
jest.mock('../services/userService');
const userService = require('../services/userService');
test('should get user data', async () => {
userService.getUserById.mockResolvedValue({ id: 1, name: 'Mocked User' });
const controller = require('../controllers/userController');
const result = await controller.getUser(1);
expect(userService.getUserById).toHaveBeenCalledWith(1);
expect(result).toEqual({ id: 1, name: 'Mocked User' });
});
2.2 Mocha와 Chai
Mocha는 유연한 테스트 프레임워크이며, Chai는 풍부한 단언문을 제공하는 assertion 라이브러리입니다.
// 설치
// npm install --save-dev mocha chai
// test/calculator.test.js
const { expect } = require("chai");
const { add, subtract } = require("../calculator");
describe("Calculator", function () {
describe("#add()", function () {
it("should add two numbers correctly", function () {
expect(add(2, 3)).to.equal(5);
expect(add(-1, 1)).to.equal(0);
});
it("should handle non-number inputs", function () {
expect(add("2", 3)).to.equal(5);
expect(() => add(null, 3)).to.throw();
});
});
describe("#subtract()", function () {
it("should subtract two numbers correctly", function () {
expect(subtract(5, 2)).to.equal(3);
expect(subtract(1, 1)).to.equal(0);
expect(subtract(1, 5)).to.equal(-4);
});
});
});
2.3 Sinon
스파이, 스텁, 목 등을 활용한 테스트 더블을 제공하는 라이브러리입니다.
// 설치
// npm install --save-dev sinon
const sinon = require("sinon");
const { expect } = require("chai");
const userController = require("../controllers/userController");
const userService = require("../services/userService");
describe("UserController", () => {
let req, res, statusStub, jsonStub, getUserStub;
beforeEach(() => {
// 요청/응답 객체 설정
req = { params: { id: "1" } };
// 스텁 및 스파이 설정
statusStub = sinon.stub();
jsonStub = sinon.stub();
res = {
status: statusStub,
json: jsonStub,
};
statusStub.returns(res);
// 서비스 스텁
getUserStub = sinon.stub(userService, "getUserById");
});
afterEach(() => {
// 스텁 복원
getUserStub.restore();
});
it("should return user when found", async () => {
// 서비스 스텁 설정
getUserStub.resolves({ id: 1, name: "Test User" });
// 컨트롤러 실행
await userController.getUser(req, res);
// 검증
expect(getUserStub.calledOnceWith("1")).to.be.true;
expect(statusStub.calledOnceWith(200)).to.be.true;
expect(jsonStub.calledOnceWith({ id: 1, name: "Test User" })).to.be.true;
});
it("should return 404 when user not found", async () => {
// 서비스 스텁 설정
getUserStub.resolves(null);
// 컨트롤러 실행
await userController.getUser(req, res);
// 검증
expect(statusStub.calledOnceWith(404)).to.be.true;
expect(jsonStub.calledOnceWith({ error: "User not found" })).to.be.true;
});
});
2.4 Supertest
HTTP 서버를 테스트하기 위한 라이브러리로, Express.js 애플리케이션 테스트에 유용합니다.
// 설치
// npm install --save-dev supertest
const request = require("supertest");
const app = require("../app");
const { expect } = require("chai");
describe("Products API", () => {
it("GET /api/products should return all products", async () => {
const response = await request(app)
.get("/api/products")
.expect("Content-Type", /json/)
.expect(200);
expect(response.body).to.be.an("array");
expect(response.body.length).to.be.greaterThan(0);
});
it("GET /api/products/:id should return a single product", async () => {
const response = await request(app)
.get("/api/products/1")
.expect("Content-Type", /json/)
.expect(200);
expect(response.body).to.be.an("object");
expect(response.body.id).to.equal(1);
expect(response.body.name).to.be.a("string");
});
it("POST /api/products should create a new product", async () => {
const newProduct = {
name: "Test Product",
price: 99.99,
description: "A product for testing",
};
const response = await request(app)
.post("/api/products")
.send(newProduct)
.expect("Content-Type", /json/)
.expect(201);
expect(response.body).to.be.an("object");
expect(response.body.id).to.exist;
expect(response.body.name).to.equal(newProduct.name);
expect(response.body.price).to.equal(newProduct.price);
});
});
2.5 Nock
HTTP 요청을 모킹하여 외부 API 의존성을 테스트하는 라이브러리입니다.
// 설치
// npm install --save-dev nock
const nock = require("nock");
const { expect } = require("chai");
const weatherService = require("../services/weatherService");
describe("WeatherService", () => {
afterEach(() => {
nock.cleanAll();
});
it("should fetch weather data by city", async () => {
// 외부 API 모킹
nock("https://api.weatherapi.com")
.get("/v1/current.json")
.query({ key: "API_KEY", q: "London" })
.reply(200, {
location: { name: "London", country: "UK" },
current: { temp_c: 15, condition: { text: "Partly cloudy" } },
});
// 서비스 호출
const result = await weatherService.getWeatherByCity("London");
// 검증
expect(result).to.be.an("object");
expect(result.location.name).to.equal("London");
expect(result.current.temp_c).to.equal(15);
});
it("should handle API errors", async () => {
// 오류 응답 모킹
nock("https://api.weatherapi.com")
.get("/v1/current.json")
.query({ key: "API_KEY", q: "InvalidCity" })
.reply(400, { error: { message: "No matching location found." } });
try {
await weatherService.getWeatherByCity("InvalidCity");
// 예외가 발생하지 않으면 테스트 실패
expect.fail("Expected an error to be thrown");
} catch (error) {
expect(error.message).to.include("No matching location found");
}
});
});
3. 테스트 주도 개발(TDD)
테스트 주도 개발(TDD)은 테스트가 개발 프로세스를 이끄는 방법론입니다.
3.1 TDD 사이클
- Red: 실패하는 테스트 작성
- Green: 테스트를 통과하는 최소한의 코드 작성
- Refactor: 코드 리팩토링 및 개선
// 1. Red: 실패하는 테스트 작성
// userService.test.js
const { expect } = require("chai");
const UserService = require("../services/userService");
describe("UserService", () => {
describe("#validateEmail", () => {
it("should return true for valid email", () => {
const userService = new UserService();
expect(userService.validateEmail("user@example.com")).to.be.true;
});
it("should return false for invalid email", () => {
const userService = new UserService();
expect(userService.validateEmail("invalid-email")).to.be.false;
expect(userService.validateEmail("")).to.be.false;
expect(userService.validateEmail(null)).to.be.false;
});
});
});
// 2. Green: 테스트 통과 코드 작성
// userService.js
class UserService {
validateEmail(email) {
if (!email) return false;
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
}
module.exports = UserService;
// 3. Refactor: 코드 개선
// userService.js (리팩토링 후)
class UserService {
validateEmail(email) {
if (!email) return false;
// 더 정확한 이메일 정규식으로 개선
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
return emailRegex.test(String(email).toLowerCase());
}
}
module.exports = UserService;
4. 테스트 커버리지
테스트 커버리지는 코드베이스 중 테스트로 검증된 부분의 비율을 측정합니다.
// Jest를 사용한 커버리지 설정 (jest.config.js)
module.exports = {
// ... 기존 설정
collectCoverage: true,
coverageDirectory: "coverage",
coverageReporters: ["text", "lcov", "html"],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};
5. 데이터베이스 테스트 전략
5.1 인메모리 데이터베이스
테스트에 인메모리 데이터베이스를 사용하여 더 빠르고 격리된.테스트를 수행할 수 있습니다.
// MongoDB용 Mongoose와 MongoDB Memory Server 설정
const mongoose = require("mongoose");
const { MongoMemoryServer } = require("mongodb-memory-server");
let mongoServer;
// 테스트 설정
before(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
});
// 테스트 종료 후 정리
after(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
// 테스트 전 데이터베이스 초기화
beforeEach(async () => {
const collections = mongoose.connection.collections;
for (const key in collections) {
await collections[key].deleteMany({});
}
});
5.2 테스트 데이터 시딩
테스트에 필요한 데이터를 미리 준비하는 방법입니다.
// 테스트 데이터 시딩 헬퍼
const User = require("../models/User");
const Post = require("../models/Post");
async function seedDatabase() {
// 사용자 생성
const user1 = new User({
username: "testuser1",
email: "test1@example.com",
password: "password123",
});
await user1.save();
const user2 = new User({
username: "testuser2",
email: "test2@example.com",
password: "password123",
});
await user2.save();
// 게시물 생성
const post1 = new Post({
title: "Test Post 1",
content: "Content for test post 1",
author: user1._id,
});
await post1.save();
const post2 = new Post({
title: "Test Post 2",
content: "Content for test post 2",
author: user2._id,
});
await post2.save();
return {
users: { user1, user2 },
posts: { post1, post2 },
};
}
module.exports = {
seedDatabase,
};
6. 테스트 최적화 및 모범 사례
6.1 독립적인 테스트 작성
각 테스트는 독립적으로 실행될 수 있어야 합니다.
// 나쁜 예: 테스트 간 상태 공유
let createdUser;
it("should create a user", async () => {
createdUser = await userService.createUser({ name: "Test User" });
expect(createdUser.name).to.equal("Test User");
});
it("should update the user", async () => {
// 이전 테스트에 의존하므로 좋지 않음
const updatedUser = await userService.updateUser(createdUser.id, {
name: "Updated User",
});
expect(updatedUser.name).to.equal("Updated User");
});
// 좋은 예: 독립적인 테스트
it("should create a user", async () => {
const user = await userService.createUser({ name: "Test User" });
expect(user.name).to.equal("Test User");
});
it("should update the user", async () => {
// 독립적인 설정
const user = await userService.createUser({ name: "Test User" });
const updatedUser = await userService.updateUser(user.id, {
name: "Updated User",
});
expect(updatedUser.name).to.equal("Updated User");
});
6.2 적절한 목킹 사용
외부 의존성을 효과적으로 목킹하여 테스트를 격리합니다.
// 의존성 주입을 통한 목킹
class OrderService {
constructor(productRepository, paymentGateway) {
this.productRepository = productRepository;
this.paymentGateway = paymentGateway;
}
async createOrder(userId, productId, quantity) {
const product = await this.productRepository.findById(productId);
if (!product || product.stock < quantity) {
throw new Error("Product unavailable");
}
const amount = product.price * quantity;
const paymentResult = await this.paymentGateway.processPayment(
userId,
amount
);
if (paymentResult.success) {
await this.productRepository.updateStock(
productId,
product.stock - quantity
);
return { orderId: generateOrderId(), status: "confirmed", amount };
} else {
throw new Error("Payment failed: " + paymentResult.message);
}
}
}
// 테스트
describe("OrderService", () => {
it("should create an order successfully", async () => {
// 목 리포지토리 및 결제 게이트웨이
const productRepository = {
findById: sinon.stub().resolves({ id: "prod-1", price: 50, stock: 10 }),
updateStock: sinon.stub().resolves(true),
};
const paymentGateway = {
processPayment: sinon
.stub()
.resolves({ success: true, transactionId: "tx-123" }),
};
// 서비스 인스턴스 생성
const orderService = new OrderService(productRepository, paymentGateway);
// 주문 생성
const result = await orderService.createOrder("user-1", "prod-1", 2);
// 검증
expect(result.status).to.equal("confirmed");
expect(result.amount).to.equal(100);
expect(productRepository.findById.calledOnceWith("prod-1")).to.be.true;
expect(paymentGateway.processPayment.calledOnceWith("user-1", 100)).to.be
.true;
expect(productRepository.updateStock.calledOnceWith("prod-1", 8)).to.be
.true;
});
});
6.3 병렬 테스트 실행
테스트 실행 시간을 단축하기 위해 병렬 실행을 활용합니다.
// Jest 병렬 실행 구성 (jest.config.js)
module.exports = {
// ... 기존 설정
maxWorkers: '50%', // CPU 코어의 50% 사용
testTimeout: 30000 // 시간 초과 설정
};
// Mocha 병렬 실행 (package.json)
{
"scripts": {
"test": "mocha --parallel"
}
}
7. 지속적 통합(CI)에서의 테스트
CI 환경에서 자동화된 테스트를 설정하여 코드 품질을 지속적으로 모니터링합니다.
# GitHub Actions CI 구성 예시 (.github/workflows/ci.yml)
name: Node.js CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [14.x, 16.x, 18.x]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run lint
run: npm run lint
- name: Run tests
run: npm test
- name: Upload coverage reports
uses: codecov/codecov-action@v1
with:
token: ${{ secrets.CODECOV_TOKEN }}
요약
Node.js 애플리케이션 테스팅에 대한 종합적인 접근 방식:
다양한 테스트 유형 활용: 유닛 테스트로 개별 구성 요소를 검증하고, 통합 테스트로 구성 요소 간 상호 작용을 확인하며, 엔드-투-엔드 테스트로 전체 사용자 흐름을 테스트합니다.
적합한 도구 선택: Jest, Mocha/Chai, Sinon, Supertest, Nock 등 프로젝트에 적합한 도구를 선택합니다.
TDD 방법론 고려: 테스트 주도 개발 방식을 통해 더 견고한 코드베이스를 구축합니다.
테스트 격리: 각 테스트는 독립적으로 실행되어야 하며, 테스트 간 상태 공유를 피해야 합니다.
CI/CD 통합: 지속적 통합 파이프라인에 테스트를 통합하여 코드 품질을 지속적으로 모니터링합니다.
효과적인 테스트 전략을 통해 코드 품질을 향상시키고, 버그를 조기에 발견하며, 유지보수가 용이한 Node.js 애플리케이션을 개발할 수 있습니다.