Вступление

Тестирование в NestJS оказалось сложным из-за отсутствия документации, которая его окружает, но я думаю, что теперь я ее взломал. Я решил создать эту статью, чтобы попытаться восполнить этот пробел в документации.

Дело не в том, что модульное тестирование - это сложно; это не так. На самом деле я нахожу вполне удовлетворительным почти «пройти тестирование» кода других людей путем тщательного тестирования всех событий и наблюдения за тем, как все тесты загораются зелеными галочками.

Единственная сложная часть, которую я обнаружил, заключалась в том, чтобы заставить его работать в NestJS, как и во многих других серверных фреймворках, таких как Spring, из-за зависимости от внедрения зависимостей и архитектуры Controller-Service. Их изоляция потребовала некоторой работы.

Я использовал фреймворк Jest для создания этих модульных тестов.

Что такое модульный тест?

Модульный тест построен на основе SOLID и принципа единой ответственности. В результате вы сосредотачиваетесь на изолированном тестировании. Это означает тестирование отдельных частей приложения или очень конкретных частей логики. Тестирование здесь не заинтересовано в том, чтобы увидеть, как части объединяются или, например, работает ли весь API так, как ожидалось. Это лучше подходит для интеграционного или сквозного тестирования.

Таким образом, модульное тестирование зависит от того факта, что код слабо связан и связан.

Базовый пример

Возможно, лучше всего будет начать с простого примера, чтобы помочь тем, кто никогда раньше не сталкивался с тестированием Jest. Несмотря на свою уникальность, модульное тестирование Jest очень похоже на большинство других сред тестирования, таких как JUnit в Java, поэтому навыки можно переносить.

describe("* Testing Maths", () => {
 it("should return a number of 5", () => {
    const x = 5;
    const y = 0;
    const expectedResult = 5;
    expect(x + y).toEqual(expectedResult);
    expect(typeof (x + y)).toBe("number");
 });
});

Как показано в тесте выше:

  • Описать - « описать» показывает в терминале общее название теста (ов), так как у вас может быть несколько функций «оно».
  • Это - функция "это" является тестовой и должна содержать описание того, что она должна делать / возвращать.
  • Ожидайте - ожидайте - это метод, который сообщает тесту, что именно это и должно произойти. Он будет действовать как логическое значение, хотя является недействительным методом и завершится ошибкой, если сравнение не удалось. Вы можете использовать несколько из них в одном тесте.
  • ToEqual - toEqual проверяет, совпадают ли два значения, и является одним из многих возможных методов тестирования, которые использует Jest. Вы можете увидеть больше здесь.
  • В утверждении Jest нет проверки типа, но это можно сделать с помощью собственного метода JavaScript «typeof» и Jests «toBe».

Я рекомендовал разложить тест именно так, включая переменную ожидаемого результата. Это облегчает чтение и понимание теста. Когда тест не проходит, вам нужно выяснить, почему. Следуя этому шаблону, вы можете легко увидеть, что происходит и что должно возвращаться. Так поменять тоже проще.

Издевательство

К сожалению, большинство тестов не так просты и понятны, как математика.

Типичный шаблон проектирования сервера следует шаблону контроллера, службы, репозитория. Каждый должен выполнять свою работу независимо друг от друга. Таким образом, мы должны тестировать каждый из них по очереди. Однако это сложно, потому что они все еще зависят друг от друга из-за внедрения зависимостей. Если контроллер внедряет службу, как мы можем протестировать контроллер без тестирования службы в результате? Это модульное тестирование, мы хотим протестировать только одно.

Мы можем сделать это с помощью хорошо знакомого принципа тестирования - mocking.

Это заняло у меня некоторое время, чтобы осмыслить, так что я думаю, вам это тоже покажется сложным. Но чем больше вы это делаете, тем больше кликов. Возьмем, к примеру, клиентский модуль.

Процесс имитации немного отличается между контроллерами и сервисами, последний более сложен.

Тесты для контроллера путем имитации сервисов

Если вы посмотрите на клиентский контроллер, у него есть 2 зависимости. Клиент и служба firebase. Мы можем смоделировать их в верхней части нашего тестового файла следующим образом:

jest.mock("../client/client.service");
jest.mock("../../core/firebase/firebase.service");

Мы должны включить абсолютный путь, я правильно знаю боль. Вышеупомянутое проинформирует наш тестовый компилятор о том, что при создании клиентского модуля и вызове метода службы не внедряйте их в зависимости. Вместо этого ожидайте фиктивной реализации / результата (который мы создадим).

Мы обернем все тесты и настройку в метод описания, который поможет нам увидеть в терминале, к каким тестам они относятся:

describe("-- Client Controller --", () => {
    ...

Настройка модуля

Прежде чем мы начнем тестирование, мы должны создать модуль. Очевидно, мы должны это сделать, поскольку у нас нет основного входного файла (main.ts или app-modules.ts), который обычно создавал бы модули. Мы используем библиотеку тестирования Nests для создания модуля тестирования:

Укажите службы, контроллер и модуль как переменные класса, чтобы их можно было использовать во всем объеме пакета. (Этот файл называется набором).

import { Test, TestingModule } from "@nestjs/testing";
describe("-- Client Controller --", () => {
 let clientService: ClientService;
 let firebaseService: FirebaseService;
 let module: TestingModule;
 let clientController: ClientController;
 beforeAll(async () => {
  module = await Test.createTestingModule({
    controllers: [ClientController],
    providers: [
      ClientService,
      FirebaseService,
    ]
   }).compile();
   clientService = module.get<ClientService>(ClientService);
   firebaseService = module.get<FirebaseService>(FirebaseService);
   clientController = module.get(ClientController);
 });
 afterEach(() => {
    jest.resetAllMocks();
 });

Мы создаем модуль в методе «beforeAll». Это гарантирует, что модуль будет создан до запуска любого теста. Просто скопируйте то, что находится в файле модуля (в данном случае client.module), передав Controller и Services.

В методе afterEach я сбрасываю все макеты, чтобы каждый тест запускался заново.

Теперь создадим несколько тестов:

describe("* Find One By Id", () => {
 it("should return an entity of client if successful", async () => {
   const expectedResult = new ClientEntity();
   const mockNumberToSatisfyParameters = 0;
   jest.spyOn(clientService, "findOneById").mockResolvedValue(expectedResult);
   expect(await clientController.findOneById(mockNumberToSatisfyParameters)).toBe(expectedResult);
 });
 it("should throw NotFoundException if client not found", async (done) => {
   const expectedResult = undefined;
   const mockNumberToSatisfyParameters = 0;
   jest.spyOn(clientService, "findOneById").mockResolvedValue(expectedResult);
   await clientController.findOneById(mockNumberToSatisfyParameters)
    .then(() => done.fail("Client controller should return NotFoundException error of 404 but did not"))
    .catch((error) => {
      expect(error.status).toBe(404);
      expect(error.message).toMatchObject({error: "Not Found", statusCode: 404});
      done();
    });
 });
});

Таким образом, приведенный выше тест будет проверять «поиск клиента по идентификатору». Два теста будут проверять, что API контроллера возвращает клиента или 404.

Метод поиска по идентификатору в клиентском контроллере вызывает метод «findOneById» клиентской службы. Мы не тестируем эту услугу. В результате мы используем подход «Завесы невежества». Это вуаль, которую сам контролер всегда должен носить. Его не должно волновать, что сервис делает или отвечает, а только что с этим делать.

Итак, мы издеваемся над методом обслуживания. Мы делаем это с помощью jest.spyOn. Метод издевательства заключен в двойные кавычки. Метод .mockResolvedValue используется, потому что эта служба является асинхронной и возвращает обещание. Этот метод будет имитировать, что служба возвращает объект Client или, во втором тестовом случае, undefined.

Затем в первом тесте мы вызываем метод контроллера (который будет использовать фиктивный сервис). Затем мы проверяем, что контроллер возвращает это правильно.

В следующем тесте мы используем обещания, потому что мы обрабатываем ошибки. Если клиента нет, наш контроллер возвращает исключение. Если вызывается .then (), то клиент вернул действительный ответ. Если это произойдет, то тест не пройдет. Мы знаем, что контроллер должен вернуть исключение. В .catch () мы проверяем, что ответ - 404 и соответствует ли это сообщению с .toMatchObject.

Взгляните на тест Create Client в client.controller.spec и узнайте, как мы можем отслеживать различные методы обслуживания, чтобы изменить ответ контроллеров:

Как показано в контроллере, он возвращает 3 исключения, если результаты 3 служб недействительны:

/**
 * Create Client
 * @param id
 * @param {CreateClientDto} createData
 * @returns ClientEntity
 */
 @Post()
 @UseGuards(SystemUserGuard)
 @UsePipes(ValidationPipe)
   async create(@Body() createData: CreateClientDto): Promise<ClientEntity> {
    const isValidClientId = await this.clientService.findOneById(createData.client_id);
    const isValidClientType = await this.clientService.validClientType(createData.client_type);
    const isValidFirebaseApp = await this.firebaseService.validFirebaseApp(createData.firebase_service_account_id);
    
    if (isValidClientId !== undefined) {
      throw new ConflictException("Already exists a client with the same client_id");
    } else if (isValidClientType === false) {
      throw new BadRequestException("Invalid client type");
    } else if (isValidFirebaseApp === false) {
      throw new BadRequestException("Invalid firebase account id");
    }
    return await this.clientService.create(createData);
 }

Мы можем использовать различные тесты, чтобы смоделировать любую из 3 служб в этом контроллере, чтобы изменить результат. То есть мы можем имитировать метод firebaseService.validFirebaseApp, чтобы вернуть false. Изучите тест ниже:

describe("* Create Client ", () => {
 const dto = new CreateClientDto();
 it("should return an object of client entity when created", async () => {
   const expectedResult = new ClientEntity();
   jest.spyOn(clientService, "create").mockResolvedValue(expectedResult);
   expect(await clientController.create(dto)).toBe(expectedResult);
 });
 it("should return conflict if client already exists ", async (done) => {
   const serviceMockResult = new ClientEntity();
   // Pretend that a client does already exist
   jest.spyOn(clientService, "findOneById").mockResolvedValue(serviceMockResult);
   await clientController.create(dto)
    .then(() => done.fail("Client controller shuold return conflict error of 409 but did not"))
    .catch((error) => {
    expect(error.status).toBe(409);
    expect(error.message).toBe("Already exists a client with the same client_id");
    done();
    });
  });
  
 it("should return BadRequestException if invalid client type ", async (done) => {
   const serviceMockResult = false;
   jest.spyOn(clientService, "validClientType").mockResolvedValue(serviceMockResult);
   await clientController.create(dto)
    .then(() => done.fail("Client controller should return BadRequestException error of 400 but did not"))
    .catch((error) => {
    expect(error.status).toBe(400);
    expect(error.message).toBe("Invalid client type");
    done();
   });
 });
 it("should return BadRequestException if invalid firebase account id", async (done) => {
   const serviceMockResult = false;
   jest.spyOn(firebaseService, "validFirebaseApp").mockResolvedValue(serviceMockResult);
   await clientController.create(dto)
    .then(() => done.fail("Client controller should return BadRequestException error of 400 but did not"))
    .catch((error) => {
    expect(error.status).toBe(400);
    expect(error.message).toBe("Invalid firebase account id");
    done();
   });
 });
});

Тестирование сервиса путем имитации репозиториев

В наших сервисах вам может понадобиться имитировать репозиторий, а может и нет. Это зависит от того, обращается ли сервис, который вы тестируете, к базе данных или просто выполняет некоторую логику. Мы не хотим тестировать базу данных. Мы снова используем подход «Завесы невежества». Что бы ни возвращала эта база данных, нам все равно, просто обработайте это и сделайте свою работу. Настройка модуля здесь немного проще, так как нам не нужно сбрасывать какие-либо макеты. Настроить так:

describe("-- Client Service --", () => {
 let clientService: ClientService;
 let module: TestingModule;
 let clientRepositoryMock: MockType<Repository<ClientEntity>>;
 let clientTypeRepositoryMock: MockType<Repository<ClientTypeEntity>>;
 const mockNumberToSatisfyParameters = 0;
 beforeAll(async () => {
   module = await Test.createTestingModule({
     providers: [
       ClientService,
       { provide: getRepositoryToken(ClientEntity), useFactory: repositoryMockFactory },
       { provide: getRepositoryToken(ClientTypeEntity), useFactory: repositoryMockFactory },
     ]
  }).compile();
  clientService = module.get<ClientService>(ClientService);
  clientRepositoryMock = module.get(getRepositoryToken(ClientEntity));
  clientTypeRepositoryMock = module.get(getRepositoryToken(ClientTypeEntity));
 });

Самое большое отличие от контроллера - это имитация репозиториев. Для этого нам нужно создать собственные макеты с помощью Jest. Нам нужно создать тип MockType и фиктивную фабрику:

// @ts-ignore
export const repositoryMockFactory: () => MockType<Repository<any>> = jest.fn(() => ({
 findOne: jest.fn(),
 find: jest.fn(),
 update: jest.fn(),
 save: jest.fn()
}));
export type MockType<T> = {
 [P in keyof T]: jest.Mock<{}>;
};

Просто скопируйте и вставьте это в конец вашего тестового файла. Основные моменты показывают методы, которые я добавил в репозиторий фиктивных. Эти методы имитируют методы, которые должен выполнять реальный репозиторий. По ходу просто добавляйте их.

Давайте создадим несколько тестов, используя этот фиктивный репозиторий:

Сервис, который мы тестируем:

/**
 * Checks if the client is active (not deleted and not pending)
 * @param {number} clientId
 * @returns {boolean} isActive
 */
 async isActive(clientId: number): Promise<boolean> {
   const client = await this.findOne(clientId);
   return client !== undefined && client.deleted === 0 && client.pending === 0 ? true : false;
 }

Нам нужно смоделировать репозиторий, который он использует:

describe("* Client IsActive", () => {
 it("should return true if active ", async () => {
   const clientId = 1000;
   const client = new ClientEntity();
   client.clientId = clientId;
   client.deleted = 0;
   client.pending = 0;
   clientRepositoryMock.findOne.mockReturnValue(client);
   expect(await clientService.isActive(clientId)).toEqual(true);
 });
 it("should return false if client undefined ", async () => {
   const clientId = 1000;
   clientRepositoryMock.findOne.mockImplementation(undefined);
   expect(await clientService.isActive(clientId)).toEqual(false);
 });
 it("should return false if client deleted and pending ", async () => {
   const clientId = 1000;
   const client = new ClientEntity();
   client.clientId = clientId;
   client.deleted = 1;
   client.pending = 1;
   clientRepositoryMock.findOne.mockReturnValue(client);
   expect(await clientService.isActive(clientId)).toEqual(false);
 });
});

Как вы можете видеть в основных моментах, мы можем имитировать, что эти репозитории должны возвращать, используя «mockImplementation» или «mockReturnValue».

Если вы видите последний тест, мы видим, как служба должна возвращать false, потому что мы имитируем, что репозиторий собирается вернуть объект, который был удален или установлен в состояние ожидания. Это очевидное преимущество для непрерывных тестов, мы можем проверить все возможные результаты.

Запуск тестов

Вы можете запускать тесты с флагом - watch, чтобы автоматически запускать тесты с изменениями кода:

Нажмите «a», когда вас попросят запустить все тесты, иначе будут выполняться только тесты с момента последней фиксации.

npm run test:watch

Или вы можете запустить команду по умолчанию, которая также показывает покрытие:

npm run test