1

I want to get into unit testing and have some configuration services for my Nest API that I want to test. When starting the application I validate the environment variables with the joi package.

I have multiple configuration services for the database, the server, ... so I created a base service first. This one is able to read environment variables, parse the raw string to a desired datatype and validate the value.

import { ConfigService } from '@nestjs/config';
import { AnySchema, ValidationResult, ValidationError } from '@hapi/joi';

export abstract class BaseConfigurationService {
    constructor(protected readonly configService: ConfigService) {}

    protected constructValue(key: string, validator: AnySchema): string {
        const rawValue: string = this.configService.get(key);

        this.validateValue(rawValue, validator, key);

        return rawValue;
    }

    protected constructAndParseValue<TResult>(key: string, validator: AnySchema, parser: (value: string) => TResult): TResult {
        const rawValue: string = this.configService.get(key);
        const parsedValue: TResult = parser(rawValue);

        this.validateValue(parsedValue, validator, key);

        return parsedValue;
    }

    private validateValue<TValue>(value: TValue, validator: AnySchema, label: string): void {
        const validationSchema: AnySchema = validator.label(label);
        const validationResult: ValidationResult = validationSchema.validate(value);
        const validationError: ValidationError = validationResult.error;

        if (validationError) {
            throw validationError;
        }
    }
}

Now I can extend this service with multiple configuration services. For the sake of simplicity I will take the server configuration service for this. Currently it only holds the port the application will listen to.

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as Joi from '@hapi/joi';

import { BaseConfigurationService } from './base.configuration.service';

@Injectable()
export class ServerConfigurationService extends BaseConfigurationService {
    public readonly port: number;

    constructor(protected readonly configService: ConfigService) {
        super(configService);
        this.port = this.constructAndParseValue<number>(
            'SERVER_PORT', 
            Joi.number().port().required(), 
            Number
        );
    }
}

I found multiple articles out there that I should only test public methods, e.g.

https://softwareengineering.stackexchange.com/questions/100959/how-do-you-unit-test-private-methods

so I'm assuming I should not test the methods from the base configuration service. But I would like to test the classes extending the base service. I started with this

import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';

import { ServerConfigurationService } from './server.configuration.service';

const mockConfigService = () => ({
  get: jest.fn(),
});

describe('ServerConfigurationService', () => {
  let serverConfigurationService: ServerConfigurationService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        ServerConfigurationService,
        { 
          provide: ConfigService,
          useFactory: mockConfigService 
        }
      ],
    }).compile();

    serverConfigurationService = module.get<ServerConfigurationService>(ServerConfigurationService);
  });

  it('should be defined', () => {
    expect(serverConfigurationService).toBeDefined();
  });
});

but as you can see in the second code snippet I'm calling the functions from the base service in the constructor. The test instantly fails with

ValidationError: "SERVER_PORT" must be a number

Is there a way I can unit test the configuration services although they depend on an abstract base class and an external .env file? Because I know I can create a mockConfigService but I think the base class breaks this. I don't know how to fix this test file.

skyboyer
  • 22,209
  • 7
  • 57
  • 64
Question3r
  • 2,166
  • 19
  • 100
  • 200

1 Answers1

5

The main problem boils down to this: You are using the Joi libary to parse environment variables. Whenever you call validateValue, Joi functions are called that expect actual environment variables to be set (in this case, SERVER_PORT). Now that these environment variables need to be set is a valid assumption for the running service. But in your test cases, you have no environment variables set, hence the Joi validation fails.

A primitive solution would be to set process.env.SERVER_PORT to some value in your beforeEach and delete it in afterEach. However, this is just a work-around around the actual issue.

The actual issue is: You hard-coded library calls into your BaseConfigurationService that have the assumption that environment variables are set. We already figured out earlier that this is not a valid assumption when running tests. When you stumble upon issues like this when writing tests, it often points to a problem of tight coupeling.

How can we address that?

  1. We can separate the concerns clearly and abstract away the actual validation into its own service class that's used by BaseConfigurationService. Let's call that service class ValidationService.
  2. We can then inject that service class into BaseConfigurationService using Nest's dependency injection.
  3. When running tests, we can mock the ValidationService so it does not rely on actual environment variables, but, for example, just doesn't complain about anything during validation.

So here's how we can achieve that, step by step:

1. Define a ValidationService interface

The interface simply describes how a class needs to look that can validate values:

import { AnySchema } from '@hapi/joi';

export interface ValidationService {
  validateValue<TValue>(value: TValue, validator: AnySchema, label: string): void;
}

2. Implement the ValidationService

Now we'll take the validation code from your BaseConfigurationService and use it to implemente ValidationService:

import { Injectable } from '@nestjs/common';
import { AnySchema, ValidationResult, ValidationError } from '@hapi/joi';

@Injectable()
export class ValidationServiceImpl implements ValidationService {
  validateValue<TValue>(value: TValue, validator: AnySchema, label: string): void {
    const validationSchema: AnySchema = validator.label(label);
    const validationResult: ValidationResult = validationSchema.validate(value);
    const validationError: ValidationError = validationResult.error;

    if (validationError) {
      throw validationError;
    }
  }
}

3. Inject ValidationServiceImpl into BaseConfigurationService

We'll now remove the validation logic from the BaseConfigurationService and instead add a call to ValidationService:

import { ConfigService } from '@nestjs/config';
import { AnySchema, ValidationResult, ValidationError } from '@hapi/joi';
import { ValidationServiceImpl } from './validation.service.impl';

export abstract class BaseConfigurationService {
  constructor(protected readonly configService: ConfigService,
              protected readonly validationService: ValidationServiceImpl) {}

  protected constructValue(key: string, validator: AnySchema): string {
    const rawValue: string = this.configService.get(key);

    this.validationService.validateValue(rawValue, validator, key);

    return rawValue;
  }

  protected constructAndParseValue<TResult>(key: string, validator: AnySchema, parser: (value: string) => TResult): TResult {
    const rawValue: string = this.configService.get(key);
    const parsedValue: TResult = parser(rawValue);

    this.validationService.validateValue(parsedValue, validator, key);

    return parsedValue;
  }


}

4. Implemente a mock ValidationService

For testing purposes, we don't want to validate against actual environment variables, but just genereally accept all values. So we implement a mock service:

import { ValidationService } from './validation.service';
import { AnySchema, ValidationResult, ValidationError } from '@hapi/joi';

export class ValidationMockService implements ValidationService{
  validateValue<TValue>(value: TValue, validator: AnySchema, label: string): void {
    return;
  }
}

5. Adapt classes extending BaseConfigurationService to have ConfigurationServiceImpl injected and pass it on to BaseConfigurationService:

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as Joi from '@hapi/joi';

import { BaseConfigurationService } from './base.configuration.service';
import { ValidationServiceImpl } from './validation.service.impl';

@Injectable()
export class ServerConfigurationService extends BaseConfigurationService {
  public readonly port: number;

  constructor(protected readonly configService: ConfigService,
              protected readonly validationService: ValidationServiceImpl) {
    super(configService, validationService);
    this.port = this.constructAndParseValue<number>(
      'SERVER_PORT',
      Joi.number().port().required(),
      Number
    );
  }
}

6. use the mock service in the test

Finally, now that ValidationServiceImpl is a dependency of BaseConfigurationService, we use the mocked version in the test:

import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';

import { ServerConfigurationService } from './server.configuration.service';
import { ValidationServiceImpl } from './validation.service.impl';
import { ValidationMockService } from './validation.mock-service';

const mockConfigService = () => ({
  get: jest.fn(),
});

describe('ServerConfigurationService', () => {
  let serverConfigurationService: ServerConfigurationService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        ServerConfigurationService,
        {
          provide: ConfigService,
          useFactory: mockConfigService
        },
        {
          provide: ValidationServiceImpl,
          useClass: ValidationMockService
        },
      ],
    }).compile();
    serverConfigurationService = module.get<ServerConfigurationService>(ServerConfigurationService);
  });

  it('should be defined', () => {
    expect(serverConfigurationService).toBeDefined();
  });
});

Now when running the tests, ValidationMockService will be used. Plus, apart from fixing your test, you also have a clean separation of concerns.

The refactoring I provided here is just an example how you can go ahead. I guess that, depending on your further use cases, you might cut ValidationService differently than I did, or even separate more concerns into new service classes.

fjc
  • 5,590
  • 17
  • 36
  • your provided answer is really really awesome for beginners! thanks. One last question: Where do you put your mock files like classes, data structures, etc. ? I think they shouldn't live in the `src` directory because then they affect the build size .. ? – Question3r Apr 20 '20 at 15:29
  • It comes down to personal preference, I guess. I like to put my mock classes right next to their interfaces and "proper" implementations to keep things together that belong in one domain. I know others, though, who prefer to put all mock classes in a `lib-test` folder along with test data. I guess you could argue that this approach makes for a clearer separation between testing and productive code. – fjc Apr 20 '20 at 15:36
  • @fjc Given that I am a newbie to Typescript, why did you create a ValidationService interface? My doubt arise because Typescript has Structural Typing, so I can still fully mock the dependency in tests. Another point is that you used the implementation ValidationServiceImpl in the BaseConfigurationService and you were able to change the 'provider' in tests. Am I missing something? – Migi Jun 30 '22 at 09:01