iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
📑

Testing Strategy Ideas in NestJS

に公開

I've written a document for internal use, so I'm sharing it here as well.

How to handle API tests (referred to as e2e tests in NestJS)

In the default NestJS template, there are src and test directories, and API tests are included in the test directory with the name app.e2e-spec.ts.

Initial directory structure of NestJS

Decisions to make

Option 1: Whether to place e2e tests under the test directory or alongside each module

Pros of placing under the test directory

  • You can execute e2e tests by specifying the test directory (though since Jest can specify file extensions with the testRegexp setting, this might not be very significant).

Cons of placing under the test directory

  • When you want to access resources under the src directory, you have to cross directories, making paths complex.
  • Even though everything is organized by module, separating only API tests into the test directory breaks the context.

Pros of placing alongside each module

  • Easier to access resources under the src directory.
  • All related files are grouped by resource, making them easier to manage.

Cons of placing alongside each module

  • It might take longer to search for a specific test compared to having them in the test directory? (Needs verification)

Therefore, I decided to place them alongside each module.

Option 2: Whether to group API tests into a single file or split them for each resource

Whether to write all tests in app.e2e-spec.ts or split files into resource1.e2e-spec.ts, resource2.e2e-spec.ts, etc.

Pros of keeping everything in one file

  • DB setup and teardown are easier.
  • Request users can be reused.
  • Since tests don't run concurrently, parallel-test-specific issues disappear (e.g., creating the same resource and hitting unique constraints).

Cons of keeping everything in one file

  • Unless you carefully consider the order of tests for each resource, resources created in one test might affect others (though this could be a pro??).
  • Jest executes serially within a single file, so execution time naturally increases as the number of tests grows.
  • The editor becomes sluggish because the file becomes very long.

Pros of splitting files

  • Each file is shorter, so the editor stays snappy.
  • Tests can run in parallel, so execution time doesn't grow as much as serial execution even as resources increase.
  • If you want serial execution, Jest allows it with a single option: --runInBand.

Cons of splitting files

  • There is overhead for setup and teardown (though Jest supports global setup and global teardown, which can minimize this if designed well).
  • Tests must be designed to support parallel execution.

Therefore, I decided to split them for each resource. This meant I needed to design the tests for parallel execution. Strategies for parallel execution are discussed later in this article.

Option 3: Which module to import for API tests

Whether to import AppModule or import relevant modules specifically for each resource.

To clarify: for an API test of a specific resource, should we use the comprehensive AppModule,

resource1.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../app.module';

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

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    await app.init();
  });

  it('/ (GET)', () => {
    return request(app.getHttpServer())
      .get('/resource1')
      .expect(200)
      .expect('Hello World!');
  });
});

or use only the modules related to that resource?

resource1.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { Resource1Module } from './resource1.module';

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

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      imports: [Resource1Module],
    }).compile();

    app = moduleFixture.createNestApplication();
    await app.init();
  });

  it('/ (GET)', () => {
    return request(app.getHttpServer())
      .get('/resource1')
      .expect(200)
      .expect('Hello World!');
  });
});

Pros of importing AppModule

  • Since it contains everything, you don't need to manually import global components.
  • The createTestingModule setup becomes similar across tests, making it easier to share logic.

Cons of importing AppModule

  • Setup might take longer (needs verification).

Pros of importing specific modules for each resource

  • Setup is likely faster (needs verification).

Cons of importing specific modules for each resource

  • It's tedious because you have to import globally exported modules every time.

Therefore, I decided to import AppModule for API tests and created a shared utility function.

What I Did (and Gave Up) to Enable Parallel Execution of API Tests

Strategy During the Era of Serial Testing

Before using Jest, I used Mocha to write serial API tests.

The tricky part of API testing is authentication. If you just create a user arbitrarily and send a request with that user, the authentication won't pass. Previously, I was using Express and skipped the authentication part during API tests.

Authentication logic concept
if(process.env.NODE_ENV !== test){
  app.use(authCheck);
}

However, in the authentication part, I was fetching the user from the database based on the email obtained from the JWT and assigning it to req.user.

Current user
// Store email address in req.authUser after authentication
req.user = userRepository.findByEmail(req.authUser);

If authentication is skipped, the email cannot be obtained. Therefore, I used to create a specific user for testing and reuse it.

Current user
// Store email address in req.authUser after authentication
if(process.env.NODE_ENV === test){
  // User for testing
  // Assumes user was created during setup
  req.user = userRepository.findByEmail('test@example.com');
} else {
  req.user = userRepository.findByEmail(req.authUser);
}

This is already getting complicated. However, if you fix the request user at the very beginning, you won't be able to test scenarios like when the user's permission is 'admin' or 'general'. Therefore, the request user was created in the before hook for each test. This wasn't a problem because each test was serial and data was deleted after each test.

createRequestUser
export const createRequestUser = async (permission: string) => {
  const user = buildDummyUser({
    permission,
    email: 'test@example.com'
  });
  
  await userRepository.upsert(user);
}
resource1.e2e-spec.ts
describe(('/resource1') => {
  let reqUser: UserEntity;
  let app: INestApplication;
  
  describe('When ADMIN', () => {
      beforeAll(async () => {
        const module: TestingModule = await Test.createTestingModule({
        imports: [Resource1Module],
      }).compile();

      app = moduleFixture.createNestApplication();
      await app.init();
    
      reqUser = await createRequestUser('ADMIN');
    })
  
    afterAll((done) => {
      // Delete all data
      deleteAllTable(done)
    })
    
    test('/ (GET)', () => {
      return request(app.getHttpServer())
        .get('/resource1')
        .expect(200)
        .expect('Hello World!');
    });
  })
  
  describe('When general user', () => {
    // Omitted
  })
})

When running in parallel, this strategy can no longer be used.

New Strategy for the Era of Parallel Testing

Therefore, I decided to adopt the following strategy:

  • Utilize global setup and global teardown instead of deleting data for each test.
  • Create a request user from scratch for each test.
  • Since token creation involves an external service, ensure JWT authentication passes for each created request user. Create a dedicated JWT strategy for testing.

Utilizing Global Setup and Global Teardown Instead of Deleting Data for Each Test

I moved test fixture processing to global setup and full data deletion to global teardown. This eliminates the overhead for each individual test.

jest.config.js
const config = {
  moduleFileExtensions: ['js', 'json', 'ts'],
  rootDir: './',
  modulePaths: ['<rootDir>'],
  testRegex: '.*\\.(e2e-spec|spec)\\.ts$', // e2eSpec for e2e tests, spec for unit tests.
  globalTeardown: '<rootDir>/src/share/teardownJest.ts',
  globalSetup: '<rootDir>/src/share/setupJest.ts',
  transform: {
    '^.+\\.(t|j)s$': 'ts-jest',
  },
  collectCoverageFrom: ['src/**/*.(t|j)s', '!src/**/*.d.ts'],
  coverageDirectory: './coverage',
  testEnvironment: 'node',
};

module.exports = config;
resource1.e2e-spec.ts
describe(('/resource1') => {
  let reqUser: UserEntity;
  let app: INestApplication;
  
  describe('When ADMIN', () => {
      beforeAll(async () => {
        const module: TestingModule = await Test.createTestingModule({
        imports: [Resource1Module],
      }).compile();

      app = moduleFixture.createNestApplication();
      await app.init();
    
      reqUser = await createUser({
        permission: 'ADMIN'
      });
    })
  
    // afterAll is gone
    
    test('/ (GET)', () => {
      return request(app.getHttpServer())
        .get('/resource1')
        .expect(200)
        .expect('Hello World!');
    });
  })
  
  describe('When general user', () => {
    // Omitted
  })
})

Create a Request User for Each Test Individually

I've stopped fixing the email address to test@example.com.

createUser
export const createUser = async (options?: UserEntityConstructor) => {
  // Do not fix the email address.
  const user = buildDummyUser(options);
  
  await userRepository.save(user);
}
resource1.e2e-spec.ts
describe(('/resource1') => {
  let reqUser: UserEntity;
  let app: INestApplication;
  
  describe('When ADMIN', () => {
      beforeAll(async () => {
        const module: TestingModule = await Test.createTestingModule({
        imports: [Resource1Module],
      }).compile();

      app = moduleFixture.createNestApplication();
      await app.init();
    
      await createUser({
        permission: 'ADMIN'
      });
    })
  
    afterAll((done) => {
      // Delete all data
      deleteAllTable(done)
    })
    
    test('/ (GET)', () => {
      return request(app.getHttpServer())
        .get('/resource1')
        .expect(200)
        .expect('Hello World!');
    });
  })
  
  describe('When general user', () => {
    // Omitted
  })
})

Creating a Dedicated JWT Strategy for Testing

I wanted to check whether authentication checks were in place, but generating a valid dummy token for every test was a hassle. Therefore, I decided to create a slightly clever JWT strategy and guard. By passing an email address instead of a token, I can verify that token authentication is being checked while also assigning the user to req.user.

Here is the intended usage:

resource1.e2e-spec.ts
test('api test', () => {
  agent
    .get('/resource1')
    .set('Authorization', `Bearer ${reqUser.email}`)
    .expect(200, done);
})

Actual code:

auth.module.ts
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';

import { UserModule } from '@/account/user.module';

import {
  JwtStrategy,
  JwtTestStrategy,
} from './strategy';

@Module({
  imports: [
    PassportModule.register({ defaultStrategy: 'jwt' }),
    UserModule,
  ],
  providers: [
    JwtStrategy,
    JwtTestStrategy,
  ],
})
export class AuthModule {}
auth/strategy/jwtForTest.strategy.ts
import {
  Injectable,
  UnauthorizedException,
  Logger,
  InternalServerErrorException,
} from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Request } from 'express';
import { Strategy } from 'passport-custom';

import { UserEntity } from '@/account/entity';
import { UserService } from '@/account/user.service';

@Injectable()
export class JwtTestStrategy extends PassportStrategy(Strategy, 'jwtTest') {
  private logger = new Logger(JwtTestStrategy.name);

  constructor(
      private userService: UserService,
  ) {
    super();
  }

  // NOTE: Returning a falsy value including null in validate results in a 401 error
  public async validate(req: Request): Promise<UserEntity> {
    const auth = req.get('Authorization');
    
    // Check if it's a Bearer token.
    if (!auth || auth.length < 10) {
      throw new UnauthorizedException(
        "Invalid or missing Authorization header. Expected to be in the format 'Bearer <your_JWT_token>'.",
      );
    }

    const authPrefix = auth.substring(0, 7).toLowerCase();
    if (authPrefix !== 'bearer ') {
      throw new UnauthorizedException(
        "Authorization header is expected to be in the format 'Bearer <your_JWT_token>'.",
      );
    }

    const email = auth.substring(7);

    const currentUser = await this.userService.findUserByEmail({
      email,
      requestId,
    });

    if (!currentUser) {
      throw new InternalServerErrorException('User not found.');
    }

    return currentUser;
  }
}
auth/guard/jwtForTest.guard.ts
import { AuthGuard } from '@nestjs/passport';

export class JwtForTestGuard extends AuthGuard('jwtTest') {
  constructor() {
    super();
  }
}
resource1.e2e-spec.ts
describe(('/resource1') => {
  let reqUser: UserEntity;
  let app: INestApplication;
  
  describe('When ADMIN', () => {
      beforeAll(async () => {
        const module: TestingModule = await Test.createTestingModule({
          imports: [AppModule],
        })
	.overrideGuard(JwtGuard) // Override JwtGuard
	.useClass(JwtForTestGuard)

      app = moduleFixture.createNestApplication();
      await app.init();
    
      reqUser = await createUser({
        permission: 'ADMIN'
      });
    })
  
    // afterAll is gone
    
    test('/ (GET)', () => {
      return request(app.getHttpServer())
        .get('/resource1')
        .set('Authorization', `Bearer ${reqUser.email}`)
        .expect(200)
        .expect('Hello World!');
    });
  })
  
  describe('When general user', () => {
    // Omitted
  })
})

With this, I have overcome the challenges encountered during the era of serial testing.

Additionally, although it may depend on the team, I have adopted the following policies:

  • To save testing time and resources, I focus primarily on testing 200 success, 401 error, and 403 error cases, while checking 400 error cases only partially. For everything else, I rely on unit tests specifically for validation.
  • To keep the testing burden as light as possible, API tests only check the status code. I do not check the database or the response.

The reason I don't check the response is that I generate DTOs from OpenAPI and apply them to the response. I don't check the database because it makes the tests significantly more complex and taxing, and I want to cover database logic with unit tests at the service or model layer as much as possible.

API tests are focused more on the entry-level aspects such as:

  • Authentication checks
  • Permission checks
  • Validation checks

https://zenn.dev/dove/articles/bdf73092fa00a9

Discussion