Bem-vindo à aula 70 do nosso curso de JavaScript! Hoje vamos mergulhar em um tópico essencial para qualquer desenvolvedor profissional: testes automatizados. Se você já enfrentou aquele momento de pânico ao modificar uma função e descobrir que quebrou outra parte do sistema, sabe o valor de uma boa suíte de testes. Nesta aula, vamos entender por que testar é crucial, conhecer os principais tipos de testes e, na prática, criar nosso primeiro teste usando um runner moderno.

Testar não é apenas uma etapa do desenvolvimento, é uma prática que traz segurança, confiança e qualidade ao código. Ao longo desta aula, você aprenderá a escrever testes de forma eficaz, integrando-os ao seu fluxo de trabalho. Vamos começar explorando as razões para adotar testes em seus projetos.

Por que testar

Imagine que você está construindo uma casa. Você não colocaria o telhado sem antes verificar se as fundações estão sólidas. Da mesma forma, testes automatizados atuam como as fundações do seu software, garantindo que cada parte se comporte como esperado. Testes ajudam a detectar erros cedo, quando são mais baratos e fáceis de corrigir. Eles também funcionam como uma documentação viva do que o código deve fazer, facilitando a colaboração entre times e a integração de novos desenvolvedores.

Além disso, testes proporcionam segurança para refatorar. Em um projeto sem testes, qualquer alteração no código pode introduzir bugs silenciosos. Com uma boa cobertura de testes, você pode modificar implementações internas com a tranquilidade de que, se algo quebrar, os testes irão apontar imediatamente. Isso incentiva a melhoria contínua e a evolução do código sem medo.

Outro ponto crucial é que testes economizam tempo a longo prazo. Embora escrever testes exija esforço inicial, eles reduzem drasticamente o tempo gasto em debugging manual e em correções de regressões. Em projetos de longo prazo, esse retorno é imenso. Além disso, testes bem escritos facilitam a identificação da causa raiz de um problema, pois falham em pontos específicos, indicando exatamente onde está o erro.

Por fim, testes são fundamentais em metodologias ágeis e em práticas como Desenvolvimento Orientado a Testes (TDD), onde os testes são escritos antes mesmo do código de produção. Isso garante que o código seja escrito para atender aos requisitos, e não o contrário. Em suma, testar é uma habilidade indispensável para qualquer desenvolvedor que busca produzir software de qualidade.

Tipos de teste

Existem vários níveis e tipos de teste, cada um com seu propósito e escopo. Os mais comuns no desenvolvimento de software são os testes unitários, de integração e de ponta a ponta (E2E). Cada tipo atua em uma camada diferente da aplicação, desde funções individuais até o fluxo completo do usuário.

Testes unitários são os menores e mais rápidos. Eles verificam o comportamento de uma unidade isolada de código, como uma função ou um método. O objetivo é garantir que cada parte funcione corretamente de forma independente. Por exemplo, se você tem uma função que soma dois números, um teste unitário verificaria se ela retorna a soma esperada para diferentes entradas. Testes unitários são a base da pirâmide de testes e devem ser numerosos.

Testes de integração verificam a interação entre duas ou mais unidades. Eles garantem que módulos que dependem uns dos outros funcionem em conjunto. Por exemplo, testar se uma função que chama uma API e processa a resposta funciona corretamente, simulando a API. Esses testes são mais lentos que os unitários, mas fornecem maior confiança sobre a integração entre componentes.

Testes de ponta a ponta (E2E) simulam o comportamento real do usuário, percorrendo o fluxo completo da aplicação, desde a interface do usuário até o backend e o banco de dados. Eles são os mais lentos e frágeis, mas são essenciais para validar o sistema como um todo. Ferramentas como Cypress e Playwright são comuns para E2E.

Além desses, há outros tipos como testes de desempenho, testes de segurança e testes de aceitação, que são mais específicos. Na prática, uma boa suíte de testes combina esses tipos, priorizando testes unitários pela velocidade e custo, seguidos de integração e alguns E2E para os fluxos críticos. Essa estratégia é conhecida como a pirâmide de testes, proposta por Mike Cohn.

Primeiro teste com um runner moderno

Vamos colocar a mão na massa! Para escrever nosso primeiro teste, utilizaremos o Vitest, um runner de testes moderno, rápido e nativo para projetos Vite. Ele oferece uma API semelhante ao Jest, mas com melhor integração ao ecossistema Vite. Se você ainda não tem um projeto, crie uma pasta e inicialize o npm:

npm init -y
npm install -D vitest

Após instalar, vamos criar um arquivo de teste. Crie um diretório chamado src e dentro dele um arquivo math.js com uma função simples:

// src/math.js
export function add(a, b) {
  return a + b;
}

Agora, crie um arquivo de teste chamado math.test.js no mesmo diretório:

// src/math.test.js
import { describe, it, expect } from 'vitest';
import { add } from './math.js';

describe('Função add', () => {
  it('deve somar dois números corretamente', () => {
    expect(add(2, 3)).toBe(5);
  });

  it('deve somar números negativos', () => {
    expect(add(-1, -1)).toBe(-2);
  });
});

O código acima importa as funções describe, it e expect do Vitest. describe agrupa testes relacionados, it define um caso de teste individual e expect cria asserções. O método .toBe() compara o valor retornado com o esperado.

Para executar os testes, adicione o script no package.json:

"scripts": {
  "test": "vitest run"
}

No terminal, execute npm test. Você verá a saída indicando que os testes passaram. O Vitest também oferece modo watch, que executa os testes automaticamente a cada mudança, usando vitest (sem run).

Esse é o ciclo básico: escrever um teste, executar e verificar se passa. Em projetos reais, você utilizará muito mais recursos, como setup, mocks e cobertura de código. Mas esse é o início de uma jornada que transformará sua forma de programar.

Boas práticas e observações finais

Ao escrever testes, lembre-se de seguir algumas boas práticas. Primeiro, mantenha os testes simples e focados: cada teste deve verificar apenas um comportamento. Use nomes descritivos que indiquem o que está sendo testado e o resultado esperado. Evite testar implementações internas; foque no comportamento público das funções ou módulos.

Outra prática importante é isolar testes: eles não devem depender de estado global ou de outros testes. Cada teste deve ser independente e poder rodar em qualquer ordem. Isso é facilitado por runners como Vitest, que executam cada arquivo em um ambiente isolado.

Além disso, não se esqueça de testar casos de borda, como entradas vazias, valores nulos ou limites. Esses são os casos que mais quebram em produção. E, por fim, integre os testes ao seu pipeline de CI/CD para que sejam executados automaticamente a cada push, garantindo que nenhuma alteração quebre o código existente.

Referências

Exercícios

  1. Crie uma função subtract(a, b) que retorne a subtração de dois números. Escreva dois testes unitários: um para números positivos e outro para números negativos.

    ✓ Resposta:
    // src/subtract.js
    export function subtract(a, b) {
      return a - b;
    }
    
    // src/subtract.test.js
    import { describe, it, expect } from 'vitest';
    import { subtract } from './subtract.js';
    
    describe('Função subtract', () => {
      it('deve subtrair dois números positivos', () => {
        expect(subtract(10, 4)).toBe(6);
      });
    
      it('deve subtrair dois números negativos', () => {
        expect(subtract(-2, -3)).toBe(1);
      });
    });
  2. Escreva um teste para uma função multiply(a, b) que verifique que ela retorna 0 quando qualquer um dos argumentos é 0.

    ✓ Resposta:
    // src/multiply.js
    export function multiply(a, b) {
      return a * b;
    }
    
    // src/multiply.test.js
    import { describe, it, expect } from 'vitest';
    import { multiply } from './multiply.js';
    
    describe('Função multiply', () => {
      it('deve retornar 0 quando o primeiro argumento é 0', () => {
        expect(multiply(0, 5)).toBe(0);
      });
    
      it('deve retornar 0 quando o segundo argumento é 0', () => {
        expect(multiply(5, 0)).toBe(0);
      });
    });
  3. Considere a função divide(a, b) que lança um erro quando b é zero. Escreva um teste que verifique que o erro é lançado.

    ✓ Resposta:
    // src/divide.js
    export function divide(a, b) {
      if (b === 0) {
        throw new Error('Divisão por zero não é permitida');
      }
      return a / b;
    }
    
    // src/divide.test.js
    import { describe, it, expect } from 'vitest';
    import { divide } from './divide.js';
    
    describe('Função divide', () => {
      it('deve lançar erro ao dividir por zero', () => {
        expect(() => divide(10, 0)).toThrow();
      });
    
      it('deve dividir normalmente quando o divisor é diferente de zero', () => {
        expect(divide(10, 2)).toBe(5);
      });
    });
  4. Crie uma função isEven(num) que retorna true se o número for par e false caso contrário. Escreva testes para números pares, ímpares e para o número zero.

    ✓ Resposta:
    // src/isEven.js
    export function isEven(num) {
      return num % 2 === 0;
    }
    
    // src/isEven.test.js
    import { describe, it, expect } from 'vitest';
    import { isEven } from './isEven.js';
    
    describe('Função isEven', () => {
      it('deve retornar true para números pares', () => {
        expect(isEven(4)).toBe(true);
      });
    
      it('deve retornar false para números ímpares', () => {
        expect(isEven(7)).toBe(false);
      });
    
      it('deve retornar true para zero', () => {
        expect(isEven(0)).toBe(true);
      });
    });
  5. Escreva um teste que verifique que uma função assíncrona fetchUser(id) retorna um objeto com as propriedades esperadas, usando um mock simples.

    ✓ Resposta:
    // src/fetchUser.js
    export async function fetchUser(id) {
      const response = await fetch(`https://api.example.com/users/${id}`);
      return response.json();
    }
    
    // src/fetchUser.test.js
    import { describe, it, expect, vi } from 'vitest';
    import { fetchUser } from './fetchUser.js';
    
    describe('Função fetchUser', () => {
      it('deve retornar os dados do usuário', async () => {
        const mockResponse = { id: 1, name: 'João' };
        global.fetch = vi.fn().mockResolvedValue({
          json: vi.fn().mockResolvedValue(mockResponse)
        });
    
        const user = await fetchUser(1);
        expect(user).toEqual(mockResponse);
        expect(global.fetch).toHaveBeenCalledWith('https://api.example.com/users/1');
      });
    });