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:
Entrada | Saída esperada |
---|---|
2 | true |
3 | false |
4 | true |
10 | true |
21 | false |
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ção | Arquivo de teste |
---|---|
Pessoa.ts | Pessoa.test.ts |
Estudante.ts | Estudante.test.ts |
Venda.ts | Venda.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: