[Nodejs] testcontainers + jest로 유닛테스트 작성하기

toto9602·2024년 7월 14일

단위 테스트를 작성할 때, 데이터베이스에 접근하는 Repository 계층은 개인적으로 항상 고민이 되던 부분 중 하나였습니다.

Repository 객체를 모킹하여 해결할 수도 있지만, 개인적으로는 고전파 스타일의 테스트 작성을 지향해서 ^^; 모킹을 줄이고 싶었고,
최근에는 제 기준, 이를 해결해 준 멋진 해결책 중 하나인 testcontainers 라이브러리를 열심히 사용해 보고 있습니다!

본 포스팅에서는 Jest + testcontainers 라이브러리를 활용하여, 단위 테스트를 작성하는 간단한 과정을 기록해 보려 합니다!

잘못된 내용에 대한 피드백은 언제나 감사드립니다! (_ _)

참고 자료

https://testcontainers.com/

예제 코드는 아래와 같은 기술 스택을 사용하여 작성하였습니다!

  • Nest js
  • TypeORM
  • PostgreSQL

0. testcontainers 란 ?

testcontainers For Node js 공식 문서

Testcontainers is a library that supports tests, providing lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container.

→ 도커 컨테이너에서 실행될 수 있는, 데이터베이스 및 여러 경량화된 인스턴스를 제공하는
테스트를 위한 라이브러리!

1. 라이브러리 설치

저는 testcontainers 라이브러리로 PostgreSQL 컨테이너를 실행할 예정이므로, 아래 라이브러리를 설치해 주었습니다! :)

npm install @testcontainers/postgresql

2. 테스트 세팅 시작!

  • #1 의 내용은 Jest의 Lifecycle Hook을 기준으로 작성하였습니다 :)
  • 이하의 코드는 TestService 라는 임의의 클래스의 로직을 테스트함을 가정합니다!

2-0. timeout 늘려주기

Container를 실행하고, 연결을 설정하는 등의 과정에는 시간이 다소 소요되므로,
우선 jest의 기본 timeout인 5000ms를 넉넉하게 늘려줍니다! :)


jest.setTimeout(20000); // 20000ms

describe("TestService", () => {
	// 테스트 작성은 후술하겠습니다 :) 
});

2-1. beforeAll -> DB Container를 실행하기

모든 테스트가 시작하기 전 실행되는, beforeAll 구문에서,
오늘의 주인공인 testcontainers 를 활용하여 테스트에 활용할 PostgreSQL Container를 실행해 줍시다!

[ test.service.spec.ts ]

import { PostgreSqlContainer, StartedPostgreSqlContainer } from "@testcontainers/postgresql";

describe("TestService", () => {
  	let postgresContainer: StartedPostgreSqlContainer;
  
	beforeAll(async () => {
      // container를 실행합니다!
      postgresContainer = await new PostgreSqlContainer().start();
    	}
    });
});

2-2. beforeEach -> 실행한 Container로 Nest js 모듈 세팅하기

2-1에서 실행한 Container를 바탕으로, 테스트에 사용할 TestingModule을 정의하고, Service를 가져와 보겠습니다!

[ test.service.spec ]

describe("TestService", () => {
  let service:TestService;
  
  let postgresContainer: StartedPostgreSqlContainer;
  
	beforeAll(async () => {
      // container를 실행합니다!
      postgresContainer = await new PostgreSqlContainer().start();
    	}
    });
  
  	beforeEach(async () => {
      const module = await Test.createTestingModule({
        	imports:[
              // testcontainers로 실행한 container의 연결 정보를 넣어줍니다!
            	TypeOrmModule.forRoot({
                  type: "postgres",
                  host: postgresContainer.getHost(),
                  port: postgresContainer.getPort(),
                  username: postgresContainer.getUsername(),
                  password: postgresContainer.getPassword(),
                  database: postgresContainer.getDatabase(),
                  entities:[TestEntity], // 해당 테스트에서 사용할 Entity를 넣어줍니다!
                  synchronize: true, 
                  // synchronize 옵션을 켜서, 실행한 postgresContainer에 Entity 정보들이 모두 반영되도록 해줍니다 :) 
                })
              ],
        providers:[TestService]
      }).compile();
      
    service = module.get<TestService>(TestService);
    	});
	}

실행해 보면...?

실행을 위해, 간단한 유닛 테스트 하나를 추가해 보겠습니다!

describe("TestService", () => {
  let service:TestService;
  
  let postgresContainer: StartedPostgreSqlContainer;
  
	beforeAll(async () => {
     // 위와 동일합니다! :) 
    });
  
  	beforeEach(async () => {
      // 역시 위와 동일합니다! :) 
    	});
  
  it("should be defined", () => {
  	expect(service).toBeDefined();
  });
}

→ Docker Desktop 등을 통해 확인해 보면, 실행 과정에서 임의의 이름으로 도커 Container가 실행되고, 테스트가 종료됨과 함께 종료되는 부분을 확인할 수 있습니다!

그런데...

위 코드를 실행하면, 아래와 같은 에러가 발생합니다..!
jest did not exit one second after the test run has completed..

-> container ( DB )와의 연결을 정상적으로 종료해 주지 않아 발생하는 문제!!

2-3. afterAll -> Connection과 container를 종료하기

테스트를 정상적으로 종료하기 위해, 아래 부분을 추가해 줍니다!

import { DataSource } from "typeorm"
import { getDataSourceToken } from "@nestjs/typeorm"

describe("TestService", () => {
	...
    let dataSource:DataSource; 
	...
    
    beforeEach(async () => {
      // 위와 동일하게 Module을 compile합니다. 
    	const module = await Test.createTestingModule({
        	imports:[
            	TypeOrmModule.forRoot({
                  type: "postgres",
                  host: postgresContainer.getHost(),
                  port: postgresContainer.getPort(),
                  username: postgresContainer.getUsername(),
                  password: postgresContainer.getPassword(),
                  database: postgresContainer.getDatabase(),
                  entities:[TestEntity],
                  synchronize: true, 
                })
              ],
        providers:[TestService]
      }).compile();
      
      service = module.get<TestService>(TestService);
      
      // 이 부분을 추가! => DataSource 객체를 가져옵니다. 
      dataSource = module.get<DataSource>(getDatasourceToken());
      
      
    })

	afterAll(async() => {
      // DB와의 연결을 종료
      await dataSource.destroy();
      // testcontainers로 실행했던 컨테이너를 중지!
      await postgresContainer.stop();
    })

→ 테스트가 깔끔하게 종료됩니다 :)

3. 테스트 작성하기

이제, DB와 실제 상호작용하듯 테스트를 작성할 수 있습니다!

describe("TestService", () => {
  let service:TestService;
  let dataSource:DataSource;
  
  beforeAll(async () => {
  // 컨테이너 실행!
  });
  
  beforeEach(async () => {
  // 모듈 compile
  // Service와 DataSource 조회 
  });
  
  afterAll(async () => {
  // db와의 연결 종료
  // testcontainers로 실행한 컨테이너 중지
  })
  
  it("저장된 모든 Test의 정보를 조회해야 합니다.", async () => {
  	// given
    // given 조건에 테스트 데이터를 저장해 줍니다.
    await datasource.getRepository(TestEntity).save({
      title:"테스트를 위한 테스트 데이터"
    });
  
  	// when
    // service에서, 전체 데이터를 조회하는 임의의 로직을 실행
    const result = await service.getAllTests();
  
  	// then 
    // 조회한 데이터가 1개인지를 확인합니다 :) 
    expect(result).toHaveLength(1);
  })
}

그런데....22

이렇듯 실제 DB와 상호작용하듯 테스트를 작성하다 보면,
위 테스트에서 test라는 데이터가 insert된 것처럼, 개별 테스트마다 DB의 상태가 달라질 수 있습니다.

이렇게 되면, 한 테스트가 다른 테스트에 의도치 않은 영향을 줄 수 있으므로..!
매 테스트마다 DB의 모든 데이터를 삭제해 주는 로직이 필요할 것 같습니다 :)

3-1. afterEach -> DB의 모든 데이터 삭제하기

describe("TestService", () => {
	...
    let dataSource:DataSource;
	...
    
    afterEach(async () => {
      const allEntities = dataSource.createEntityManager().connection.entityMetadatas;
      	
      // 전체 테이블 이름을 조회하고 , 로 join합니다. 
      const tableNames = entities.map((entity) => `"${entity.tableName}"`),join(", ");
      
      // 모든 테이블을 truncate하고, identity를 재시작해 줍니다 :) 
      await dataSource.query(`TRUNCATE TABLE ${tableNames} RESTART IDENTITY CASCADE;`);
    	})
})

4. 전체 예제 코드

[ test.service.spec.ts ]


describe("TestService", () => {
	let service:TestService;
	let dataSource: DataSource;
  
  	let postgresContainer: StartedPostgreSqlContainer;
  
  	beforeAll(async () => {
      postgresContainer = await new PostgreSqlContainer().start();
    	}
    });
  
      beforeEach(async () => {
    	const module = await Test.createTestingModule({
        	imports:[
            	TypeOrmModule.forRoot({
                  type: "postgres",
                  host: postgresContainer.getHost(),
                  port: postgresContainer.getPort(),
                  username: postgresContainer.getUsername(),
                  password: postgresContainer.getPassword(),
                  database: postgresContainer.getDatabase(),
                  entities:[TestEntity],
                  synchronize: true, 
                })
              ],
        providers:[TestService]
      }).compile();
      
      service = module.get<TestService>(TestService);
      
      dataSource = module.get<DataSource>(getDatasourceToken());
    });
  
      afterEach(async () => {
      const allEntities = dataSource.createEntityManager().connection.entityMetadatas;
      	
      // 전체 테이블 이름을 조회하고 , 로 join합니다. 
      const tableNames = entities.map((entity) => `"${entity.tableName}"`),join(", ");
      
      // 모든 테이블을 truncate하고, identity를 재시작해 줍니다 :) 
      await dataSource.query(`TRUNCATE TABLE ${tableNames} RESTART IDENTITY CASCADE;`);
    	});
  
  	afterAll(async() => {
      // DB와의 연결을 종료
      await dataSource.destroy();
      // testcontainers로 실행했던 컨테이너를 중지!
      await postgresContainer.stop();
    });
  
  	it("should be defined", () => {
    	expect(service).toBeDefined();
    });
})
profile
주니어 백엔드 개발자입니다! 조용한 시간에 읽고 쓰는 것을 좋아합니다 :)

0개의 댓글