NestJS TypeORM E2E 테스트 환경 구축하기

이호영·2023년 6월 23일
2

테스트 환경 구축

목록 보기
1/2

TypeORM Integration

For integrating with SQL and NoSQL databases, Nest provides the @nestjs/typeorm package. Nest uses TypeORM because it's the most mature Object Relational Mapper (ORM) available for TypeScript. Since it's written in TypeScript, it integrates well with the Nest framework.

https://docs.nestjs.com/techniques/database#typeorm-integration

NestJS는 TypeORM 사용을 권장하고 있고 TypeScript 환경에서 ORM의 선택지는 한정적이라, 대부분 TypeORM을 사용하고 있을 것 입니다.

하지만, Github을 확인해보면 2016년 12월 0.0.2 버전이 릴리즈 된 이후 약 6년의 시간이 지났지만, 아직도 메이저 버전이 나오지 않았습니다. 그렇다 보니 완전한 지원이 되지 않는 경우도 있고, 마이너 버전이 1 올라가면서 엄청난 breaking change를 만드는 이슈도 있었습니다. (https://github.com/typeorm/typeorm/issues/8775 를 읽으면 속터진다..)

그렇다 보니 유닛테스트나 E2E테스트에서 DB에 연결하여 테스트를 하는 것이 좋겠다고 판단했습니다.

SQLite

테스트시 기존 환경에 영향을 주지 않고, 테스트 데이터를 빠르게 제거하기 위해 보통 인메모리DB를 이용해 테스트를 진행합니다. 이것 또한 ORM을 사용하는 장점이기 때문에 그래서 실제 환경과 다르지만 SQLite로 변경해서 테스트를 진행했습니다.

그런데 문제가 발생했습니다. ‘status’ 필드를 대부분 enum으로 생성하여 관리하고 있었는데, SQLite에서는 컬럼 타입이 enum을 지원하지 않는 다는 것이었습니다.

물론 해결 방법은 있었습니다. enum을 simple-enum으로 변경하고 json은 simple-json과 같이 변경하면 적어도 해당 컬럼 타입을 지원하지 않는다는 에러는 넘길 수 있습니다. 그럼에도 불구하고, SQLite에서 MySQL의 그대로를 지원하지 않다 보니, 복잡한 쿼리의 경우 테스트를 진행할 수 없었습니다.

테스트를 쉽게 진행하기 위해 변경과 포기해야하는 것들이 너무 많았기 때문에 변경할 수 없다고 판단했습니다.

Test helper

그 다음은 테스트 헬퍼를 제작하는 것 이었습니다. 테스트 헬퍼는 다음과 같은 역할을 하고 있었습니다.

  • 테스트 데이터 생성 및 저장
  • 생성한 테스트 데이터 삭제

테스트를 비교적 쉽게 작성하기 위해 테스트에서 사용할 데이터와 삭제를 헬퍼가 도와주고, 개발자는 테스트 코드 작성에만 집중할 수 있도록 했습니다.

하지만, 여기에도 큰 문제가 있었다. 테스트 케이스가 늘어갈 수록 테스트 헬퍼가 거대해지고, 로직이 변경되지 않았지만 DB의 변경사항이 생기면 테스트 헬퍼가 정상적으로 동작하지 않는 케이스가 종종 발생했습니다.

테스트 데이터를 삭제하기 위해 외래키로 관계되어 있는 모든 데이터를 제거해야 했는데, 이 순서도 중요했고 데이터들 중 테스트 데이터만 지워야 하기 때문에 어떠한 데이터들을 지울 것 인지도 중요했습니다.

그렇다 보니 어느샌가 테스트 헬퍼를 유지보수 하는 시간이 테스트 코드를 작성하는 시간보다 오히려 더 크게 들어가고 있었습니다.

그래서 어느샌가 점점 레포지토리를 Mocking하는 테스트 코드를 작성하기 시작했습니다. 그러다 보니, 레포지토리에서 문제가 발생하는 경우를 테스트하지 못하게 되었습니다.

테스트 환경 세팅

그래서 실제 데이터 베이스와 유사한 환경을 가지면서, 테스트 데이터를 삭제를 쉽게 할 수 있는 방안을 찾아보다가 도커를 이용해보기로 했습니다. 도커로 테스트 환경의 데이터베이스를 구성하고, 테스트가 종료되면 컨테이너를 종료해 손쉽게 테스트 데이터를 삭제 할 수 있도록 환경을 구성해볼 것 입니다.

테스트 코드 작성

먼저 환경을 테스트하기 위한 간단한 테스트를 작성합니다. 회원가입 API를 호출하는 테스트이며, 테스트 데이터가 정상적으로 등록, 삭제되는 지 확인하기 위해 유저의 정보는 랜덤한 값이 아닌, 상수로 지정하여 진행하도록 하겠습니다.

describe('/users', () => {
  it('/signup (POST)', () => {
    const user = { email: 'test_email@gmail.com', password: '12345678', name: '이호영' };
    return request(app.getHttpServer()).post('/users/signup').send(user).expect(201).expect({ email: user.email });
  });
});

유저 정보를 저장하는 ERD는 아래와 같습니다.

user 테이블에는 유저의 name, email등 유저에 관한 데이터가 저장되며, user_authentication 테이블에는 유저의 인증 수단에 대한 정보가 들어갈 것입니다. (ex. password, otp 등등..)

이 테스트를 진행하고 나면 DB 테이블에 name이 이호영 email이 test_email@gmail.com인 row가 user에 1개의 row가 저장되고, 비밀번호 12345678이 암호화 되어 user_authentication 테이블에 1개 row가 저장된다.

💡 만약 테스트가 종료된 후 테스트 데이터를 삭제하려면 user 테이블에서 생성된 유저를 찾고 해당 id를 가지고 user_authentication에 존재하는 데이터를 삭제 후 user를 삭제해야 합니다.
만약, user테이블을 참조하는 다른 테이블이 생긴다면, 회원가입에 대한 테스트 코드에서 유저가 정상적으로 삭제되지 않을 가능성이 있습니다.

Config 생성

테스트를 실행하게 되면, 기존 데이터베이스가 아닌 도커에서 실행중인 테스트용 데이터베이스에 연결되어야 합니다. 또한 테스트 실행시 테스트용 데이터베이스는 다른 옵션을 사용할 수 있으므로 Config을 분리해서 관리하겠습니다.

@Injectable()
export class MysqlConfig implements TypeOrmOptionsFactory {
  constructor(private readonly configService: ConfigService) {}

  createTypeOrmOptions(): TypeOrmModuleOptions {
    const { env } = this.configService.get('app');
    assert.ok(env !== 'test', 'Cannot use MysqlConfig in test environment');

    const config = this.configService.get('database');
    return {
      type: 'mysql',
      host: config.host,
      port: config.port,
      username: config.username,
      password: config.password,
      database: config.database,
      timezone: 'Z',
      logging: false,
      synchronize: false,
      keepConnectionAlive: true,
      autoLoadEntities: true,
      extra: {
        connectionLimit: config.mysqlPoolSize,
      },
    };
  }
}
@Injectable()
export class MysqlTestConfig implements TypeOrmOptionsFactory {
  constructor(private readonly configService: ConfigService) {}

  createTypeOrmOptions(): TypeOrmModuleOptions {
    const { env } = this.configService.get('app');
    assert.ok(env === 'test', 'Cannot use MysqlTestConfig in non-test environment');

    const config = this.configService.get('database');
    return {
      type: 'mysql',
      host: 'localhost',
      port: 3310,
      username: 'root',
      password: 'test1234!',
      database: 'test_database',
      timezone: 'Z',
      logging: false,
      synchronize: true,
      keepConnectionAlive: true,
      autoLoadEntities: true,
      extra: {
        connectionLimit: config.mysqlPoolSize,
      },
    };
  }
}

테스트 Config이나 기본 Config 모두 의도하지 않은 env에서는 동작하지 않도록 확인하는 로직을 추가했습니다. 서로 환경을 다르게 지정하여 실행했을 시 문제가 발생하는 것을 막기 위함입니다.

Config에서 중요한 부분은, 기본 Config은 synchronize 옵션을 키지 않지만 테스트 Config의 경우 synchronize 옵션을 켜고 사용합니다. 테스트용 DB는 테스트 실행시에 아무런 설정도 되어있지 않기 때문에 추가적인 작업을 하지 않기 위함이 있고, 테스트용 DB는 항상 코드의 Entity와 동기화하여 사용하도록 하기 위함이 있습니다.

Docker-compose 파일 작성

services:
  db:
    image: mysql/mysql-server:latest
    ports:
      - 3310:3306
    environment:
      - MYSQL_ROOT_HOST=%
      - MYSQL_ROOT_PASSWORD=test1234!
      - MYSQL_DATABASE=test_database

Docker로 데이터베이스를 생성하고 나면, 기본적으로 Root 접속 권한이 제한되어 있습니다. 테스트 DB의 경우 권한을 나누어 유저를 생성할 필요가 없기 때문에 Root 접속 권한을 누구나 가능하도록 설정합니다. 또한 데이터베이스를 저장할 필요가 없기 때문에 volume또한 지정하지 않습니다.

package.json 스크립트 추가

{
	...
	"test:e2e": "NODE_ENV=test jest --config ./test/jest-e2e.json", // Only linux or mac
	"test:docker:up": "docker-compose -f docker-compose.test.yml up -d",
	"test:docker:down": "docker-compose -f docker-compose.test.yml  down"
}

테스트를 위한 docker를 쉽게 띄우고 내릴 수 있도록 package.json에 명령어를 추가해 줍니다.

이제 테스트를 실행해보겠습니다. 먼저 테스트 도커를 띄웁니다.

그 다음 테스트를 진행합니다.

테스트가 종료되면 도커를 종료하면 끝입니다.

도커를 종료하면 DB가 아예 삭제되니 테스트 종료 후 남은 데이터들을 삭제할 필요가 없습니다.

도커를 껏다 키지 않았을 때, 회원가입 테스트에서 동일한 이메일로 가입하려고 하였으나, 이미 이전 테스트에서 회원가입이 이루어져 데이터베이스에 저장되었기 때문에 첫번째 이후의 테스트는 실패하고 있습니다.

Docker를 재실행하여 테스트를 진행하면 이전 테스트 데이터가 삭제되었기 때문에 테스트가 정상적으로 돌아가는 것을 확인할 수 있습니다.

profile
안녕하세요!

0개의 댓글