Escrevendo testes com Jest + supertest

vitordelfino

Vitor Silva Delfino

Posted on March 24, 2021

Escrevendo testes com Jest + supertest

Neste post, vamos escrever o teste unitário do CRUD de usuários feito até aqui.

Como a nossa camada de serviço acessa a base de dados com o typeorm, vamos escrever algumas funções que vai mockar a instância do typeorm, facilitando reescrever o retorno do acesso ao banco.

Passo a passo

Instalações

yarn add -D babel-jest jest jest-mock-extended supertest ts-jest @types/jest @types/supertest
Enter fullscreen mode Exit fullscreen mode

Configurações

O próprio jest tem uma função para montar o arquivo de configurações, como eu já uso a lib em vários projetos, vou copiar um padrão que eu costumo utilizar. Por estarmos usando o babel e import nomeado (@middleware, etc...) a config já está certinha ;D

jest.config.js

const { pathsToModuleNameMapper } = require('ts-jest/utils');

const { compilerOptions } = require('./tsconfig.json');

module.exports = {
  clearMocks: true,
  moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, {
    prefix: '<rootDir>',
  }),
  coverageDirectory: 'coverage',
  coverageReporters: ['lcov', 'html', 'text'],
  coveragePathIgnorePatterns: [
    '/node_modules/',
    'src/tools',
    'src/services',
    'src/middlewares',
  ],
  preset: 'ts-jest',
  testEnvironment: 'node',
  modulePathIgnorePatterns: ['dist', 'node_modules', 'coverage'],
  testMatch: ['**/?(*.)+(spec|test).(js|ts|tsx)'],
};
Enter fullscreen mode Exit fullscreen mode

Mocks

Mocking user modules

Simulações manuais são definidas por escrever um módulo em um subdiretório mocks/ imediatamente adjacente ao módulo. Por exemplo, para simular (mock, em inglês) um módulo chamado user no diretório models, crie um arquivo chamado user.js e coloque ele no diretório models/mocks.

Levando em conta a explicação da documentação do Jest, vamos mockar o middleware de logs.

src/middlewares/__mocks__/logger.ts

const logger = {
  log: () => {},
  info: () => {},
  warn: () => {},
  error: () => {},
  debug: () => {},
  silly: () => {},
};

export default logger;
Enter fullscreen mode Exit fullscreen mode

Agora, quando o nosso teste passar por um log dentro da classe de serviço, não vai ser executado nada, deixando o console do teste mais limpo.

Mock Typeorm

Quando queremos mockar um módulo que foi instalado como dependênciacia, nós criamos a pasta __mocks__ na raiz do projeto, e dentro dela os arquivos com o nome da lib.

__mocks__/typeorm.ts

import { mock } from 'jest-mock-extended';
import { Repository, MongoRepository } from 'typeorm';

export const repositoryMock = mock<Repository<any>>();

export const mongoRepositoryMock = mock<MongoRepository<any>>();

export const getConnection = jest.fn().mockReturnValue({
  getRepository: () => repositoryMock,
  getMongoRepository: () => mongoRepositoryMock,
});

export class BaseEntity {}
export const ObjectIdColumn = () => {};
export const Column = () => {};
export const Index = () => {};
export const CreateDateColumn = () => {};
export const UpdateDateColumn = () => {};
export const Entity = () => {};
Enter fullscreen mode Exit fullscreen mode

Aqui estamos mockando todos os recursos do typeorm que a aplicação está utilizando, decorators, repositories, funções, etc...

Então lá na classe de serviço, onde importamos um repository no construtor, quando o teste for executado, é o objeto do arquivo acima que será utilizado. Dessa forma, no teste unitário eu consigo simular o retorno dos métodos de acesso ao banco, findOne, find, update, delete, etc...

Escrevendo o primeiro teste

Para os testes do crud, vou utilizar o supertest, ele simula a camada do express, e assim conseguimos fazer um request pra nossa api.

Vamos escrever nossos testes dentro uma pasta tests na raiz do projeto, e então o separamos por módulos.

GET

Os testes unitários são executados em blocos de código, assim conseguimos separar cada bloco em um assunto específico, revise a documentação caso necessário

E para facilitar a escrita do testes, fazendo passar por todas as regras de negócio, eu costumo deixar a classe serviço aberta lado a lado do teste.

image

A primeira regra é: Se o usuário não existir na base de dados, a api devolve um erro com status 404.

Então vamos escrever esse teste

tests/User/user.test.ts

import { MockProxy } from 'jest-mock-extended';
import request from 'supertest';
import { MongoRepository } from 'typeorm';

jest.mock('typeorm');
jest.mock('../../src/middlewares/logger');
describe('## User Module ##', () => {
  // Importamos a instância do express para usar com supertest
  const { app } = require('../../src/app').default;

  // Aqui é a instância do typeorm que vai na base de dados
  const repository = require('typeorm').mongoRepositoryMock as MockProxy<
    MongoRepository<any>
  >;

  // Vamos separar os endpoints do crud por blocos

  describe('## GET ##', () => {
    // Aqui vamos escrever os testes para o método findOne da classe de serviço
    test('should return error when user does not exists', async () => {
      // A condição para retornar esse erro é o retorno da base ser nulo
      // Então vamos mocar o retorno do typeorm

      // Assim quando o typeorm resolver a chamada findOne,
      // o retorno é o objetos que passarmos no mock
      repository.findOne.mockResolvedValue(null);

      // Aqui estou fazendo um request para a api
      await request(app)
        .get('/api/users/some-id')
        .expect(404, {
          errors: [
            {
              code: 'USER_NOT_FOUND',
              message: 'Usuário não encontrado',
              status: 404,
            },
          ],
        });
    });
  });
});

Enter fullscreen mode Exit fullscreen mode

No Vscode, instale a extenções Jest e Jest Runner

Com elas, podemos executar um teste específico clicando no botão Run

Alt Text

Agora, vamos escrever todos os outros testes do bloco ## GET ##


  ...

  describe('## GET ##', () => {
    test('should return error when user does not exists', async () => {
      repository.findOne.mockResolvedValue(null);
      await request(app)
        .get('/api/users/some-id')
        .expect(404, {
          errors: [
            {
              code: 'USER_NOT_FOUND',
              message: 'Usuário não encontrado',
              status: 404,
            },
          ],
        });
    });

    test('should return an user', async () => {
      const user = {
        _id: '6001abf43d4675bc1aa693bc',
        name: 'Teste',
        password: '1234',
      };
      repository.findOne.mockResolvedValue(user);
      await request(app).get('/api/users/some-id').expect(200, user);
    });
  });

...

Enter fullscreen mode Exit fullscreen mode

Nosso CRUD, não tem tantas regras de negócio, mas é importante passar por todas elas, para simular o comportamento da api.

POST

Agora vamos escrever os testes da criação do usuário

async create(user: Users): Promise<Users> {
    try {
      const response = await this.repository.save(user);
      return response;
    } catch (e) {
      if (e.code === 11000)
        throw new CustomError({
          code: 'USER_ALREADY_EXISTS',
          message: 'Usuário já existente',
          status: 409,
        });
      throw e;
    }
  }
Enter fullscreen mode Exit fullscreen mode

Na classe de serviço, só temos uma regra, a de usuário ja existente. Mas temos um middleware para validar o payload recebido, os testes desse bloco deve cobrir todas essas regras.

O primeiro teste vai cair na validação de documento

...
describe('## POST ##', () => {
    test('should return error when document is invalid', async () => {
      await request(app)
        .post('/api/users')
        .send({ name: 'Teste', document: '1234', password: '0123456789' })
        .expect(400, {
          errors: [
            {
              code: 'ValidationError',
              message: 'document: deve conter exatamente 11 caracteres',
            },
          ],
        });
    });
  });
...
Enter fullscreen mode Exit fullscreen mode

O segundo teste na validação do password

...
test('should return error when password is invalid', async () => {
      await request(app)
        .post('/api/users')
        .send({
          name: 'Teste',
          document: '12345678900',
          password: '1234',
        })
        .expect(400, {
          errors: [
            {
              code: 'ValidationError',
              message: 'password: valor muito curto (mí­nimo 6 caracteres)',
            },
          ],
        });
    });
...
Enter fullscreen mode Exit fullscreen mode

E um teste para validar a obrigatoriedade dos campos

...
test('should return error when payload is invalid', async () => {
      await request(app)
        .post('/api/users')
        .send({})
        .expect(400, {
          errors: [
            { code: 'ValidationError', message: 'name é um campo obrigatório' },
            {
              code: 'ValidationError',
              message: 'document é um campo obrigatório',
            },
            {
              code: 'ValidationError',
              message: 'password é um campo obrigatório',
            },
          ],
        });
    });
...
Enter fullscreen mode Exit fullscreen mode

Agora, o teste vai passar nas validações, mas cair na regra de usuário já existente

...
test('should return error when user already exists', async () => {
      // Aqui vamos simular o erro de criação do usuário
      repository.save.mockRejectedValue({
        code: 11000,
      });

      await request(app)
        .post('/api/users')
        .send({
          name: 'Teste',
          document: '12345678900',
          password: '1234567890',
        })
        .expect(409, {
          errors: [
            {
              code: 'USER_ALREADY_EXISTS',
              message: 'Usuário já existente',
              status: 409,
            },
          ],
        });
    });
...
Enter fullscreen mode Exit fullscreen mode

Caindo na exception não tratada

...
test('should return error when create user', async () => {
      repository.save.mockRejectedValue(new Error('Some Exception'));

      await request(app)
        .post('/api/users')
        .send({
          name: 'Teste',
          document: '12345678900',
          password: '1234567890',
        })
        .expect(500, {
          errors: [{ code: 'E0001', message: 'Some Exception' }],
        });
    });
...
Enter fullscreen mode Exit fullscreen mode

Agora o de sucesso

...
test('should create an user', async () => {
      const user = {
        name: 'Teste',
        document: '12345678900',
        password: '1234567890',
      };
      repository.save.mockResolvedValue({
        ...user,
        _id: 'some-id',
      });

      await request(app).post('/api/users').send(user).expect(200, {
        name: 'Teste',
        document: '12345678900',
        password: '1234567890',
        _id: 'some-id',
      });
    });
...

Enter fullscreen mode Exit fullscreen mode

Coverage

Antes de escrever os testes de UPDATE e DELETE. Vamos ver como está ficando a cobertura dos testes

No arquivo package.json, vamos escrever um script que executa os testes e colhe a cobertura

package.json

{
 ...
"scripts": {
    ...
    "coverage": "rimraf coverage && NODE_ENV=test jest --coverage --silent --detectOpenHandles --forceExit",
    ...
  },
 ...
}
Enter fullscreen mode Exit fullscreen mode

No terminal vamos executar

yarn coverage
Enter fullscreen mode Exit fullscreen mode

Alt Text

Esse comando gerou uma pastinha chamada coverage na raiz do projeto.

abra o arquivo index.html dela no browser e vemos o resultado dos testes com a cobertura

Alt Text

Navegando até UserService, podemos ver que já estamos com 77% de cobertura nesse arquivo, e os métodos create e findOne está totalmente coberto.

Não esqueca de adicionar a pasta coverage no arquivo .gitignore, para não subi-la para o repositório

UPDATE e DELETE

...
describe('## PUT ##', () => {
    test('should return error when user does not exists', async () => {
      repository.updateOne.mockResolvedValue({} as any);
      repository.findOne.mockResolvedValue(null);
      await request(app)
        .put('/api/users/6001abf43d4675bc1aa693bd')
        .send({ name: 'Teste' })
        .expect(404, {
          errors: [
            {
              code: 'USER_NOT_FOUND',
              message: 'Usuário não encontrado',
              status: 404,
            },
          ],
        });
    });

    test('should return updated user', async () => {
      const user = {
        name: 'Teste',
        document: '12345678900',
        password: '1234567890',
      };
      repository.updateOne.mockResolvedValue({} as any);

      repository.findOne.mockResolvedValue({
        ...user,
        _id: '6001abf43d4675bc1aa693bd',
      });

      await request(app)
        .put('/api/users/6001abf43d4675bc1aa693bd')
        .send({ name: 'Teste' })
        .expect(200, {
          ...user,
          _id: '6001abf43d4675bc1aa693bd',
        });
    });
  });

  describe('## DELETE ##', () => {
    test('should return error when user does not exists', async () => {
      repository.findOne.mockResolvedValue(null);
      await request(app)
        .delete('/api/users/6001abf43d4675bc1aa693bd')
        .send({ name: 'Teste' })
        .expect(404, {
          errors: [
            {
              code: 'USER_NOT_FOUND',
              message: 'Usuário não encontrado',
              status: 404,
            },
          ],
        });
    });

    test('should return deleted user', async () => {
      const user = {
        name: 'Teste',
        document: '12345678900',
        password: '1234567890',
      };

      repository.findOne.mockResolvedValue({
        ...user,
        _id: '6001abf43d4675bc1aa693bd',
      });

      repository.deleteOne.mockResolvedValue({} as any);

      await request(app)
        .delete('/api/users/6001abf43d4675bc1aa693bd')
        .send({ name: 'Teste' })
        .expect(200, {
          ...user,
          _id: '6001abf43d4675bc1aa693bd',
        });
    });
  });
...
Enter fullscreen mode Exit fullscreen mode

Agora com todos os testes executados, o coverage está em 100%

Alt Text

Considerações Finais

Pra finalizar, vamos escrever um script que executa todos os testes.

E ao realizar um commit, todos os testes serão executados e caso algum falhe, o commit será barrado.

Essa é uma boa prática, nos impede de subir alguma coisa que está falhando devido alguma alteração no código

package.json

{
 ...
"scripts": {
    ...
    "test": "rimraf coverage && NODE_ENV=test jest --coverage --silent --detectOpenHandles --forceExit",
    ...
  },
"husky": {
    "hooks": {
      "pre-commit": "npm test",
      "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
    }
  },
 ...
}
Enter fullscreen mode Exit fullscreen mode

Agora, em todo commit teremos os testes sendo executados

Forcei um erro para a imagem abaixo
Alt Text

E com todos os testes OK
Alt Text

O que está por vir

No próximo post, implementaremos uma camada de autenticação com JWT

💖 💪 🙅 🚩
vitordelfino
Vitor Silva Delfino

Posted on March 24, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related