Testes unitários

Agora que já aprendemos a fazer a validação de dados numa classe (por meio do tratamento de exceções), vamos dar continuidade à profissionalização do nosso desenvolvimento com a inclusão de um recurso muito importante: os testes unitários.

💡 Entendendo o conceito

Testar um software é uma tarefa fundamental para garantir a sua qualidade. Este tema é estudado de forma aprofundada pela área de Engenharia de Software e pode, em alguns cursos de graduação, ter uma disciplina específica para discutir seus conceitos e características.

De forma bastante resumida, podemos dizer que o teste tem níveis (unidade, componente e sistema) e técnicas (caixa preta, caixa branca). Por enquanto, estamos interessados no menor nível de teste, que é o teste de unidade.

Nesse teste, buscamos validar o funcionamento de cada pequena unidade do sistema, ou seja: classes e os métodos (ou funções, pensando além da orientação a objetos). Note que um teste não garante a inexistência de erros, mas busca revelar a maior quantidade de erros possíveis.

🪄 Testes automatizados, mas não mágicos

Intuitivamente, você realiza uma espécie de teste de software desde que aprendeu a programar. No entanto, realizar os testes manualmente, executando o programa diversas vezes, é inviável.

Atualmente, temos diversas ferramentas que apoiam a execução de testes automatizados, cada uma para uma linguagem de programação. Em Java, existe o JUnit. Em JavaScript (e TypeScript) o Jest.

O uso dessas ferramentas de teste automatizado é, em geral, bastante simples. O segredo está em definir os casos de teste.

🔎 Definindo os casos de teste

Um caso de teste, em linhas gerais, é formado por uma entrada e uma saída esperada. Por exemplo: numa função que identifica se um número é par, temos os seguintes casos de teste:

EntradaSaída esperada
2true
3false
4true
10true
21false

A execução dos testes automatizados consiste em submeter cada entrada ao programa e identificar se a saída real é igual a saída esperada. Se for, dizemos que o teste passou. Se for diferente (por exemplo: o programa retornou false para a entrada 2), dizemos que o teste falhou.

Note, a partir do exemplo acima, que não podemos garantir que o programa funciona adequadamente. Pode ser que, a partir de um número X, ele perca sua funcionalidade correta. Podemos garantir, no entanto, que ele funciona de forma adequada para os casos de teste avaliados. Assim, é importante cobrir diferentes cenários (inputs variados), inclusive casos limites (como números muito grandes ou negativos), para aumentar a cobertura do teste.

👩🏼‍💻 Da teoria à prática

Vamos verificar, na prática, como implementar um teste unitário automatizado utilizando a biblioteca Jest. Esta biblioteca possui uma documentação bastante completa e disponível em Português, que pode ser consultada aqui. Para facilitar, vou reproduzir alguns passos iniciais, mas é importante que vocês se acostumem a consultar as documentações das linguagens e bibliotecas.

Primeiro, precisamos adicionar a dependência ao nosso projeto:

npm install --save-dev jest @types/jest ts-jest
  • jest: A biblioteca de testes.
  • @types/jest: Tipos para Jest, necessários para TypeScript.
  • ts-jest: Permite que o Jest entenda arquivos TypeScript.

Agora que as dependência estão instaladas, vamos à parte prática. Idealmente, cada arquivo deve ter um arquivo complementar, que será o seu arquivo de teste. Veja, na tabela a seguir, um exemplo:

Arquivo de implementaçãoArquivo de teste
Pessoa.tsPessoa.test.ts
Estudante.tsEstudante.test.ts
Venda.tsVenda.test.ts

Para que os testes sejam executados considerando essa nomenclatura, vamos criar um novo arquivo na raiz do projeto, chamado jest.config.ts, com o seguinte conteúdo:

export default {
    preset: 'ts-jest',
    testEnvironment: 'node',
    transform: {
      '^.+\\.ts?$': 'ts-jest',
    },
    moduleFileExtensions: ['ts', 'js'],
    testMatch: ['**/*.test.ts'], 
  };

Precisamos também configurar, no arquivo package.json, o comando a ser executado para o processo de teste. A linha:

"test": "echo \"Error: no test specified\" && exit 1"

deve ser alterada para

"test": "jest"

Agora que já está tudo configurado, vamos aos exemplos.

Exemplo simples

Aproveitando o próprio exemplo dos casos de teste, vamos imaginar uma função que recebe um valor e retorna true se ele for par e false se ele for ímpar:

// ehPar.ts
export function ehPar(valor: number): boolean{
    if(valor % 2 == 0) return true;
    return false;
}

Para testarmos a função, criamos o arquivo ehPar.test.ts, com o seguinte conteúdo:

// ehPar.test.ts
import { ehPar } from './ehPar';

test('2 é par', () => {
  expect(ehPar(2)).toBe(true);
});
> npm run test

PASS  ./ehPar.test.ts
2 é par (3 ms)
  
Test Suites: 1 passed, 1 total                                                                                                                                                    
Tests:       1 passed, 1 total                                                                                                                                                    
Snapshots:   0 total
Time:        2.053 s, estimated 3 s
Ran all test suites.

Note que fizemos apenas um caso de teste simples. Para esse caso, precisamos combinar vários testes. Neste caso, segundo a documentação do Jest, usamos o describe:

// ehPar.test.ts
import { ehPar } from './ehPar';

describe('ehPar', () => {
    test('2 é par', () => {
      expect(ehPar(2)).toBe(true);
    });
    test('3 é ímpar', () => {
      expect(ehPar(3)).toBe(false);
    });
    test('5 é ímpar', () => {
      expect(ehPar(3)).toBe(false);
    });
    test('8 é par', () => {
      expect(ehPar(8)).toBe(true);
    });
});
> npm run test

 PASS  ./ehPar.test.ts
  ehPar
2 é par (3 ms)                                                                                                                                                              
3 é ímpar (1 ms)                                                                                                                                                            
5 é ímpar                                                                                                                                                                   
8 é par (1 ms)                                                                                                                                                              
                                                                                                                                                                                  
Test Suites: 1 passed, 1 total                                                                                                                                                    
Tests:       4 passed, 4 total                                                                                                                                                    
Snapshots:   0 total
Time:        2.189 s
Ran all test suites.

Exemplo completo

Vamos assumir, portanto, que existe uma classe chamada Pessoa, com a seguinte implementação (a mesma usada para explicar o lançamento de exceções):

// Pesssoa.ts
export class Pessoa {
    private _nome: string;

    constructor(nome: string) {
        this.validarNome(nome);
        this._nome = nome;
    }

    set nome(nome: string) {
        this.validarNome(nome);
        this._nome = nome;
    }

    get nome(): string {
        return this._nome;
    }

    private validarNome(nome: string): void {
        if (nome.length < 3) {
            throw new Error("O nome precisa ter pelo menos 3 caracteres.");
        }
    }
}
// Pessoa.test.ts
import { Pessoa } from './Pessoa';

describe('Pessoa', () => {
  test('Pessoa com um nome válido', () => {
    const pessoa = new Pessoa('João');
    expect(pessoa.nome).toBe('João');
  });

  test('Erro se o nome tiver menos de 3 caracteres no construtor', () => {
    expect(() => new Pessoa('Jo')).toThrow(Error);
  });

  test('Erro se o nome tiver menos de 3 caracteres ao usar o setter', () => {
    const pessoa = new Pessoa('Ana');
    expect(() => { pessoa.nome = 'Lu'; }).toThrow(Error);
  });

  test('Atualizar o nome corretamente quando um nome válido for definido', () => {
    const pessoa = new Pessoa('Carlos');
    pessoa.nome = 'Maria';
    expect(pessoa.nome).toBe('Maria');
  });

  test('Não alterar o nome se o setter lançar um erro', () => {
    const pessoa = new Pessoa('Paula');
    try {
      pessoa.nome = 'Jo'; // Nome inválido
    } catch (e) {
      // Ignora o erro
    }
    expect(pessoa.nome).toBe('Paula'); // O nome não foi alterado
  });
});

Finalmente, vejamos um exemplo de execução do teste, a partir do comando npm run test:

> npm run test

PASS  ./Pessoa.test.ts
  Pessoa
    √ Pessoa com um nome válido (4 ms)                                                                                                                                            
    √ Erro se o nome tiver menos de 3 caracteres no construtor (8 ms)                                                                                                             
    √ Erro se o nome tiver menos de 3 caracteres ao usar o setter (1 ms)                                                                                                          
    √ Atualizar o nome corretamente quando um nome válido for definido                                                                                                            
    √ Não alterar o nome se o setter lançar um erro (1 ms)                                                                                                                        
                                                                                                                                                                                  
Test Suites: 1 passed, 1 total                                                                                                                                                    
Tests:       5 passed, 5 total                                                                                                                                                    
Snapshots:   0 total
Time:        1.961 s, estimated 3 s
Ran all test suites.

Observe que os casos de teste para a classe pessoa foram executados em quase 2 segundos. Parece bastante tempo, mas ainda é muito mais rápido que executar o programa diversas vezes (ainda mais inserindo dados via teclado). Vale lembrar, evidentemente, que esse tempo pode variar dependendo do número de testes e da complexidade do projeto.

📚 Para aprender mais

Para entender melhor sobre o tema de teste de software, recomendo os seguintes materiais:

  1. Aula da UnivespTV
  2. Capítulo sobre teste de software
  3. Documentação do JEST