11/01/2023

E2E 테스트

발단

지겹고 지루한 단위 테스트를 끝내고 E2E 테스트를 하려고 인증 E2E 테스트 파일을 만든 다음 회원가입 API에서 이름이 정규식과 맞지 않을 경우 예외를 던지는 테스트를 작성하고 실행했는데 회원가입이 성공적으로 수행되었고 데이터베이스에 사용자가 저장되었다... 응?! 즉, 유효성 검증 데코레이터가 작동하지 않았다! main.ts 파일에 전역으로 파이프를 설정했는데 왜 이러지? 이에 대한 답은 NestJS 공식 문서에 직접적으로 나와있지 않기에 행간을 읽어야 했다.


힌트

NestJS 공식 문서의 FUNDAMENTALS의 Testing의 Testing facilities을 보면 createTestingModule() 메서드에 대해 설명한다. 이 메서드는 모듈 메타데이터 객체(일반적으로 @Module() 데코레이터에 전달하는 객체와 동일)를 인자로 취하고 TestingModule 인스턴스를 반환한다. 이 인스턴스가 가지는 여러 메서드 중 compile() 메서드는 모듈을 의존성과 함께 부트스트랩하고 테스트할 준비가 된 모듈을 반환한다. 이는 일반적인 main.ts 파일에서 NestFactory.create()를 사용하여 애플케이션을 부트스트랩하는 방식과 유사하다. 이 부분이 가장 중요하다!! 다음으로 End-to-end testing을 보면 createNestApplication() 메서드를 사용하여 완전한 NestJS 런타임 환경을 인스턴스화 한다. 즉, 개발 환경의 애플리케이션 부스트스랩 방식과 E2E 테스트 환경의 애플리케이션 부스트스랩 방식의 차이 때문에 유효성 검증 데코레이터가 작동하지 않는 것이다! 


main.ts

개발 환경을 구체적으로 살펴보면 사용자, 주문, 상품, 장바구니와 같은 여러 모듈을 애플리케이션 모듈에 가져오고(import) 애플리케이션 모듈을 main.ts 파일에 가져온 다음 부트스트랩 함수는 애플리케이션 모듈에서 새로운 NestJS 애플리케이션을 생성한 다음 여러 설정(쿠키 파서, 파이프, 인터셉터, CORS, 미들웨어 등)을 전역으로 적용하고 주어진 포트에서 트래픽을 수신한 다음 호출된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 애플리케이션을 생성하고 여러 설정을 전역으로 적용한 후 포트를 열고 트래픽을 수신한다.
// 여기서는 CORS, 파이프, 쿠키 파서를 설정한다.
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
 
  const corsOptions: CorsOptions = {
    origin:
      process.env.NODE_ENV === 'production'
        ? false
        : ['http://127.0.0.1:3000''http://localhost:3000'],
    credentials: true,
    methods: ['GET''POST''PATCH''DELETE'],
  };
 
  app.enableCors(corsOptions);
 
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      skipMissingProperties: true,
    }),
  );
 
  app.use(cookieParser());
 
  const configService = app.get(ConfigService);
 
  await app.listen(configService.get<string>('PORT'|| 3000);
}
// 부트스트랩 함수 호출!
bootstrap();
cs


e2e-spec.ts

그렇다면 E2E 테스트 환경은 어떨까? 코드를 보면 답을 알 수 있다. 바로 E2E 테스트 환경은 main.ts 파일을 건너뛴다! 따라서, main.ts 파일 내의 어떤 코드도 실행되지 않는다. E2E 테스트는 앞서 언급한 createTestingModule() 메서드에서 애플리케이션 모듈을 가져와서 compile() 메서드를 호출한 다음 createNestApplication() 메서드를 호출해서 애플리케이션을 부트스트랩한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
describe('Authentication (e2e)', () => {
  let app: INestApplication;
 
  beforeEach(async () => {
    // AppModule 클래스에 존재하는 모든 모듈과 설정을 가져온다.
    // 따라서 main.ts 파일의 부트스트랩 함수의 설정은 무시된다.
    // compile() 메서드로 AppModule을 의존성과 함께 부트스트랩하고 모듈을 반환한다.
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();
 
    // NestJS 런타임 환경을 인스턴스화 한다.
    app = moduleFixture.createNestApplication();
    await app.init();
  });
 
  it('should throw BadRequestException with an invalid name', async () => {
    const createUserDto: CreateUserDto = {
      name'박선심',
      password: '1234Aa!@',
      email: 'sunshim631212@naver.com',
      phoneNumber: '010-1234-5678',
      birthday: new Date('1966-01-01'),
    };
 
    await request(app.getHttpServer())
      .post('/auth/signup')
      .send(createUserDto)
      .expect(400);
  });
 
  afterEach(() => app.close());
});
cs


app.module.ts

E2E 테스트는 main.ts 파일을 실행하지 않는다는 점은 이제 명확하다. 그러면 main.ts 파일에 존재하는 여러 설정을 전역으로 적용하려면 어떻게 해야 할까? 일단 설정이 전역이라는 점은 변함이 없어야 하며 E2E 테스트는 애플리케이션 모듈에 존재하는 설정과 다른 모듈을 가져온다. 따라서, 애플리케이션 모듈에서 설정을 전역으로 구현해야 한다. 다시 말해, 애플리케이션을 생성할 때 애플리케이션 모듈에서 설정을 전역으로 적용한다. main.ts 파일과 달리 app.module.ts 파일에 설정을 전역으로 명시하면 가드, 파이프, 인터셉터, 필터는 의존성 주입을 사용할 수 있다(NestJS 공식 문서의 가드, 파이프, 인서텝터, 필터를 보면 두 방식의 차이점을 서술한다.).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 애플리케이션 모듈
@Module({
  imports: [
    ...
  ],
  // main.ts에 설정하면 의존성 주입을 사용할 수 없다.
  providers: [
    {
      provide: APP_FILTER,
      useClass: AllExceptionFilter,
    },
    {
      // 파이프를 전역으로 설정한다. 
      provide: APP_PIPE,
      useValue: new ValidationPipe({
        whitelist: true,
        skipMissingProperties: true,
      }),
    },
  ],
})
export class AppModule {}
cs

테스트가 정상적으로 작동한다~


미들웨어

파이프가 정상적으로 작동하는 것을 확인했다. 그렇다면 미들웨어는 어떻게 설정해야 할까? main.ts 파일을 보면 cookieParser를 미들웨어로 등록한다. 앞서 말했듯이 E2E 테스트는 애플리케이션 모듈을 가져오기 때문에 파이프처럼 애플리케이션 모듈이 존재하는 app.module.ts 파일에 미들웨어를 등록해야 한다는 점을 바로 알 수 있다. NestJS 공식 문서의 OVERVIEW의 Middleware의 Applying middleware를 보면 "@Module() 데코레이터에서는 미들웨어를 설정할 위치가 없기 때문에 모듈 클래스의 configure() 메서드를 사용하여 미들웨어를 설정해야 하므로 미들웨어를 포함하는 모듈은 NestModule 인터페이스를 구현해야 한다." 라고 적혀있다. 또한, Global middleware에서 모든 등록된 라우트에 미들웨어를 바인딩하려면 INestApplication 인스턴스에서 제공되는 use() 메서드를 사용할 수 있지만 이 경우 전역 미들웨어에서 IoC 컨테이너에 접근할 수 없다. 클래스 미들웨어를 사용하고 AppModule(또는 다른 모듈)에서 .forRoutes('*')를 사용하여 이를 소비할 수 있다고 명시한다.

즉, app.module.ts 파일의 AppModule에 NestModule 인터페이스를 구현한다! configure() 메서드는 애플리케이션이 들어오는 트래픽을 수신하기 시작할 때 자동으로 호출되며 MiddlewareConsumer 클래스를 매개변수로 가지는데 이 클래스는 헬퍼 클래스로 미들웨어를 관리하는 데 사용되는 여러 내장 메서드를 가진다. apply() 메서드로 미들웨어를 지정하고 forRoutes('*)'를 사용하여 미들웨어를 전역으로 설정한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export class AppModule implements NestModule {
  corsOptions: CorsOptions = {
    origin:
      process.env.NODE_ENV === 'production'
        ? false
        : ['http://127.0.0.1:3000''http://localhost:3000'],
    credentials: true,
    methods: ['GET''POST''PATCH''DELETE'],
  };
 
  configure(consumer: MiddlewareConsumer) {
    // cookieParse(): 쿠키를 읽기 쉬운 객체 형태로 파싱하는 미들웨어.
    // cors(): CORS 설정 미들웨어.
    consumer.apply(cors(this.corsOptions), cookieParser()).forRoutes('*');
  }
}
cs

main.ts 파일에서 모든 설정을 명시한 것과 본질적으로 동등하지만 모든 설정이 애플리케이션 모듈을 사용하여 애플리케이션을 생성할 때마다 자동으로 수행된다.


데이터베이스 정리

단위 테스트와 달리 E2E 테스트는 데이터베이스를 사용하기 때문에 매번 테스트 시 테이블에 저장된 데이터를 처리하는 방법을 찾아야 한다. 일단 데이터베이스 설정 클래스에서 환경변수 값에 따라서 데이터베이스를 달리 선택하도록 옵션을 구성했다. 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Module({
  imports: [
    ...
    ConfigModule.forRoot({
      isGlobal: true,
      // 개발은 env.deveolopment, 테스트는 env.test 파일을 사용한다~
      envFilePath: `.env.${process.env.NODE_ENV}`,
      validationSchema: Joi.object({
        HOST: Joi.string().required(),
        PORT: Joi.number().required(),
        DB_TYPE: Joi.string().required(),
        DB_HOST: Joi.string().required(),
        DB_PORT: Joi.number().required(),
        DB_DATABASE: Joi.string().required(),
        DB_USERNAME: Joi.string().required(),
        DB_PASSWORD: Joi.string().required(),
        JWT_ACCESS_TOKEN_SECRET: Joi.string().required(),
        JWT_ACCESS_TOKEN_EXPIRATION: Joi.string().required(),
        JWT_REFRESH_TOKEN_SECRET: Joi.string().required(),
        JWT_REFRESH_TOKEN_EXPIRATION: Joi.string().required(),
      }),
    }),
    TypeOrmModule.forRootAsync({
      useClass: TypeOrmConfigService,
    }),
  ],
  ...
})
cs

그리고 이제 데이터베이스를 정리해야 하는데 검색을 해보니 clear() 메서드로 테이블의 데이터를 모두 삭제하라는 내용을 찾았다. 하지만 바로 밑에 댓글을 보니 외래키가 설정된 테이블의 경우 오류가 발생한다는 지적이 있었다. 이 때문에 SET_FOREIGN_KEY_CHECKS를 매 테스트 시작 전 비활성화하고 테스트 종료 후 활성화하는 방법을 생각했다.
1
2
3
4
5
6
7
8
9
10
11
12
afterEach(async () => {
  const dataSource = app.get(DataSource);
 
  await dataSource.manager.query('SET FOREIGN_KEY_CHECKS = 0');
 
  await dataSource.manager.clear(Cart);
  await dataSource.manager.clear(User);
 
  await dataSource.manager.query('SET FOREIGN_KEY_CHECKS = 1');
 
  await app.close();
});
cs

그런데 오류가 발생했다! 데이터베이스를 보니 장바구니랑 사용자는 다 비워져있는데 알고보니 다대다 관계를 가진 장바구니와 옵션 사이에 존재하는 중간 테이블이 지워지지 않아서 문제가 발생했다!! 

중간 테이블의 데이터가 그래도 존재~

그래서 데이터소스의 여러 메서드를 찾다가 delete() 메서드를 발견했고 이를 사용한 결과 모든 것이 정상적으로 작동했다~
1
2
3
4
5
6
7
8
9
10
afterEach(async () => {
  const dataSource = app.get(DataSource);
 
  await dataSource.manager.delete(OrderToOption, {});
  await dataSource.manager.delete(Order, {});
  await dataSource.manager.delete(Cart, {});
  await dataSource.manager.delete(User, {});
 
  app.close();
});
cs

E2E 테스트 끝~


테스트 시간

Jest 테스트 프레임워크는 동시에 여러 테스트를 실행하려고 하는데 TypeScript를 Jest와 함께 사용할 때 테스트를 병렬로 실행하면 성능이 실제로 훨씬 나쁘다고 한다. 따라서, 이 문제를 해결하기 위해 Jest가 테스트를 병렬로 실행하지 말고 하나씩만 실행하면 테스트가 훨씬 빨리 실행되기 때문에 --maxWorkers=1로 설정해서 하나의 테스트 파일만 실행하도록 했는데 아직도 뭔가 시간이 많이 걸리는 것 같다...
--maxWorkers=1


--detectOpenHandles vs. --forceExit

CI/CD 적용하는데 이상하게 CD에서 E2E 테스트가 통과는 다 하는데 종료 코드를 반환하지 않아서 삽질을 너무 했다. 그러니까 E2E 테스트 종료 후 무한 루프에 갇혔다... 검색을 통해서 이리 저리 시도했는데 문제를 해결되지 않았고 그러다가 갑자기 생각이 났다. 테스트 실행 스크립트에 추가한 --detectOpenHandles 옵션과 관련이 있나? 이전에 비동기 작업 때문에 테스트 완료 1초 후에도 Jest가 종료되지 않는 오류가 발생했는데 이를 --detectOpenHandles 옵션으로 해결했다. 찾아보니 강제종료가 필요한 경우에 사용하며 성능이 많이 저하된다고 한다. 그래서 이를 지우고 다시 E2E 테스트를 했는데 오류가 다시 나왔다.

나온 김에 그냥 오류의 원인이 먼지 찾아보기로 결정했다! 코드 상 문제는 없는 것 같아서 한동안 생각을 했는데 짚히는게 딱 하나 있었다. 바로 Redis가 추가된 것이다. 그래서 혹시 Redis랑 관련이 있나 검색을 해보니 그러하였다. 검색 결과 애플리케이션이 종료되면 Redis가 종료되어야 하는데 그렇지 않은 것이다! 이와 관련해서 검색을 했지만 만족스러운 대답을 얻을 수 없었다. 대신, 임시방편으로 --forceExit 옵션을 사용하라는 의견이 많았다. 일단 이렇게 가자~
1
2
3
4
5
6
7
8
9
10
{
  ...
  "scripts": {
    ...
    "test:e2e": "cross-env NODE_ENV=test jest --config ./test/jest-e2e.json --maxWorkers=1 --forceExit",
    "test:e2e:watch": "cross-env NODE_ENV=test jest --config ./test/jest-e2e.json --watch --maxWorkers=1",
    ...
  },
  ...
}
cs

종료 코드 0 반환!

update: 2024.01.23

댓글 없음:

댓글 쓰기