Escrevendo testes com Jest + supertest
Vitor Silva Delfino
Posted on March 24, 2021
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, jest, jest-mock-extended, supertest, 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
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
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, vamos mockar o middleware de logs.
src/middlewares/__mocks__/logger.ts
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
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, 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.
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,
},
],
});
});
});
});
No Vscode, instale a extenções Jest e Jest Runner
Com elas, podemos executar um teste específico clicando no botão Run
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);
});
});
...
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;
}
}
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',
},
],
});
});
});
...
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)',
},
],
});
});
...
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',
},
],
});
});
...
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,
},
],
});
});
...
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' }],
});
});
...
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',
});
});
...
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",
...
},
...
}
No terminal vamos executar
yarn coverage
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
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',
});
});
});
...
Agora com todos os testes executados, o coverage está em 100%
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"
}
},
...
}
Agora, em todo commit teremos os testes sendo executados
O que está por vir
No próximo post, implementaremos uma camada de autenticação com JWT
Posted on March 24, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.