Escrevendo testes com Jest + supertest
Tags: node, typescript, tutorial, braziliandevs
Published: 2021-03-24 16:49:35

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

- Instalar as dependências ([babel-jest](https://www.npmjs.com/package/babel-jest), [jest](https://jestjs.io/pt-BR/), [jest-mock-extended](https://www.npmjs.com/package/jest-mock-extended), [supertest](https://www.npmjs.com/package/supertest), [ts-jest](https://www.npmjs.com/package/ts-jest)) e seus types
- Configurar o Jest
- Escrever mocks de alguns middlewares, por exemplo de logs
- Escrever o mock do typeorm
- Implementar os testes

## Instalações

```bash
yarn add -D babel-jest jest jest-mock-extended supertest ts-jest @types/jest @types/supertest
```

## 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`
```javascript
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)'],
};
```

### 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](https://jestjs.io/pt-BR/docs/manual-mocks), vamos mockar o middleware de logs.

`src/middlewares/__mocks__/logger.ts`
```typescript
const logger = {
  log: () => {},
  info: () => {},
  warn: () => {},
  error: () => {},
  debug: () => {},
  silly: () => {},
};

export default logger;
```

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`
```typescript
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 = () => {};
```

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](https://www.npmjs.com/package/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](https://jestjs.io/pt-BR/docs/setup-teardown#ordem-de-execução-dos-blocos-describe-e-test) 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/sf3tlukdxku9fzgmdbnp.png)

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`
```typescript
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,
            },
          ],
        });
    });
  });
});

```

No Vscode, instale a extenções [Jest](https://marketplace.visualstudio.com/items?itemName=Orta.vscode-jest) e [Jest Runner](https://marketplace.visualstudio.com/items?itemName=firsttris.vscode-jest-runner)

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

![Alt Text](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1f0pftkr87nxvno1bl2v.png)

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

```typescript

  ...

  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);
    });
  });

...

```

>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

```typescript
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;
    }
  }
```

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
```typescript
...
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',
            },
          ],
        });
    });
  });
...
```

>O segundo teste na validação do password
```typescript
...
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)',
            },
          ],
        });
    });
...
```
> E um teste para validar a obrigatoriedade dos campos
```typescript
...
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',
            },
          ],
        });
    });
...
```
> Agora, o teste vai passar nas validações, mas cair na regra de usuário já existente

```typescript
...
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,
            },
          ],
        });
    });
...
```

> Caindo na exception não tratada
```typescript
...
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' }],
        });
    });
...
```
> Agora o de sucesso
```typescript
...
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',
      });
    });
...

```

## 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`
```json
{
 ...
"scripts": {
    ...
    "coverage": "rimraf coverage && NODE_ENV=test jest --coverage --silent --detectOpenHandles --forceExit",
    ...
  },
 ...
}
```

No terminal vamos executar

```bash
yarn coverage
```

![Alt Text](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/d297ygbsejpu16ur6uy9.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hjprdckl2xzqvgbqd1gf.png)

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 ##

```typescript
...
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',
        });
    });
  });
...
```

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

![Alt Text](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/gru2iqhyhphwj9zou9ty.png)

## 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`
```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"
    }
  },
 ...
}
```

Agora, em todo commit teremos os testes sendo executados

>Forcei um erro para a imagem abaixo
![Alt Text](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4iegiyy4ud6ly999f9lw.png)

E com todos os testes OK
![Alt Text](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xupny71cv9hkwgceh64x.png)
 
## O que está por vir

No próximo post, implementaremos uma camada de autenticação com [JWT](https://jwt.io)