smob_logo
smob_logo
header-image
TEST테스트 없는 프로젝트에 NodeJS API 통합 테스트 구축하기Testcontainers, Tsyringe, Vitest를 활용한 테스트 환경 구축2024.09.01

현제 저희 프로젝트에서는 테스트 환경이 전혀 없었고, 명세서도 부족한 상태였습니다. 이러한 상황에서 기능을 추가하거나 코드 수정을 할 때마다 시스템 전체에 미치는 영향을 확인하는 것은 매우 어렵습니다. 이를 해결하기 위해, 먼저 통합 테스트 환경을 구축하는 데 집중했습니다.

저희 프로젝트는 TypeScript 기반의 Node.js API로, MongoDB를 데이터베이스로 사용하며, GraphQL(Apollo Server)과 REST API(Express)로 구성되어 있습니다.

왜 통합 테스트가 단위 테스트보다 먼저 필요했을까?

  1. 레이어 분리가 없는 구조
    • 현재 코드가 단일 함수에 집중되어 있어, 비즈니스 로직과 데이터 접근 로직이 분리되지 않았습니다. 이로 인해 개별 기능을 독립적으로 테스트하는 것이 어려우며, 시스템 전체의 동작을 검증하는 통합 테스트가 우선적으로 필요합니다.
  2. MongoDB Hook 의존성
    • 프로젝트의 많은 부분이 MongoDB hook에 의존하고 있어, 각 기능을 단일하게 분리해 단위 테스트를 작성하기 어렵습니다. 통합 테스트를 통해 MongoDB와의 상호작용이 예상대로 이루어지는지 검증하는 것이 중요합니다.
  3. 테스트 부재
    • 현재 프로젝트에는 테스트 코드가 전혀 작성되어 있지 않으며, 기존 기능에 대한 테스트 환경이 마련되지 않은 상태입니다. 따라서, 전체 시스템의 기능을 먼저 통합적으로 테스트하여 안정성을 확보해야 합니다.
  4. 기능 명세서 부재
    • 기능 명세서가 없어 각 기능이 어떻게 동작해야 하는지 명확한 기준이 부족합니다. 통합 테스트를 통해 전체적인 기능 흐름을 검증하면서, 세부 기능에 대한 이해를 높일 수 있습니다.
  5. 기능 추가 및 리팩토링 안정성 확보
    • 기능 추가나 리팩토링을 진행할 때, 기존 시스템의 전반적인 동작이 유지되는지 확인하는 것이 필수적입니다. 통합 테스트를 먼저 작성하면 시스템 전체의 안정성을 확보한 상태에서 리팩토링이나 기능 추가를 진행할 수 있습니다.

API 통합테스트 환경 구축 시 중요하게 고려했던 점

  1. 실제 API 환경과 동일한 설정
    • 운영 환경과 동일한 테스트 환경을 구축하여, 실제 기능을 정확히 검증.
  2. 테스트 데이터 격리
    • 각 테스트가 독립적으로 실행되도록 데이터베이스 상호작용을 격리.
  3. 도메인 공유를 위한 문서화
    • 기능 검증뿐 아니라 팀원 간 도메인 지식 공유에 도움이 되는 테스트 코드 작성.
  4. 유연하고 간결한 테스트 코드
    • 유지보수와 작성이 쉬운 명확하고 간결한 테스트 코드 설계.

Testcontainers를 활용하여 실제 API 환경과 유사하게 만들기

Testcontainers는 도커 컨테이너를 사용하여 실제 운영 환경과 유사한 테스트 환경을 만드는 도구입니다. 이를 통해 실제 API 환경과 거의 동일한 조건에서 테스트를 수행할 수 있습니다.

1. MongoDB 컨테이너 설정

MongoDB의 프로덕션 버전과 동일한 설정을 TestContainer에서 구현합니다. 이는 실제 운영 환경에서의 데이터베이스와 동일한 조건을 제공하여, 데이터 관리와 테스트의 일관성을 보장합니다.

const mongoContainer = await new GenericContainer(EnumContainerConfig.MONGO_IMAGE_NAME)
  .withEnvironment({
    MONGO_INITDB_ROOT_USERNAME: EnumContainerConfig.MONGO_INITDB_ROOT_USERNAME,
    MONGO_INITDB_ROOT_PASSWORD: EnumContainerConfig.MONGO_INITDB_ROOT_PASSWORD,
    MONGO_INITDB_DATABASE: EnumContainerConfig.MONGO_DB_NAME,
  })
  .withExposedPorts(27017)
  .withCommand(['--bind_ip', '0.0.0.0'])
  .withNetworkMode(network.getName())
  .withWaitStrategy(Wait.forLogMessage(EnumContainerConfig.MONGO_COMPLETED_LOG_MESSAGE))
  .start();

2. API 서버 컨테이너 설정

실제 API 서버 이미지를 TestContainer를 통해 만들고, 위에서 설정한 MongoDB 컨테이너와 연결하여 실행합니다. 이 과정은 API 서버가 실제 데이터베이스와 동일한 방식으로 데이터를 처리하고 응답할 수 있도록 합니다.

const apiContainer = await new GenericContainer(EnumContainerConfig.API_IMAGE_NAME)
  .withExposedPorts(EnumContainerConfig.API_PORT)
  .withEnvironment({
    MONGODB_URI: `mongodb://${EnumContainerConfig.MONGO_INITDB_ROOT_USERNAME}:${
      EnumContainerConfig.MONGO_INITDB_ROOT_PASSWORD
    }@host.docker.internal:${mongoContainer.getMappedPort(27017)}/${
      EnumContainerConfig.MONGO_DB_NAME
    }?authSource=admin`,
  })
  .withNetworkMode(network.getName())
  .withWaitStrategy(Wait.forLogMessage(EnumContainerConfig.API_COMPLETED_LOG_MESSAGE))
  .start();

3. 테스트 환경 설정

만들어진 API 컨테이너를 테스트 실행 전 실행시키고, 각 테스트 후 데이터베이스 클린업을 수행하여 테스트 독립성을 보장합니다.

// 테스트 환경 설정
beforeAll(async () => {
	// MongoDB 컨테이너 생성 및 시작
  const mongoContainer = await new GenericContainer(EnumContainerConfig.MONGO_IMAGE_NAME).start();

  // API 컨테이너 생성 및 시작
  const apiContainer = await new GenericContainer(EnumContainerConfig.API_IMAGE_NAME).start();

	...
}, 5 * 60 * 1000);

afterEach(async () => {
	// 각 테스트 후에 데이터베이스 클린업 실행
  await container.resolve(MongoDatabase).deleteAllCollections();
});

// 모든 테스트 후에 리소스 정리
afterAll(async () => {
  const mongoClient = container.resolve(MongoDatabase);
  const mongoContainer = container.resolve<StartedTestContainer>(EnumContainer.MONGO_CONTAINER);
  const apiContainer = container.resolve<StartedTestContainer>(EnumContainer.API_CONTAINER);

  await mongoClient.disconnect();
  await mongoContainer.stop();
  await apiContainer.stop();
});

유지보수 하기 쉬고 가독성 좋은 테스트 코드 만들기

테스트 코드는 프로젝트의 중요한 자산입니다. 하지만 잘 관리되지 않는 테스트 코드는 오히려 개발 프로세스의 부담을 증가시키는 요인이 됩니다. 이를 해결하기 위해, 의존성 주입을 활용하여 테스트 코드를 모듈화하여 유지보수하기 쉽게 구성했습니다.

의존성 주입 라이브러리는 Tsyringe를 사용했습니다.

테스트 예시: 오프라인 "2시간 패스" 티켓 상품 결제

// Test 템플릿
test('오프라인 "2시간 패스" 티켓 상품을 결제 시 선수금 매출이 생성됩니다.', async () => {
  // Given 준비 구간

  // When 실행 구간

  // Then 검증 구간
}

1. 준비 구간

지점 정보와 판매할 티켓 정보가 데이터베이스에 사전 등록되어 있어야 합니다.

test('오프라인 "2시간 패스" 티켓 상품을 결제 시 선수금 매출이 생성됩니다.', async () => {
  // Given 준비절
  const mongoClient = new MongoClient(...)
  const park1 = {
    _id: new ObjectId('...'),
	   ...
  };
  const offlineTicket = {
	  _id: new ObjectId(...),
	  ...
  }
  await mongoClient.getCollection("parks").insert(park1);
  await mongoClient.getCollection("tickets").insert(offlineTicket);

  // When 실행절

  // Then 검증절
}

이 코드 접근 방식은 다음과 같은 문제를 초래할 수 있습니다.

  1. 직접적인 데이터베이스 접근으로 인해 코드 길이가 길어지고, 추상화 수준이 낮습니다.
  2. 여러 테스트에서 중복된 데이터 설정 코드가 많이 발생할 수 있습니다.
  3. DB 변경 시 많은 테스트 코드를 수정해야 합니다.

이를 개선하기 위해서는 데이터 관리 로직을 분리하고, repository 객체로 분리하여 데이터 접근을 추상화할 수 있습니다.

// 지점Repository
import { ObjectId } from 'mongodb';
import { inject, singleton } from 'tsyringe';

import { MongoDatabase } from '../db/mongo-database';

@singleton()
export class ParkRepository {
  private static COLLECTION_NAME = '...';

  // 의존성 주입
  constructor(@inject(MongoDatabase) private readonly _mongoClient: MongoDatabase) {}

  private async _getCollection() {
    return await this._mongoClient.getCollection(ParkRepository.COLLECTION_NAME);
  }

	// 테스트에 필요한 메소드 정의
  async insert_오프라인_티켓_판매하는_지점_정보() {
	  ...
  }
}
test('오프라인 "2시간 패스" 티켓 상품을 결제 시 선수금 매출이 생성됩니다.', async () => {
  // Given 준비절
  const PARK_REPOSITORY = container.resolve(ParkRepository);
  const TICKET_REPOSITORY = container.resolve(TicketRepository);

  await PARK_REPOSITORY.insert_오프라인_티켓_판매하는_지점_정보();
  await TICKET_REPOSITORY.insert_2시간_패스();

  // When 실행절

  // Then 검증절
});

위와 같이 작성하여 다음과 같은 이점을 얻을 수 있습니다.

  1. 데이터 설정 로직을 재사용 가능한 repository로 분리했습니다.
  2. 메소드 추출를 통해 추상화하여 코드 가독성을 올릴 수 있습니다.
  3. DB 구조 변경 및 Repository 교체 시 Repository만 수정하면 되므로, 테스트 코드 수정이 최소화됩니다.

2. 실행구간

실행 구간에서는 실제 API 호출을 통해 비즈니스 로직을 테스트합니다. 준비구간과 동일하게 GraphQL API의 경우 resolver를 통해 처리합니다. (Rest API 같은 경우 controller)

GraphQL API Clienet는 GraphQL Codegen을 활용하였습니다.

export class OrderResolver {
  // 의존성 주입
  constructor(@inject(GraphqlClient) private readonly graphqlClient: GraphqlClient) {}

  async createOrder(variables: OrderCreateOneMutationVariables) {
    return this.graphqlClient.execute(graphql(주문_생성_MUTATION), variables);
  }
}
test('오프라인 "2시간 패스" 티켓 상품을 결제 시 선수금 매출이 생성됩니다.', async () => {
  // Given 준비절
  ...

  // When 실행절
	const ORDER_RESOLVER = container.resolve(OrderResolver);
	const PAYMENT_RESOLVER = container.resolve(PaymentResolver);

	await ORDER_RESOLVER.주문생성();
  await PAYMENT_RESOLVER.결제하기();

  // Then 검증절
})

3. 검증구간

검증구간에서는 위에 API가 호출 시 비즈니스 로직에 맞는 기대 결과를 검증합니다.

test('오프라인 "2시간 패스" 티켓 상품을 결제 시 선수금 매출이 생성됩니다.', async () => {
  // Given 준비절
  ...

  // When 실행절
	...

  // Then 검증절
  const 매출_결과 = await 매출_REPOSITORY.find(...);

  expect(매출_결과.선수금_생성여부).toBeTruthy();
  ....
})
test-result.png

테스트 코드 작성 규칙

1. 의존성 주입된 객체는 명확하게 작성합니다

테스트 코드에서 의존성 주입 라이브러리(tsyringe)로 받은 구현체는 명확하게 대문자로 표기하여 가독성을 높입니다.

test('...', () => {
  const PARK_REPOSITORY = container.resolve(ParkRepository);
  const ORDER_RESOLVER = container.resolve(OrderResolver);
  const GATE_CONTROLLER = container.resolve(GateController);
});

2. Given, When, Then 패턴으로 테스트 구분

테스트 코드는 Given(준비), When(실행), Then(검증) 패턴으로 구분하여 작성하면, 논리적 흐름을 쉽게 이해할 수 있습니다. 이 패턴은 테스트가 무엇을 준비하고, 어떤 행동을 실행하며, 어떤 결과를 검증하는지를 명확히 드러냅니다.

test('...', () => {
  // Given 준비 구간
  ...

  // When 실행 구간
  ...

  // Then 검증 구간
  ...
});

3. 테스트 설명은 구체적인 비즈니스 로직을 반영

테스트의 목적을 명확히 하기 위해, 테스트 설명에는 구체적인 비즈니스 로직을 반영합니다. 단순한 "매출 확인"보다는, 어떤 이벤트에서 어떤 결과를 기대하는지 명시해주는 것이 더 좋습니다.

// Bad
test('티켓 결제 후 매출을 확인합니다.');

// Better
test('티켓 결제 시 선수금 매출이 생성됩니다.');

4. 테스트는 검증하려는 API 호출을 명확히 작성

테스트에서 검증해야 할 API는 구체적으로 명시해줍니다. 이렇게 하면 테스트가 실제로 무엇을 검증하고자 하는지 명확히 파악할 수 있습니다.

await PAYMENT_RESOLVER.addPayment({
  record: {
    amount: TicketRepository.TWO_HOUR_PASS_TICKET_PRICE,
    status: EnumPaymentStatus.Paid,
    ...
  },
});

5. 테스트 구간에서 DB나 API 직접 호출 금지

테스트 구간에서는 직접적으로 데이터베이스나 API를 호출하지 않고, Repository, Resolver, Controller 객체를 통해 작업을 수행합니다.

test("...", () => {
	// Bad
	await mongoClient.getCollection("tickets").insert(...);
	await axios.get(...);
	await graphqlClient.query(...)

	// Better
	await TICKET_REPOSITORY.insert(...);
	await GATE_CONTROLLER.insert(...);
	await TICKET_RESOLVER.find..(...);
})

6. 실제 클라이언트에서 사용하는 API 케이스만 테스트

테스트는 실제로 사용되는 API 케이스에만 집중합니다. 모든 가능성을 테스트하기보다는, 실제 클라이언트가 호출하는 API와 시나리오를 테스트해 유지보수 비용을 줄일 수 있습니다.

마치며

통합 테스트 환경을 구축하고 API 테스트를 자동화하면서, 기능 추가와 수정 시 더 안정적으로 작업할 수 있게 되었습니다. 또한 테스트 코드는 프로젝트의 기능과 동작을 명확하게 파악할 수 있게 해주었으며, 이를 통해 팀원 간 도메인 지식도 효과적으로 공유할 수 있었습니다.

#test
#typescript
avatar
By. 정규재안녕하세요. 잘 부탁 드립니다.