Node.js 인터뷰 질문 89

질문: Node.js 애플리케이션에서 테스트를 구현하는 다양한 방법과 테스트 전략에 대해 설명해주세요.

답변:

Node.js 애플리케이션의 테스트는 코드의 품질과 신뢰성을 보장하는 중요한 부분입니다. 단위 테스트, 통합 테스트, E2E 테스트 등 다양한 테스트 전략을 살펴보겠습니다.

1. 단위 테스트 (Jest 사용)

Jest는 Facebook에서 개발한 JavaScript 테스트 프레임워크로, Node.js 애플리케이션 테스트에 널리 사용됩니다.

// user.service.js
class UserService {
  constructor(userRepository) {
    this.userRepository = userRepository;
  }

  async createUser(userData) {
    if (!userData.email || !userData.password) {
      throw new Error("이메일과 비밀번호는 필수입니다");
    }

    const existingUser = await this.userRepository.findByEmail(userData.email);
    if (existingUser) {
      throw new Error("이미 존재하는 이메일입니다");
    }

    return this.userRepository.create(userData);
  }

  async getUserById(id) {
    const user = await this.userRepository.findById(id);
    if (!user) {
      throw new Error("사용자를 찾을 수 없습니다");
    }
    return user;
  }
}

// user.service.test.js
describe("UserService", () => {
  let userService;
  let mockUserRepository;

  beforeEach(() => {
    // 목 리포지토리 설정
    mockUserRepository = {
      findByEmail: jest.fn(),
      findById: jest.fn(),
      create: jest.fn(),
    };
    userService = new UserService(mockUserRepository);
  });

  describe("createUser", () => {
    it("유효한 데이터로 사용자를 생성해야 함", async () => {
      const userData = {
        email: "test@example.com",
        password: "password123",
      };

      mockUserRepository.findByEmail.mockResolvedValue(null);
      mockUserRepository.create.mockResolvedValue({
        id: 1,
        ...userData,
      });

      const result = await userService.createUser(userData);

      expect(result).toHaveProperty("id", 1);
      expect(result.email).toBe(userData.email);
      expect(mockUserRepository.create).toHaveBeenCalledWith(userData);
    });

    it("이메일이 없으면 오류를 발생시켜야 함", async () => {
      const userData = {
        password: "password123",
      };

      await expect(userService.createUser(userData)).rejects.toThrow(
        "이메일과 비밀번호는 필수입니다"
      );
    });

    it("이미 존재하는 이메일이면 오류를 발생시켜야 함", async () => {
      const userData = {
        email: "existing@example.com",
        password: "password123",
      };

      mockUserRepository.findByEmail.mockResolvedValue({ id: 1, ...userData });

      await expect(userService.createUser(userData)).rejects.toThrow(
        "이미 존재하는 이메일입니다"
      );
    });
  });
});

2. 통합 테스트 (Supertest 사용)

Supertest를 사용하여 HTTP API 엔드포인트를 테스트합니다.

// app.js
const express = require("express");
const app = express();

app.use(express.json());

app.post("/api/users", async (req, res) => {
  try {
    const user = await userService.createUser(req.body);
    res.status(201).json(user);
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

// app.test.js
const request = require("supertest");
const app = require("./app");
const { connectDB, closeDB } = require("./db");

describe("User API", () => {
  beforeAll(async () => {
    await connectDB();
  });

  afterAll(async () => {
    await closeDB();
  });

  describe("POST /api/users", () => {
    it("유효한 데이터로 사용자를 생성해야 함", async () => {
      const userData = {
        email: "test@example.com",
        password: "password123",
      };

      const response = await request(app)
        .post("/api/users")
        .send(userData)
        .expect(201);

      expect(response.body).toHaveProperty("id");
      expect(response.body.email).toBe(userData.email);
    });

    it("유효하지 않은 데이터로 400 에러를 반환해야 함", async () => {
      const userData = {
        password: "password123",
      };

      const response = await request(app)
        .post("/api/users")
        .send(userData)
        .expect(400);

      expect(response.body).toHaveProperty("error");
    });
  });
});

3. E2E 테스트 (Cypress 사용)

Cypress를 사용하여 전체 애플리케이션 흐름을 테스트합니다.

// cypress/integration/user-flow.spec.js
describe("사용자 흐름", () => {
  beforeEach(() => {
    cy.visit("/");
  });

  it("회원가입 및 로그인 흐름을 테스트", () => {
    // 회원가입
    cy.get('[data-test="signup-link"]').click();
    cy.get('[data-test="email-input"]').type("test@example.com");
    cy.get('[data-test="password-input"]').type("password123");
    cy.get('[data-test="signup-button"]').click();

    // 회원가입 성공 확인
    cy.get('[data-test="success-message"]')
      .should("be.visible")
      .and("contain", "회원가입이 완료되었습니다");

    // 로그인
    cy.get('[data-test="login-link"]').click();
    cy.get('[data-test="email-input"]').type("test@example.com");
    cy.get('[data-test="password-input"]').type("password123");
    cy.get('[data-test="login-button"]').click();

    // 로그인 성공 확인
    cy.get('[data-test="user-profile"]')
      .should("be.visible")
      .and("contain", "test@example.com");
  });
});

4. 목(Mock)과 스텁(Stub) 사용

외부 의존성을 테스트에서 격리하기 위한 목과 스텁 사용:

// payment.service.js
class PaymentService {
  constructor(paymentGateway) {
    this.paymentGateway = paymentGateway;
  }

  async processPayment(amount, cardDetails) {
    try {
      const result = await this.paymentGateway.charge(amount, cardDetails);
      return {
        success: true,
        transactionId: result.id,
      };
    } catch (error) {
      return {
        success: false,
        error: error.message,
      };
    }
  }
}

// payment.service.test.js
describe("PaymentService", () => {
  let paymentService;
  let mockPaymentGateway;

  beforeEach(() => {
    // 목 페이먼트 게이트웨이 생성
    mockPaymentGateway = {
      charge: jest.fn(),
    };
    paymentService = new PaymentService(mockPaymentGateway);
  });

  it("결제가 성공적으로 처리되어야 함", async () => {
    const amount = 1000;
    const cardDetails = {
      number: "4111111111111111",
      expiry: "12/24",
      cvv: "123",
    };

    mockPaymentGateway.charge.mockResolvedValue({
      id: "tx_123",
      status: "succeeded",
    });

    const result = await paymentService.processPayment(amount, cardDetails);

    expect(result.success).toBe(true);
    expect(result.transactionId).toBe("tx_123");
    expect(mockPaymentGateway.charge).toHaveBeenCalledWith(amount, cardDetails);
  });

  it("결제 실패 시 에러를 반환해야 함", async () => {
    mockPaymentGateway.charge.mockRejectedValue(
      new Error("카드가 거절되었습니다")
    );

    const result = await paymentService.processPayment(1000, {});

    expect(result.success).toBe(false);
    expect(result.error).toBe("카드가 거절되었습니다");
  });
});

5. 테스트 커버리지 분석

Jest의 테스트 커버리지 도구 사용:

// package.json
{
  "scripts": {
    "test": "jest",
    "test:coverage": "jest --coverage"
  },
  "jest": {
    "collectCoverageFrom": [
      "src/**/*.js",
      "!src/index.js",
      "!src/config/*.js"
    ],
    "coverageThreshold": {
      "global": {
        "branches": 80,
        "functions": 80,
        "lines": 80,
        "statements": 80
      }
    }
  }
}

6. 성능 테스트

Artillery를 사용한 부하 테스트:

# performance-test.yml
config:
  target: "http://localhost:3000"
  phases:
    - duration: 60
      arrivalRate: 20
      name: "Warm up"
    - duration: 120
      arrivalRate: 50
      name: "Peak load"

scenarios:
  - name: "API 엔드포인트 테스트"
    flow:
      - get:
          url: "/api/users"
      - think: 1
      - post:
          url: "/api/users"
          json:
            email: "test@example.com"
            password: "password123"

7. 테스트 환경 설정

테스트 환경별 설정 관리:

// config/test.js
module.exports = {
  database: {
    url: "mongodb://localhost:27017/test_db",
  },
  jwt: {
    secret: "test_secret",
  },
  mail: {
    // 테스트용 메일 설정
    transport: "smtp",
    host: "localhost",
    port: 1025,
  },
};

// test/setup.js
const config = require("../config/test");
const { MongoMemoryServer } = require("mongodb-memory-server");

let mongoServer;

beforeAll(async () => {
  // 인메모리 MongoDB 서버 시작
  mongoServer = await MongoMemoryServer.create();
  const mongoUri = mongoServer.getUri();
  process.env.MONGODB_URI = mongoUri;
});

afterAll(async () => {
  // 테스트 후 정리
  await mongoServer.stop();
});

// 각 테스트 전에 데이터베이스 초기화
beforeEach(async () => {
  await clearDatabase();
});

요약

Node.js 애플리케이션 테스트 전략의 주요 포인트:

  1. 단위 테스트

    • 개별 컴포넌트 테스트
    • Jest 사용
    • 목과 스텁 활용
  2. 통합 테스트

    • API 엔드포인트 테스트
    • 데이터베이스 연동 테스트
    • Supertest 활용
  3. E2E 테스트

    • 전체 사용자 흐름 테스트
    • Cypress 사용
    • 실제 환경과 유사한 조건
  4. 테스트 환경

    • 환경별 설정 관리
    • 테스트 데이터베이스 사용
    • 외부 서비스 목 처리
  5. 테스트 커버리지

    • 코드 커버리지 측정
    • 임계값 설정
    • 지속적인 모니터링
  6. 성능 테스트

    • 부하 테스트
    • 확장성 검증
    • 병목 지점 식별
  7. 테스트 자동화

    • CI/CD 파이프라인 통합
    • 자동화된 테스트 실행
    • 빠른 피드백 루프

results matching ""

    No results matching ""