Escrever testes é uma habilidade essencial para qualquer desenvolvedor que queira entregar software confiável e fácil de manter. Nesta aula, vamos explorar na prática como escrever testes unitários, utilizar mocks para simular dependências, medir a cobertura de código e testar funções assíncronas. Você verá exemplos reais com JavaScript e Jest, um dos frameworks de teste mais populares, e aprenderá a aplicar esses conceitos em seus próprios projetos.

Testar não é apenas uma formalidade; é uma forma de documentar o comportamento esperado do código e de prevenir regressões. Ao dominar essas técnicas, você conseguirá criar suítes de teste robustas que aumentam a confiança no código e facilitam a colaboração em equipe.

Unitários

Testes unitários são a base da pirâmide de testes. Eles verificam o comportamento de uma unidade isolada de código, geralmente uma função ou método, de forma independente do restante do sistema. O objetivo é garantir que cada função faça exatamente o que se espera, com entradas e saídas previsíveis. Para escrever bons testes unitários, é importante seguir o padrão AAA (Arrange, Act, Assert): preparar os dados, executar a função e verificar o resultado.

Vamos considerar um exemplo simples: uma função que calcula o fatorial de um número. Escreveremos testes para os casos mais comuns, incluindo casos de borda, como 0 e 1, e números negativos.

// fatorial.js
function fatorial(n) {
  if (n < 0) throw new Error('Número deve ser não negativo');
  if (n === 0 || n === 1) return 1;
  return n * fatorial(n - 1);
}

module.exports = fatorial;
// fatorial.test.js
const fatorial = require('./fatorial');

describe('Função fatorial', () => {
  test('calcula fatorial de 0', () => {
    expect(fatorial(0)).toBe(1);
  });

  test('calcula fatorial de 1', () => {
    expect(fatorial(1)).toBe(1);
  });

  test('calcula fatorial de 5', () => {
    expect(fatorial(5)).toBe(120);
  });

  test('lança erro para números negativos', () => {
    expect(() => fatorial(-1)).toThrow('Número deve ser não negativo');
  });
});

Nesse exemplo, usamos describe para agrupar testes relacionados e test para definir cada caso. O Jest fornece uma ampla variedade de matchers, como toBe para igualdade estrita e toThrow para verificar exceções. Ao rodar os testes com npm test, o Jest executa cada caso e reporta se passou ou falhou.

Mocks

Mocks são objetos simulados que imitam o comportamento de dependências reais. Eles são essenciais para isolar a unidade sob teste, evitando que fatores externos (como chamadas de API, acesso a banco de dados ou relógio do sistema) interfiram nos resultados. Com mocks, podemos controlar as respostas das dependências e verificar se elas foram chamadas corretamente.

O Jest oferece funções de mock (jest.fn()) e a capacidade de substituir módulos inteiros com jest.mock(). Vamos criar um exemplo em que uma função processUser depende de uma API externa para buscar dados de um usuário.

// api.js
async function fetchUser(id) {
  const response = await fetch(`https://api.exemplo.com/users/${id}`);
  return response.json();
}

module.exports = { fetchUser };
// processUser.js
const api = require('./api');

async function processUser(id) {
  const user = await api.fetchUser(id);
  return `Nome: ${user.name}, Email: ${user.email}`;
}

module.exports = { processUser };
// processUser.test.js
const { processUser } = require('./processUser');
const api = require('./api');

jest.mock('./api');

test('deve retornar uma string formatada com dados do usuário', async () => {
  // Configura o mock para retornar um objeto fixo
  api.fetchUser.mockResolvedValue({ name: 'Maria', email: 'maria@example.com' });

  const result = await processUser(1);

  expect(result).toBe('Nome: Maria, Email: maria@example.com');
  // Verifica se fetchUser foi chamada com o argumento correto
  expect(api.fetchUser).toHaveBeenCalledWith(1);
});

Nesse exemplo, jest.mock('./api') substitui o módulo api por versões mockadas automaticamente. Em seguida, usamos mockResolvedValue para definir o valor que a promessa deve resolver. Isso nos permite testar processUser sem fazer requisições reais, tornando o teste rápido e determinístico. Também verificamos que a dependência foi chamada com o argumento esperado usando toHaveBeenCalledWith.

Cobertura

Cobertura de testes é uma métrica que indica qual porcentagem do código está sendo exercitada pelos testes. Ela ajuda a identificar áreas não testadas e a avaliar a qualidade da suíte de testes. O Jest possui cobertura integrada e pode gerar relatórios detalhados, mostrando cobertura por arquivo, por função, por linha e por branch.

Para habilitar a cobertura, basta executar o Jest com a flag --coverage. Vamos ver um exemplo de relatório e como interpretá-lo.

npm test -- --coverage

O relatório exibirá algo como:

----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files |   80    |    50    |   75    |   80    |
 fatorial.js |  100  |    100   |  100    |  100    |
 api.js      |    0  |      0   |    0    |    0    | 1-5
----------|---------|----------|---------|---------|-------------------

É importante lembrar que cobertura não é garantia de qualidade: ter 100% de cobertura não significa que os testes são bons. Porém, uma cobertura baixa indica lacunas que merecem atenção. Ferramentas de cobertura também podem ser integradas a pipelines de CI para garantir que a cobertura não caia abaixo de um limite definido.

Testando código assíncrono

JavaScript é assíncrono por natureza, e testar código assíncrono pode ser desafiador. O Jest oferece várias formas de lidar com isso: callbacks, promises e async/await. O mais recomendado é usar async/await nos testes, pois torna o código mais legível e evita problemas com callbacks aninhados.

Vamos criar um exemplo com uma função que busca dados de um usuário e usa async/await no teste.

// userService.js
const api = require('./api');

async function getUserInfo(id) {
  const user = await api.fetchUser(id);
  return user;
}

module.exports = { getUserInfo };
// userService.test.js
const { getUserInfo } = require('./userService');
const api = require('./api');

jest.mock('./api');

test('deve retornar dados do usuário', async () => {
  const mockUser = { id: 1, name: 'João' };
  api.fetchUser.mockResolvedValue(mockUser);

  const user = await getUserInfo(1);

  expect(user).toEqual(mockUser);
});

test('deve lidar com rejeição da promise', async () => {
  api.fetchUser.mockRejectedValue(new Error('Falha na requisição'));

  await expect(getUserInfo(1)).rejects.toThrow('Falha na requisição');
});

No primeiro teste, usamos async/await para esperar a resolução da promessa. No segundo, verificamos que a promessa é rejeitada com um erro específico usando .rejects.toThrow(). Também é possível testar com o padrão de promises usando .resolves e .rejects.

Outra situação comum é testar funções que usam setTimeout ou timers. O Jest permite usar jest.useFakeTimers() para controlar o tempo e avançar manualmente com jest.advanceTimersByTime(). Isso evita esperas reais e torna os testes mais rápidos.

// timer.js
function delay(callback, ms) {
  setTimeout(callback, ms);
}

module.exports = { delay };
// timer.test.js
const { delay } = require('./timer');

jest.useFakeTimers();

test('deve chamar o callback após o tempo especificado', () => {
  const callback = jest.fn();
  delay(callback, 1000);

  expect(callback).not.toHaveBeenCalled();

  jest.advanceTimersByTime(1000);

  expect(callback).toHaveBeenCalledTimes(1);
});

Boas práticas e observações finais

Escrever testes de qualidade requer prática e atenção a alguns princípios. Prefira testar comportamento em vez de implementação: isso torna os testes menos frágeis. Mantenha os testes independentes e isolados, evitando dependências entre eles. Use nomes descritivos para os testes, que expliquem o que está sendo verificado. E lembre-se de que mocks devem ser usados com moderação: se você mockar tudo, os testes podem não refletir a realidade.

Por fim, integre os testes ao seu fluxo de desenvolvimento, rodando-os frequentemente. Ferramentas como Jest oferecem modo watch, que executa os testes automaticamente a cada alteração, ajudando a detectar problemas rapidamente.

Referências

Exercícios

  1. Escreva uma função soma que receba dois números e retorne a soma. Crie testes unitários para essa função, incluindo casos com números negativos e zero.

    ✓ Resposta:
    function soma(a, b) {
      return a + b;
    }
    
    // testes
    const soma = require('./soma');
    
    test('soma números positivos', () => {
      expect(soma(2, 3)).toBe(5);
    });
    
    test('soma com zero', () => {
      expect(soma(0, 5)).toBe(5);
    });
    
    test('soma com negativos', () => {
      expect(soma(-1, -2)).toBe(-3);
    });
    
  2. Dada a função getUser que faz uma chamada a uma API, escreva um teste usando mock para verificar se ela retorna o objeto esperado e se a API foi chamada com o ID correto.

    ✓ Resposta:
    // getUser.js
    const api = require('./api');
    
    async function getUser(id) {
      return api.fetchUser(id);
    }
    
    // getUser.test.js
    const { getUser } = require('./getUser');
    const api = require('./api');
    
    jest.mock('./api');
    
    test('deve retornar o usuário e chamar a API com o ID', async () => {
      const mockUser = { id: 1, name: 'Ana' };
      api.fetchUser.mockResolvedValue(mockUser);
    
      const user = await getUser(1);
    
      expect(user).toEqual(mockUser);
      expect(api.fetchUser).toHaveBeenCalledWith(1);
    });
    
  3. Execute o Jest com cobertura em um projeto simples e explique o que o relatório mostra. (Resposta em texto)

    ✓ Resposta:

    O relatório mostra a porcentagem de statements, branches, funções e linhas cobertas por arquivo. Por exemplo, se um arquivo tem 80% de cobertura de linhas, significa que 80% das linhas executáveis foram executadas durante os testes. Isso ajuda a identificar áreas não testadas.

  4. Escreva um teste para uma função assíncrona que retorna uma promessa rejeitada. Use async/await e verifique a rejeição.

    ✓ Resposta:
    // asyncFunc.js
    function asyncFunc() {
      return Promise.reject(new Error('Falha'));
    }
    
    // asyncFunc.test.js
    const asyncFunc = require('./asyncFunc');
    
    test('deve rejeitar com erro', async () => {
      await expect(asyncFunc()).rejects.toThrow('Falha');
    });
    
  5. Você tem uma função que usa setTimeout para chamar um callback após 5 segundos. Escreva um teste usando timers falsos para verificar que o callback só é chamado após o tempo avançar.

    ✓ Resposta:
    // delay.js
    function delay(callback, ms) {
      setTimeout(callback, ms);
    }
    
    // delay.test.js
    const { delay } = require('./delay');
    
    jest.useFakeTimers();
    
    test('callback só é chamado após o tempo', () => {
      const callback = jest.fn();
      delay(callback, 5000);
    
      expect(callback).not.toHaveBeenCalled();
    
      jest.advanceTimersByTime(5000);
    
      expect(callback).toHaveBeenCalledTimes(1);
    });