jeudi 24 décembre 2020

NestJS E2E tests don't clean up main database connection after closing

Goal

When a NestJS E2E test stops, do not display the following message:

Jest did not exit one second after the test run has completed.

This usually means that there are asynchronous operations that weren't stopped in your tests. Consider running Jest with `--detectOpenHandles` to troubleshoot this issue.

Question

How can I make sure that both the Main Database Connection and the E2E Database connection are both closed after an E2E test run?

I've searched other similar StackOverflow questions and I couldn't find an answer that solved my problem, hence I opened my own question.

See the analysis below for how I came to the conclusion that answering this question will fulfill my goal.

Cause Analysis

I've done some experimentation and I believe I've narrowed down the cause, but I'm unsure as to how to fix it. I believe this is caused by NestJS not closing the main database connection in a NestJS E2E Test.

Project Setup

Here's my Database module setup code:

export const getConfig = () => TypeOrmModule.forRootAsync({
  imports: [ConfigModule],
  useFactory: async (configService: ConfigService) => {
    console.log('starting database...', configService.get<number>('db.port'));
    return ({
      type: 'mysql',
      host: configService.get<string>('db.host'),
      port: configService.get<number>('db.port'),
      username: configService.get<string>('db.username'),
      password: configService.get<string>('db.password'),
      database: configService.get<string>('db.database'),
      autoLoadEntities: true,
      namingStrategy: new SnakeNamingStrategy(),
    });
  },
  inject: [ConfigService],
});

app.module.ts:

import { Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';

import { getConfig as getAppConfig } from './config/module-config';
import { getConfig as getDatabaseConfig } from './database/module-config';
import { HealthController } from './health/health.controller';

@Module({
  imports: [
    getAppConfig(),
    TerminusModule,
    getDatabaseConfig(),
  ],
  controllers: [HealthController],
})
export class AppModule {}

main.ts

import { INestApplication } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import * as helmet from 'helmet';
import { AppModule } from './app.module';

/**
 * Applies common app config to both the main application and e2e test app config.
 * It's important that both the main app and the e2e apps are configured the same
 * @param app Nest application to apply configuration to
 */
export const applyAppConfig = (app : INestApplication) => {
  app.setGlobalPrefix('api');
  app.use(helmet());
};

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  applyAppConfig(app);
  app.enableShutdownHooks();

  const port = process.env.PORT || 0;
  if (process.env.NODE_ENV !== 'test') {
    await app.listen(port);
  }

  console.info(`App running on: localhost:${port}`);
}
bootstrap();

A utils.ts file that hosts some functions used in setting up e2e tests:

export const getTestConfig = () =>
  ConfigModule.forRoot({
    isGlobal: true,
    load: [testConfiguration],
  });

/**
 * Creates a test app for e2e tests that has the same configuration as the main application
 * @param {TestingModule} moduleRef A list of testing modules to inject into the app, so each e2e test injects only the dependencies it needs to function
 * @returns {INestApplication} A fully initialized INestApplication
 */
export const createTestApp = async (moduleRef : TestingModule) : Promise<INestApplication> => {
  const app = moduleRef.createNestApplication();
  applyAppConfig(app);
  return await app.init();
};

And my abbreviated app.e2e-spec.ts:

describe('AppController', () => {
  let app: INestApplication;

  beforeAll(async () => {
    const moduleRef = await Test.createTestingModule({
      imports: [
        getTestConfig(),
        AppModule
      ],
    }).compile();

    app = await createTestApp(moduleRef);
  });

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

  describe('GET /api/health', () => {
    it('returns 200', () => {
      return request(app.getHttpServer())
        .get('/api/health')
        .expect(200);
    });
  });

// ... rest of the code excluded

Running the tests console output

Note: 12360 is my main database and 12361 is my E2E database

[Nest] 6017   - 12/24/2020, 12:39:52 PM   [NestFactory] Starting Nest application...
  console.log
    starting database... 12360

      at InstanceWrapper.useFactory [as metatype] (src/database/module-config.ts:8:13)
          at async Promise.all (index 3)

  console.log
    starting database... 12361

      at InstanceWrapper.useFactory [as metatype] (src/database/module-config.ts:8:13)
          at async Promise.all (index 3)

  console.info
    App running on: localhost:3200

      at bootstrap (src/main.ts:41:11)

 PASS  test/app.e2e-spec.ts
  AppController
    GET /api/health
      ✓ returns 200 (23 ms)
    GET /api/restdocs/
      ✓ returns 200 (3 ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        3.637 s, estimated 4 s
Ran all test suites.
Jest did not exit one second after the test run has completed.

This usually means that there are asynchronous operations that weren't stopped in your tests. Consider running Jest with `--detectOpenHandles` to troubleshoot this issue.

Test completed Connections State

Checking the MySQL connections shows that the e2e tests are not correctly terminating the main database connection. This can be shown by the fact that the main database has an extra connection with user test (the user the application currently connects with). The other four connections are the MySQL Workbench connections (made with user root).

Main Database Connections

enter image description here

Test Database Connections

enter image description here

Aucun commentaire:

Enregistrer un commentaire