- Blog
- Testes assíncronos em JavaScript
Testes assíncronos em JavaScript
Como garantir qualidade de código em cenários inesperados de testes com callbacks, promises e casos de sucesso e falha
É bem comum lidar com código assíncrono quando estamos criando alguma aplicação. Seja alguma consulta à alguma API externas ou qualquer trecho de código que possa bloquear a execução de nosso software e esteja organizado de maneira assíncrona. Lidar com essas questões essa é uma realidade no nosso dia-a-dia.
Assincronia, por si só, é um tópico a parte e que envolve vários conceitos diferentes e relacionados à forma como os motores JavaScript funcionam e lidam com ações que serão concluídas no futuro.
De qualquer forma, garantir que esses cenários estejam sob nosso controle através dos nossos testes é parte fundamental para que tenhamos confiança no software que escrevemos e é sobre isso que vou falar um pouco hoje.
#O que é assincronia e sua linha do tempo
Quando penso sobre assincronia, particularmente, costumo pensar sobre um código que não sei exatamente o momento que vai ser executado e nem se será executado com sucesso ou falha.
Indo um pouco mais afundo, conseguimos imaginar uma linha do tempo do código assíncrono em JavaScript e suas formas de utilização. Ao fazer isso, temos algo como:
- callbacks;
- promises;
- async/await (que também são Promises mas com um tempero diferente).
Acho que faz sentido dividir o assunto nessas três partes, justamente para moldar nossos casos de teste.
Lembrando que todos os exemplos aqui levam o Jest em consideração como framework de teste mas os fundamentos podem ser reaproveitados pra qualquer ferramenta que você estiver usando. Não esquece que a documentação oficial também é muito boa e pode ser um guia bacana para tirar outras dúvidas.
#Callbacks
Se traduzirmos o termo callback temos algo como "chamar de volta" numa tradução livre. Isso significa que, em algum momento do nosso código, precisamos executar algo assíncrono e registraremos uma função que será "chamada de volta" quando algo acontecer (como um clique em um botão ou uma resposta de um API).
E um exemplo prático de um trecho que utiliza callback mas que nem sempre percebemos é o próprio .addEventListener
. O que esse método faz é justamente registrar um callback (uma função que será executada) quando algum evento ocorrer. Por exemplo:
// cria função de callback
const callback = () => {
alert('Botão clicado');
};
// consulta botão no DOM
const button = document.querySelector('.my-button');
// registra função de callback para o evento click
button.addEventListener('click', callback);
Esse trecho é um exemplo bem prático de utilização de callbacks, já que não sabemos o momento exato que a função callback
será chamada, pois quem estiver utilizando nosso sistema pode clicar no botão o momento que quiser.
Outros exemplos como o setTimeout
também beneficiam-se de callbacks:
// cria função de callback
const callback = () => {
alert('Timer finalizado');
};
// registra função de callback a ser executada após 5000ms
setTimeout(callback, 5000);
O que setTimeout
realiza é justamente o registro de um callback
que será executado após determinado tempo (no exemplo, depois de 5 segundos ou 5000
milissegundos).
Agora vamos aos testes.
Vamos imaginar que temos a função consultaPersonagens
no nosso código, que realizará uma chamada assíncrona à uma API. Não vamos nos preocupar com a implementação dessa função mas sabemos que ela recebo um callbacks como argumento e executa esse callback passando como parâmetro uma lista de personagens existentes.
Ou seja, nossa função deve ser executada mais ou menos assim:
// importa a função de um lugar qualquer
import { consultaPersonagens } from './';
// executa a função passando uma função como callback
consultaPersonagens(personagens => {
// manipula os personagens da API
});
Para facilitar nosso exemplo, vamos imaginar que já realizamos um mock do módulo que realiza essa consulta externa, como vimos no post sobre o assunto e que sabemos que a chamada retornará algo como:
[
{ "name": "anakin skywalker", "nickname": "darth vader" },
{ "name": "leia organa" },
{ "name": "luke skywalker" },
{ "name": "r2-d2" },
{ "name": "c3po" }
]
Parando para analisar a função é fácil pensar que tudo que precisaremos fazer é criar uma função de callback para nosso teste. Algo como:
import { consultaPersonagens } from './';
// faz o mock do módulo que vai fazer a requisição
// ...jest.mock(...)
describe('consultaPersonagens', () => {
// caso de sucesso
it('consulta personagens com sucesso', () => {
// criamos callback de sucesso
const callbackDeSucesso = personagens => {
// realizamos nossas asserções normalmente
expect(personagens.length).toEqual(5);
};
// executamos nossa função passando o callback criado
consultaPersonagens(callbackDeSucesso);
});
});
Porém, isso não funcionará como o esperado. Isso acontece porque o Jest finaliza a execução dos testes assim que nosso bloco dentro das funções it
ou test
terminam.
A forma de fazer com que o Jest "aguarde" as chamadas assíncronas é receber, na declaração do teste (no bloco it
) um parâmetro chamado comumente de done
(que, em inglês, significa finalizado
). Esse parâmetro é uma função e, utilizando-o corretamente dentro de nosso teste o Jest aguardará até que sua execução seja realizada e, se não for chamado, nosso teste falhará.
Adaptando o exemplo anterior, temos algo como:
import { consultaPersonagens } from './';
// faz o mock do módulo que vai fazer a requisição
// ...jest.mock(...)
describe('consultaPersonagens', () => {
// inserimos o done como parâmetro recebido na função it
it('consulta personagens com sucesso', done => {
const callbackDeSucesso = personagens => {
expect(personagens.length).toEqual(5);
// executamos o done após nossas asserções
done();
};
consultaPersonagens(callbackDeSucesso);
});
});
Caso nosso teste não passe e precisemos de um log de erro melhor, no nosso terminal, podemos inserir um try/catch
dentro de nosso callback, da seguinte maneira:
import { consultaPersonagens } from './';
// faz o mock do módulo que vai fazer a requisição
// ...jest.mock(...)
describe('consultaPersonagens', () => {
it('consulta personagens com sucesso', done => {
const callbackDeSucesso = personagens => {
// try/catch adicionado
try {
expect(personagens.length).toEqual(5);
done();
} catch (error) {
done(error);
}
};
consultaPersonagens(callbackDeSucesso);
});
});
Dessa forma poderemos ter um log de erro mais detalhado no terminal ao executar os testes.
#Promises
Assim como fizemos com callbacks, se traduzirmos o termo promise temos algo como "promessa" numa tradução livre e é justamente o que esse objeto nos permite: lidar com a promessa de que teremos um resultado futuro resolvido com sucesso ou erro.
Trabalhar com esse cenário nos testes vai ser mais simples do que o exemplo de callback que fizemos. Vamos manter nossa função consultaPersonagens
em mente, mas vamos imaginar que agora ela utiliza promises e é executada mais ou menos assim:
import { consultaPersonagens } from './';
consultaPersonagens()
.then(personagens => {
// manipula os personagens da API
})
.catch(error => {
// lida com casos de erro
});
Para adaptarmos nosso teste, tudo que precisaremos fazer é retornar uma promise e inserir nossa asserção dentro de um .then
. Simples assim:
import { consultaPersonagens } from './';
// faz o mock do módulo que vai fazer a requisição
// ...jest.mock(...)
describe('consultaPersonagens', () => {
it('consulta personagens com sucesso', () => {
return consultaPersonagens().then(personagens => {
expect(personagens.length).toEqual(5);
});
});
});
O passo importante é se certificar de retornar (return
) uma promise corretamente. Esquecer de retornar esse valor fará com que o Jest complete o teste antes da promise resolver.
E, para o cenário e falha, podemos retornar uma promise com .catch
normalmente. Entretanto, precisaremos adicionar um bloco indicando quantas asserções nossos testes terão, para auxiliar o Jest na missão de identificar quantas asserções teremos no cenário de falha.
Ao fim, teremos um teste mais ou menos assim:
import { consultaPersonagens } from './';
// faz o mock do módulo que vai fazer a requisição
// ...jest.mock(...)
describe('consultaPersonagens', () => {
it('consulta personagens com sucesso', () => {
return consultaPersonagens().then(personagens => {
expect(personagens.length).toEqual(5);
});
});
// caso de erro
it('consulta personagens com erro', () => {
// indicamos quantas asserções teremos
expect.assertions(1);
// retornamos promise que resolverá com erro
return consultaPersonagens().catch(error => {
expect(error).toMatch('error');
});
});
});
Outra forma de lidar com esses cenários de sucesso/error é as funções .resolves
e .rejects
. Particularmente acho mais simples e tudo que precisaremos fazer é retornar essa asserção utilizando agora esses métodos.
Adaptando nossos dois cenários acima, teremos algo como:
import { consultaPersonagens } from './';
// faz o mock do módulo que vai fazer a requisição
// ...jest.mock(...)
describe('consultaPersonagens', () => {
it('consulta personagens com sucesso', () => {
const mockDosPersonagens = [
// ...
];
return expect(consultaPersonagens()).resolves.toBe(mockDosPersonagens);
});
// caso de erro
it('consulta personagens com erro', () => {
return expect(consultaPersonagens()).rejects.toMatch('error');
});
});
Mais prático, não?
#Async/Await
Pra finalizar, vamos imaginar que nossa função consultaPersonagens
continua igual, mas agora vamos utilizar async/await
para os testes. Basta inserir async
na função passada para nosso bloco it
e realizar os testes normalmente com await
. Caso o cenário seja de falha, mantemos nosso bloco expect.assertions
como vimos anteriormente e inserimos try/catch
.
O cenário completo (de sucesso e falha) seria algo como:
import { consultaPersonagens } from './';
// faz o mock do módulo que vai fazer a requisição
// ...jest.mock(...)
describe('consultaPersonagens', () => {
// inserimos async na função do teste
it('consulta personagens com sucesso', async() => {
// utilizamos await normalmente e seguimos com as asserções
const data = await consultaPersonagens();
expect(data.length).toEqual(5);
});
// caso de erro
it('consulta personagens com erro', async () => {
// mantemos a indicação de quantas asserções teremos
expect.assertions(1);
// inserimos um try/catch normalmente
// com a asserção no caso de falha
try {
await consultaPersonagens();
} catch (error) {
expect(error).toMatch('error');
}
});
});
Podemos, inclusive, combinar async/await
com os métodos resolves/rejects
, da seguinte forma:
import { consultaPersonagens } from './';
// faz o mock do módulo que vai fazer a requisição
// ...jest.mock(...)
describe('consultaPersonagens', () => {
it('consulta personagens com sucesso', async() => {
const mockDosPersonagens = [
// ...
];
// agora utilizando resolves
await expect(consultaPersonagens()).resolves.toBe(mockDosPersonagens);
});
it('consulta personagens com erro', async () => {
// agora utilizando rejects
await expect(consultaPersonagens()).rejects.toThrow('error');
});
});
#Agora é só testar!
Já conhecia esses métodos e costuma testar código assíncrono?
Espero que tenha entendido um pouco mais sobre como funciona assíncronia em JavaScript e como realizar esses tipos de teste corretamente. Temos várias alternativas para testar esses trechos e tenho certeza que alguma delas vai te ajudar!