테스트 코드

[NestJS] 실제 DB와 연결해 Service Layer 테스트하기 + runInBand

후뿡이 2024. 3. 3. 20:50

이 글은 인프런 강의 박우빈 님의Practical Testing: 실용적인 테스트 가이드를 수강하고 참고하여 작성한 게시글입니다.

DB 접속 없이 Service Layer를 테스트하고 싶다면 이 글을 참고해 주세요.

 

※ 잘못된 점이 있거나 다른 의견을 가지신 분들 댓글로 내용 남겨 주세요 ! 언제나 환영입니다.

🐳 서론

지금 재직하는 회사에서는 테스트코드를 작성하지 않기 때문에 다른 NestJS를 사용하는 곳에서는 Service Layer 를 어떻게 테스트하는지 궁금했다. 그래서 NestJS 테스트코드 강의를 수강해 봤지만 시원하게 Service Layer를 테스트하는 방법을 알려주는 강의를 발견하지 못했다. 그래서 이미 테스트코드 시장이 많이 발전한 Java로 시선을 돌렸고 박우빈 님의 강의를 발견하게 됐다.

이 강의에서는 NestJS와 유사한 Spring boot의 Controller, Service, Persistence 레이어를 테스트하는 방법이 소개되어 있었다.

박우빈 님의 강의에서 아이디어를 얻어 NestJS에서도 실제 DB에 접속해 Service Layer를 테스트하는 과정을 소개하겠습니다.

 

아래의 예제로 사용하는 코드의 출처는 필자가 작업하고 있는 출석부 사이드 프로젝트에서 가지고 왔습니다.

궁금하신 분들은 여기에 소스코드가 있습니다

 

🐳 왜 실제 DB와 연결해 Service Layer를 테스트해야 할까?

공학도라면 기술의 장단점에 대해 먼저 고민해봐야 할 것이다. 그래야 언제 이 방법을 사용할지 보완해야 할 것은 무엇인지 알 수 있기 때문이다.

그렇다면 실제 DB와 접속해 Service Layer를 테스트할 경우 얻는 장단점은 무엇일까 ?

 

장점1. Persistence Layer 의 동작 또한 테스트가 가능하다.

TypeORM을 사용해 DB와 통신하는 모든 부분을 mocking해 테스트하게 되면 내가 원하는 결과가 나오는지 확인하기 어렵다.

하지만 실제 DB에 접속해 테스트하게 되면 Persistence Layer의 동작까지 테스트할 수 있게 된다.

 

장점2. 더 실전 같은 테스트

Service Layer만 따로 떼어서 테스트하는 것보다 Service Layer 와 Persistence Layer를 통합해 테스트하기 때문에 더 실전 같은 테스트가 가능하다.

하지만 두 레이어를 함께 테스트하기 때문에 Unit Test의 성향보다는 통합테스트의 성향을 띠게 된다.

 

단점1. 테스트가 DB에 대한 의존성이 생긴다.

이는 DB에 접속해 테스트를 하기 때문에 당연한 얘기일 것이다.

즉, 테스트코드의 문제가 아닌 외부의 요소로 인해 테스트가 실패할 수 있음을 의미하기도 한다.

 

단점2. 테스트를 위한 추가적인 DB 환경설정이 필요하다.

실제 DB에 접속해 테스트하기 때문에 테스트용 DB 환경 설정도 필요할 것이다.

각 테스트가 서로에게 영향을 주지 않게 하기 위해서 테스트 실행 전 후로 DB를 청소하는 작업들이 ( DELETE TABLE 등 ) 필요하다. 

그런데 혹여나 테스트를 개발/상용 DB에서 진행한다고 생각해 보자... 정말 끔찍한 일이 아닐 수 없다.

그렇기 때문에 추가적인 Test를 위한 DB 설정도 필요하게 된다.

( 필자는 이 부분을 Docker의 MySQL 이미지를 활용해 테스트 DB를 구축했다. )

 

단점3. 테스트 수행 속도가 느려지게 된다.

당연히 Mocking을 사용해 DB를 접속하지 않고 테스트할 때보다 테스트 실행 시간이 길어질 수밖에 없다.

 

 

이 외에도 더 많은 장단점들이 있겠지만 직접 사용해 보면서 느낀 장단점들은 위와 같다.

 

그렇다면 이제 실제 DB에 접속해 테스트하는 방법을 알아보자 !

 

🐳 TestingModule을 사용하여 DB에 접속하기

NestJS에는 Root module이 되는 app.module이 있다.

이와 비슷하게 test.module을 생성해 test용 module을 생성해 주자.

그리고 NestJS 의 테스트 기능 중 하나인 TestingModule을 이용해서 module을 실행시켜서 실제 DB에 접속해 보자

 

1. test.module.ts 구현하기

@Module({
  imports: [
    ConfigModule.forRoot({
      envFilePath: `.env.test`,
    }),
    TypeOrmModule.forRoot({
      ...getOrmConfig(),
      entities: [User, Attendance,UserAttendance],
    }),
    UsersModule,
    AuthModule,
    AttendancesModule,
  ],
})
export class TestModule {}

 

위 코드를 살펴보자 ! 

NestJS의 app.module.ts와 굉장히 유사한 코드를 가지고 있음을 알 수 있다.

먼저 환경변수를 .env.test 로 설정해 주었다. ( 이에 따라 getOrmConfig() 로 가져오는 ormConfig 값이 바뀌게 된다. )

또한 TypeOrmModule 설정을 통해 Entity와 사용할 Entity들을 지정하고 ormConfig에 지정된 DB로 접속하게 된다.

그리고 실제 repository를 사용하는 만큼 각각의 module의 의존성 또한 고민할 필요가 없다.

테스트하기 위한 실제 서버를 실행시키기 때문이다.

 

2. TestingModule 생성하기

describe('AttendancesService', () => {
  let module: TestingModule;
  let service: AttendancesService;
  let attendanceRepository: Repository<Attendance>;
  let userAttendanceRepository: Repository<UserAttendance>;
  let userRepository: Repository<User>;

  beforeAll(async () => {
    module = await Test.createTestingModule({
      imports: [
        TestModule,
        TypeOrmModule.forFeature([Attendance, UserAttendance, User]),
      ],
      providers: [AttendancesService],
    }).compile();

    service = module.get<AttendancesService>(AttendancesService);
    attendanceRepository = module.get(getRepositoryToken(Attendance));
    userAttendanceRepository = module.get(getRepositoryToken(UserAttendance));
    userRepository = module.get(getRepositoryToken(User));
  });

  beforeEach(async () => {
    await setupTest();
  });

  afterEach(async () => {
    // Delete tables after each test
    await clear();
  });

  afterAll(async () => {
    await module.close();
  });
  
  it('should be defined', () => {
    expect(service).toBeDefined();
  });
  
})

 

테스트 코드 세팅을 위한 코드는 위와 같습니다.

 

먼저 가장 중요한 beforeAll 코드를 살펴봅시다.

beforeAll(async () => {
    module = await Test.createTestingModule({
      imports: [
        TestModule,
        TypeOrmModule.forFeature([Attendance, UserAttendance, User]),
      ],
      providers: [AttendancesService],
    }).compile();

    service = module.get<AttendancesService>(AttendancesService);
    attendanceRepository = module.get(getRepositoryToken(Attendance));
    userAttendanceRepository = module.get(getRepositoryToken(UserAttendance));
    userRepository = module.get(getRepositoryToken(User));
  });

 

1 번 과정 ( test.module.ts ) 에서 생성한 TestModule을 import해서 의존성을 해결해 줍니다.

그다음 TypeOrmModule.forFeature를 사용하여 Attendance, UserAttendance, User 엔티티를 등록해 줍니다.

createTestingModule을 통해 생성될 module이 위의 세 Entity를 사용하기 때문입니다.

그런 후 providers를 통해 테스트할 서비스 ( AttendanceService ) 를 등록합니다.

 

그런 다음 createTestingModule을 통해 생성한 모듈에서 테스트를 위해 필요한 인스턴스들을 가지고 옵니다.

여기서는

service

attendanceRepository

userAttendanceRepository

userRepository

위의 네 가지 인스턴스를 사용합니다.

 

테스트 전처리 후처리 

그다음에는 테스트가 서로에게 영향을 주지 않게 하기 위해서 DB의 전처리 후처리를 해주어야 합니다.

  beforeEach(async () => {
    await setupTest();
  });

  afterEach(async () => {
    // Delete tables after each test
    await clear();
  });

 

위의 코드에서는 테스트 코드 실행 전과 후에 각각 setupTest() 와  clear() 를 실행하게 만들었습니다.

테스트코드 또한 하나의 문서이기 때문에 가독성을 위해 "테스트를 세팅한다." "테스트를 정리한다"라는 의미만 남기고 내용은 코드 가장 하단으로 분리했습니다.

setupTest의 내용은 필요한 user를 미리 database에 저장하는 코드입니다.

clear의 경우 테스트 후에 생성된 데이터를 삭제하기 위해서 UserAttendance,Attendance, User Entity를 모두 삭제하는 코드입니다.

이때 각 Entity간의 관계가 있기 때문에 Foreign Key 설정을 고려하여 삭제 순서를 정해야 합니다.

 

마지막으로 module 종료 코드입니다.

afterAll(async () => {
    await module.close();
  });

 

위의 코드를 통해서 실행한 module을 종료시켜줘야 합니다.

그렇지 않으면 테스트가 종료되었음에도 서버가 종료되지 않고 계속 남아있게 됩니다.

 

실행결과 확인

아래의 테스트 코드를 통해 service가 제대로 생성되었는지 확인해 봅시다.

it('should be defined', () => {
    expect(service).toBeDefined();
  });

 

 

제대로 service가 생성된 것을 확인할 수 있습니다.

그리고 console 창을 통해서 실제 DB에 접속하면서 여러 쿼리들을 날리는 것을 확인할 수 있습니다.

제대로 실제 DB에 접속하고 있는 것이 확인이 됩니다.

 

🐳 테스트코드를 작성하자

describe('createAttendance Test', () => {
    it('Attendance 테이블에 출석부를 생성한다.', async () => {
      // given
      const attendanceDto = createAttendanceDto('test title', 'test description', AttendanceType.WEEKDAY);

      const user = new User();
      user.id = 'user id 1';

      // when
      const createdAttendanceId = await service.create(attendanceDto, user);

      const newAttendance = await service.findOneById(createdAttendanceId.id);

      // then
      expect(newAttendance.title).toBe('test title');
      expect(newAttendance.description).toBe('test description');
      expect(newAttendance.type).toBe(AttendanceType.WEEKDAY);
    });

    it('UserAttendance 테이블에 MASTER 권한으로 데이터가 생성된다.', async () => {
      // given
      const attendanceDto = createAttendanceDto('test title 1', 'test description', AttendanceType.WEEKDAY);

      const user = new User();
      user.id = 'user id 1';

      // when
      const createdAttendanceId = await service.create(attendanceDto, user);

      const userAttendance = await userAttendanceRepository.findBy({
        userId: 'user id 1',
      });

      // then
      expect(userAttendance[0].role).toBe(RoleType.MASTER);
      expect(userAttendance[0].attendanceId).toBe(createdAttendanceId.id);
    });
  });

 

위의 코드는 출석부를 생성하는 코드이다.

* 배경 설명

출석부 : 회원 = N:M 관계이기 때문에 JoinTable인 UserAttendance가 있다. 그리고 이 UserAttendance JoinTable에 Role이 존재한다.

 

실행결과도 살펴보자.

 

아주 깔끔하게 성공한 것을 볼 수 있다.

 

실제 DB와 연결해 테스트를 진행하기 때문에 여러 쿼리들이 실행되고 있는 것을 확인할 수 있다 !

 

직접 DB와 연결해 Service Layer와 Persistence Layer의 동작을 확인해 볼 수 있었다 !

 

🐳 테스트 병렬 실행 문제 발생

이렇게 실제 Database와 연결한 후 작업을 진행하니 다른 문제가 발생했다.

 

하나의 테스트파일을 실행할 때는 괜찮았으나 전체테스트를 실행하면 실행 결과가 계속 바뀌는 것이다.

실패했던 테스트가 성공하고, 성공했던 테스트가 실패하는 현상이 계속 발생했다.

 

왜 그런지 log를 확인해 보니

duplicate key 오류가 발생하고 있었다.

 

문제의 원인은 Jest는 테스트성능을 위해 테스트를 병렬로 실행시키는데 병렬로 실행된 테스트코드가 서로에게 영향을 미치고 있었던 것이다.

한 개의 테스트코드 파일 내에서는 beforeEach , afterEach를 통해서 테스트 간의 영향을 없앴지만

여러 개의 테스트 파일 내에서의 동작은 격리시킬 수 없는 문제가 발생했다.

가령 user.service.spec.ts, attendance.service.spec.ts 가 병렬로 실행되면서 서로에게 영향을 미치고 있었던 것이다.

 

🐳 테스트 병렬 실행 문제 해결방안 ( runInBand )

이 문제를 해결하기 위해서 jest의 실행에 --runInBand 옵션을 추가해 주었다.

이 옵션은 테스트를 순차적으로 실행하도록 하는 옵션이다.

이 옵션을 사용하게 되면 여러 테스트를 격리시켜 실행이 가능해진다.

 

runInBand의 장점은 아래와 같습니다.

  • 테스트 간의 자원 충돌문제 해결 : 여러 테스트가 동일한 자원에 동시에 접근하는 문제를 해결할 수 있습니다.
  • 메모리 문제 해결 : 순차적으로 실행하므로 시스템의 메모리 부담을 줄일 수 있습니다.
  • 디버깅 : 순차적으로 실행되므로 어디서 문제가 발생하는지 확인하기가 쉬워집니다.

하지만 당연히 단점도 존재합니다.

바로 실행 속도가 느려진다는 단점이다. 병렬로 실행되던 테스트를 순차적으로 실행시키니 당연한 결과일 것입니다.

 

 

runInBand 옵션 사용 방법은 간단하다. 실행문 뒤에 --runInBand를 추가해 주면 된다.

package.json 파일로 들어가 "test" 실행 스크립트 뒤에 --runInBand 옵션을 추가해 줍시다 !

 

 

그런 후에 다시 한번 전체 테스트 코드를 실행해 봅시다.

 

위의 사진처럼 runInBand 옵션과 함께 전체 테스트를 실행시켰고

그 결과 병렬처리로 인해 발생하던 문제들이 해결 돼 모든 테스트 코드가 통과하는 것을 확인할 수 있었습니다.

 

🐳 결론

실제 DB에 접속해 테스트를 진행하는 것은 추가적인 작업들이 많이 필요하게 됩니다.

단일 테스트 파일 내에서도 테스트코드 간의 격리도 해주어야 하고

다수의 테스트 파일 간의 격리도 해주어야 합니다.

또한 테스트용 DB설정도 추가해 주어야 합니다.

 

하지만 이런 어려움에도 불구하고

실제 동작을 확인할 수 있고, Persistence Layer 또한 테스트할 수 있다는 점이 매력적으로 느껴집니다.

 

그래서 필자는 MockClass를 구현해 테스트를 하는 것보다

위의 방법처럼 실제 DB에 연결해 작업하는 것이 전체 서비스를 더 견고하게 만들어 준다고 생각했고 이후로는 위의 방법처럼 테스트 코드를 작성했습니다.