Gabriel / Ramos

⟨ pintor de pixel ⟩

  • 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

8 min. de leitura

Foto por Skye Studios

É 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!


Compartilhe