Criando testes com Jest

Eaii galerinha, tudo bem?
Hoje venho apresentar para vocês como começar a escrever testes para uma aplicação em NodeJs
. 🤙
Lembrando que para entender melhor o conteúdo, você precisa ter um conhecimento básico de Javascript.
Existem diversas bibliotecas disponíveis para a implementação de testes, a que eu escolhi para escrever esse post é o Jest.
Outras bibliotecas que podem ser utilizadas são Jasmine, Vitest e Mocha.
📑 O que são testes automatizados?
Os testes automatizados são testes que rodam automaticamente ao executar um determinado comando. A ideia é que possamos testar todo o código de uma aplicação, sem precisar executar manualmente cada etapa.
O legal é que com o uso de testes automatizados, podemos testar todo o código de uma aplicação de forma mais rápida e eficiente. Garantindo assim que a nossa implementação esteja fazendo o que realmente deveria fazer e que não quebrou nenhuma outra parte do código com a modificação. 😜
E o que é o Jest?
Jest
é uma biblioteca Javascript
muito popular para a criação da estrutura de testes. Com ela podemos escrever um conjunto de testes, especificações e expectativas para testar as funções de uma aplicação. 🤯
Neste post, vamos aplicar exemplos práticos de testes.
Todos os exemplos abaixo serão desenvolvidos em NodeJS
.
Bora começar!
Eu criei um projeto bem simples em NodeJs
e dentro dele vou mostrar alguns tipos de testes que podemos realizar no backend.
Vou deixar aqui abaixo algumas funções do próprio jest
que vamos utilizar e ao lado sua finalidade.
describe // É todo o contexto onde está sendo executado os testes, dentro dele podem ser executados vários testes.
test // Inicia um teste, com sua descrição e em seguida o teste com suas expectativas.
expect // Função que define o que se espera no teste, realiza as comparações.
toBe // Compara se o valor é igual.
toEqual // Compara se o valor é exatamente igual.
toHaveLength // Verifica o tamanho do elemento.
toBeUndefined // Verifica se o valor é undefined.
toMatchObject // Verifica alguns campos do objeto (não precisa ser todos).
toBeInstanceOf// Verifica o tipo de instância do elemento.
beforeEach // Realiza todos os comandos dentro dessa função antes de começar a executar cada um dos testes
beforeAll // Realiza todos os comandos dentro dessa função antes de começar a executar os testes
afterAll // Realiza todos os comandos dentro dessa função após terminar de executar os testes
Lembrando que aqui nos exemplos iremos utilizar 3 bibliotecas:
jest
- Para executar os testes e realizar as comparações.http
- Para subir um servidor durante os testes.supertest
- Para realizar requisições durante os testes.
Além disso criei uma aplicação no meu GitHub com os exemplos mostrados nesse post, caso tenham interresse. 😉
Testes unitários
Os testes unitários são aqueles que validam componentes individuais. O objetivo é validar se cada parte do código está funcionando como o esperado. Para realizar o teste podemos pegar uma função específica e fazer validações na execução dela.
Um exemplo simples, é o uma criação de conta de usuário, onde a validação do email seria uma função unitária.
Abaixo, criei uma função que me retorna uma sequência de 6 caracteres aleatórios.
const allowedCharacters = 'abcdefghijklmnopqrstuvwxyz0123456789'
export const getRandomHash = () =>
new Array(6)
.fill('')
.map(() =>
allowedCharacters.charAt(
Math.floor(Math.random() * allowedCharacters.length)
)
)
.join('')
É uma função bem simples, ela apenas pega alguns caracteres que eu mesmo defini e seleciona um de modo aleatório, isso acontecerá seis vezes. Executando essa função eu teria um valor parecido com esse: 'd4dnoc'
.
Então vou escrever três testes para cobrir alguns cenários.
import { getRandomHash } from '~/helpers/helpers'
describe('[UNITÁRIO] Retornar um hash', () => {
test('Deve ser uma função', () => {
expect(getRandomHash).toBeInstanceOf(Function)
})
test('Deve retornar uma string', () => {
const newHash = getRandomHash()
expect(newHash).toEqual(expect.any(String))
})
test('Deve retornar 6 caracteres', () => {
const newHash = getRandomHash()
expect(newHash).toHaveLength(6)
})
})
-
O primeiro teste verifica se o elemento que eu estou testando é uma função.
-
O segundo, executa a função e verifica se ela retorna uma
String
. (Nossos caracteres são do tipoString
) -
O terceiro, executa a função e verifica se ela possui o tamanho igual a 6.
Agora vou mandar executar os testes unitários.
(Executando os testes unitários.)
Vou realizar uma alteração na função. Ao invés de 6 caracteres, vou alterar para ela me retornar 7 e executarei o teste em seguida.
(Executando os testes unitários com erro.)
Os dois primeiros testes continuam passando, pois seus comportamentos não foram alterados. Mas o terceiro teste acabou acusando um erro, dizendo que a função deveria retornar 6 caracteres, mas está retornando 7.
Testes de integração
Os testes de integração tem o objetivo de validar se o comportamento entre módulos está correto, o objetivo é expor defeitos na integração desses módulos.
Para isso vamos testar uma função que busca clientes em uma lista (você provavelmente vai utilizar um banco de dados), e caso encontre, busque o telefone do usuário.
import { findUserPhone } from './phone'
/**
* Lista de usuários.
* Simulando um banco de dados.
*/
export const users = [
{ id: 1, name: 'Anderson Espindola', email: 'anderson@example.com' },
{ id: 2, name: 'Roberto Umbelino', email: 'roberto@example.com' },
{ id: 3, name: 'Pablo Danilo', email: 'pablo@example.com' }
]
/**
* Busca de usuário.
*/
export const findUser = (id: number) => {
/**
* Busca de usuário.
*/
const foundUser = users.find(user => user.id === id)
/**
* Não encontrado.
*/
if (!foundUser) return
/**
* Busca o telefone do usuário.
*/
const foundPhone = findUserPhone(foundUser.id)
return { ...foundUser, phone: foundPhone }
}
A função de busca de telefones também observará uma lista.
/**
* Lista de telefones.
* Simulando um banco de dados.
*/
export const phones = [
{ id: 1, idUser: 3, phone: '99999-9999' },
{ id: 2, idUser: 1, phone: '88888-8888' },
{ id: 3, idUser: 2, phone: '77777-7777' }
]
/**
* Busca o telefone do usuário.
*/
export const findUserPhone = (userId: number) =>
phones.find(phone => phone.idUser === userId)
Agora, para realizar o teste de integração, executaremos a função de busca de usuário e verificaremos os possíveis cenários que possam ocorrer dentro dessa função, lembrando que ela também busca telefones caso encontre o usuário.
import { users } from '~/api/user/services/finder'
import { phones } from '~/api/user/services/phone'
import { findUser } from '~/api/user/services/finder'
describe('[INTEGRAÇÃO] Busca de usuário', () => {
test('Deve retornar as informações do usuário', () => {
/**
* Id do usuário.
*/
const userId = 2
/**
* Busca usuário.
*/
const foundUser = findUser(userId)
/**
* Busca usuário com o id 2.
*/
const expectedUser = users.find(user => user.id === userId)
/**
* Busca o telefone do usuário.
*/
const expectedPhone = phones.find(phone => phone.idUser === userId)
/**
* Expectativa.
*/
expect(foundUser).toMatchObject({
id: expectedUser?.id,
name: expectedUser?.name,
email: expectedUser?.email,
phone: expectedPhone
})
})
test('Não deve retornar informações quando for enviado um id inexistente', () => {
/**
* Id do usuário.
*/
const userId = 9999
/**
* Busca usuário.
*/
const foundUser = findUser(userId)
/**
* Expectativa.
*/
expect(foundUser).toBeUndefined()
})
})
-
No primeiro teste, nós executamos a função e verificamos se o usuário e o telefone encontrados são os corretos.
-
No segundo, validamos se quando for enviado um id de usuário que não exista em nossa lista, ele não retorne nenhuma informação (no caso ele deve retornar undefined).
Testes de E2E
Os testes E2E são aqueles de ponta a ponta. O objetivo é testar um processo da aplicação do início ao fim. Nesse teste é ideal utilizar o cenário real da aplicação, ou seja, realizar a conexão com o banco de dados, utilizar dependências externas, etc. 🧙🏾♂️
Na nossa aplicação vou subir um servidor e realizar uma requisição para buscar um usuário através do seu id.
Agora, vamos criar algumas funções para iniciar e fechar o servidor durante os testes. Aqui você poderia realizar a conexão com o banco ou apenas criar as tabelas.
import { Server } from 'http'
import { server as httpServer } from '~/server'
/**
* Váriaveis de ambiente.
*/
const PORT = 3333
/**
* Servidor.
*/
export let server: Server
/**
* Iniciar o servidor.
*/
export const startServer = async () => {
/**
* Iniciar o servidor.
*/
const app = await httpServer()
server = app.listen(PORT)
}
/**
* Fechar o servidor.
*/
export const closeServer = () => server.close()
Para realizar os testes, vamos fazer requisições e verificar se o retorno é o esperado.
import request from 'supertest'
import { users } from '~/api/user/services/finder'
import { phones } from '~/api/user/services/phone'
import { startServer, closeServer, server } from '../helpers/server'
describe('[E2E] Busca de usuário', () => {
/**
* Iniciar o servidor.
*/
beforeAll(startServer)
/**
* Fechar o servidor.
*/
afterAll(closeServer)
test('Deve retornar as informações de um usuário', async () => {
/**
* Id do usuário.
*/
const userId = 3
/**
* Envio da solicitação.
*/
const foundUser = await request(server).get(`/${userId}`)
/**
* Busca usuário com o id 3.
*/
const expectedUser = users.find(user => user.id === userId)
/**
* Busca o telefone do usuário.
*/
const expectedPhone = phones.find(phone => phone.idUser === userId)
/**
* Expectativa.
*/
expect(foundUser.body).toMatchObject({
id: expectedUser?.id,
name: expectedUser?.name,
email: expectedUser?.email,
phone: expectedPhone
})
})
test('Deve retornar um erro quando for procurado um usuário inexistente', async () => {
/**
* Id do usuário.
*/
const userId = 9999
/**
* Envio da solicitação.
*/
const foundUser = await request(server).get(`/${userId}`)
/**
* Expectativa.
*/
expect(foundUser.body).toMatchObject({ message: 'User not found' })
})
})
-
No primeiro teste, realizamos uma requisição com o id e verificamos se as informações do usuário retornadas estão corretas.
-
No segundo teste, é realizado a requisição com um id que não existe em nossa lista e ele retorna um erro de usuário não encontrado.
Após aplicar todos os testes, podemos rodar um comando para verificar a cobertura de testes da nossa aplicação, ou seja, ver se os testes que escrevemos estão passando em todas as linhas de código. O ideal é que esse número seja o mais próximo de 100 possível. Isso significaria que todas as linhas da aplicação estão sendo testadas.
(Verificando cobertura de testes.)
📖 Conclusão
Os testes são muito eficientes e nos garantem uma grande segurança em futuras implementações. Além de ser um ótimo guia para o desenvolvimento quando utilizamos o TDD
(Test-driven development).
Aplicando as regras apresentadas aqui nesse post, certamente você estará codando de uma forma mais escalável. Quando estiver trabalhando com uma aplicação ‘gigante’, por exemplo, ficará mais seguro sabendo que, se suas modificações interferirem em algo não mapeado, haverá um teste acusando o erro pra você. 😉
Se você quiser ver mais conteúdo sobre testes, temos um post aqui no blog que explica sobre Troféu de testes
.
🔗 Referências
- https://www.devmedia.com.br/teste-unitario-com-jest/41234
- https://www.digite.com/pt-br/agile/testes-unitarios/
- https://medium.com/gtsw/a-pir%C3%A2mide-de-teste-e-os-testes-end-to-end-38f77ad3d137
“Você pode criar qualquer coisa: basta escrever! - C. S. Lewis” 🥸
comments powered by DisqusAnderson Espindola.