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 사이클

  1. Red: 실패하는 테스트 작성
  2. Green: 테스트를 통과하는 최소한의 코드 작성
  3. 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 애플리케이션 테스팅에 대한 종합적인 접근 방식:

  1. 다양한 테스트 유형 활용: 유닛 테스트로 개별 구성 요소를 검증하고, 통합 테스트로 구성 요소 간 상호 작용을 확인하며, 엔드-투-엔드 테스트로 전체 사용자 흐름을 테스트합니다.

  2. 적합한 도구 선택: Jest, Mocha/Chai, Sinon, Supertest, Nock 등 프로젝트에 적합한 도구를 선택합니다.

  3. TDD 방법론 고려: 테스트 주도 개발 방식을 통해 더 견고한 코드베이스를 구축합니다.

  4. 테스트 격리: 각 테스트는 독립적으로 실행되어야 하며, 테스트 간 상태 공유를 피해야 합니다.

  5. CI/CD 통합: 지속적 통합 파이프라인에 테스트를 통합하여 코드 품질을 지속적으로 모니터링합니다.

효과적인 테스트 전략을 통해 코드 품질을 향상시키고, 버그를 조기에 발견하며, 유지보수가 용이한 Node.js 애플리케이션을 개발할 수 있습니다.

results matching ""

    No results matching ""