Search code examples
typescriptunit-testingdependency-injectionnestjsvitest

Nestjs is not injecting repository dependency when testing using Test.createTestingModule


I am suffering with an issue on nestjs authomatic dependecy injection that I can't resolve. The problem is when I am running a unit test of a service, the repository is undefined inside the service! But when I run the service, the application works fine!

I am using vitest to run the tests and pnpm to manage the packages! The repository, and the details to reproduce de example is on this github

I have a simple abstract class called UserRepository and two implementations, PrismaUserRepository for running production code that deals with a real database and LocalUserRepository that only implements the methods without any dependency.

export abstract class UserRepository {
  abstract getById(id: string): Promise<User | undefined>;
}

Prisma implementation:

@Injectable()
export class PrismaUserRepository implements UserRepository {
  constructor(private repository: RemoteRepository) {}

  async getById(id: string): Promise<User | undefined> {
    const rawUser = await this.repository.rawUser.findUnique({
      where: { id },
    });

    if (rawUser) {
      return new User(rawUser);
    }
  }
}

Local implementation for tests:

@Injectable()
export class LocalUserRepository implements UserRepository {
  async getById(_id: string): Promise<User | undefined> {
    return {} as User;
  }
}

My service is called GetUserUseCase and only depends of the UserRepository

@Injectable()
export class GetUserUseCase {
  constructor(private readonly userRepository: UserRepository) {}

  async execute({ userId }: GetUserRequest): Promise<User> {
    const user = await this.userRepository.getById(userId);

    if (!user) {
      throw new NotFoundException('User not found');
    }
    return user;
  }
}

I have a controller with a Get route with a hard coded userId of "123" and the API is working! enter image description here

The app.module is like this:

@Module({
  imports: [
    ConfigModule.forRoot({
      envFilePath: ['.env'],
    }),
  ],
  controllers: [UserController],
  providers: [
    GetUserUseCase,
    RemoteRepository,
    { provide: UserRepository, useClass: PrismaUserRepository },
  ],
})

And now, the problem! My unit test is broken! The repository is not getting injected when the GetUserUseCase service runs

Test setup:

beforeEach(async () => {
    const moduleRef = await Test.createTestingModule({
      providers: [
        {
          provide: UserRepository,
          useClass: LocalUserRepository,
        },
        GetUserUseCase,
      ],
    }).compile();

    userRepository = moduleRef.get<UserRepository>(UserRepository);
    spyUserRepositoryGetById = vi.spyOn(userRepository, 'getById');
    getUserUseCase = moduleRef.get<GetUserUseCase>(GetUserUseCase);
  });

The error: enter image description here

I can make the tests run with two approachs, but I do not want to make those ways, because for my real codebase, it will make me and my team suffer a lot!

The first and easiest way to make it run, is to instanciate the service passing the UserRepository as a dependency:

beforeEach(async () => {
    const moduleRef = await Test.createTestingModule({
      providers: [
        {
          provide: UserRepository,
          useClass: LocalUserRepository,
        },
        GetUserUseCase,
      ],
    }).compile();

    userRepository = moduleRef.get<UserRepository>(UserRepository);
    spyUserRepositoryGetById = vi.spyOn(userRepository, 'getById');
    // getUserUseCase = moduleRef.get<GetUserUseCase>(GetUserUseCase);
    getUserUseCase = new GetUserUseCase(userRepository);
  });

It is not good for a big codebase, because I have so many services and I will need to do this in each test 😞

The other way to make this work is puting a injection decorator inside the service, and use the provide with it's name: Service code:

@Injectable()
export class GetUserUseCase {
  constructor(
    @Inject(UserRepository.name)
    private readonly userRepository: UserRepository,
  ) {}

And the test file will look like this:

beforeEach(async () => {
  const moduleRef = await Test.createTestingModule({
    providers: [
      {
        provide: UserRepository.name,
        useClass: LocalUserRepository,
      },
      GetUserUseCase,
    ],
  }).compile();

  userRepository = moduleRef.get<UserRepository>(UserRepository.name);
  spyUserRepositoryGetById = vi.spyOn(userRepository, 'getById');
  // getUserUseCase = moduleRef.get<GetUserUseCase>(GetUserUseCase);
  getUserUseCase = new GetUserUseCase(userRepository);
});

Note that i have put UserRepository.name in the provide and in the moduleRef.get parameters. And to the server runs correctly, I have to make the same in the app.module.ts

I have tried using useValue and mocked the functions instead of using useClass but did not work as well!

So this is what I have to show. I realy want to make the unit tests work only doing a simple configuration using Test.createTestingModule because doing this way the codebase will only have one big Test.createTestingModule with all the configuration!


Solution

  • vitest by default uses esbuild as a Typescript interpreter under the hood. This is great and fast but esbuild doesn't support emitDecoratorMetadata which Nest extensively uses to know how to inject providers into each other. To fix this, you can tell vitest to use @swc/core through a plugin, which does support the emitDecoratorMetadata option, allowing vitest to still be the test runner of choice.

    Documentation on the setup can be found in Nest's docs