Testes Unitários com Jest e Typescript
Este artigo busca trazer de maneira exemplificada a construção de um suíte de testes unitários, aplicando boas práticas de desenvolvimento de testes e demonstrando como o desenvolvimento desse tipo de testes pode ser simples e extremamente útil para a sua aplicação.
Por que escrever testes unitários ?
Testes unitários ajudam a manter a integridade do código, ou seja, garantir que o funcionamento do mesmo se mantenha íntegro e resistente a modificações errôneas que podem alterar o funcionamento esperado da aplicação.
O que os testes devem cobrir ?
Testes unitários devem cobrir toda sua função, sem exceções. Alguns dos pontos a serem testados são:
- Casos de exceção.
- Casos de sucesso.
- Parâmetros de métodos.
- Quantidade de vezes em que uma função é executada.
- Retornos.
- Comportamento baseado em diferentes cenários de comportamento das dependências.
Estrutura de um teste
Assim como o código de uma feature, os testes também devem seguir um padrão de boas práticas para que sejam de fácil entendimento. Uma estrutura bem conhecida de testes é a chamada Triple A (Arrange, Act, Assert):
- Arrange: Essa primeira etapa é referente a preparação para o teste, ou seja, criação de massas de teste, instanciação de classes e etc.
- Act: A segunda etapa é referente às ações do teste, ou seja, execução do método a ser testado, manipulação de valores de mocks e coisas do tipo.
- Assert: A última etapa é onde acontece a validação dos resultados do teste, ou seja, essa etapa conterá todos os expects referentes a cada item testado.
Aplicando os conceitos
Agora que já é possível ter uma ideia do que um teste precisa validar e como estruturá-lo, a utilização desses conceitos será demonstrada de maneira prática. Para isso, será desenvolvido um suite de testes para a classe a seguir:
class CreateUserUseCase implements UseCase<UserInput, User> {
constructor(
private readonly validateUserInput: Validator<UserInput>,
private readonly createUserRepository: Repository<UserInput, User>
) {}
async execute(userInput: UserInput) {
if (!userInput) throw new MissingParamError("userInput");
const { isValid, fieldErrors } = await this.validateUserInput.execute(
userInput
);
if (!isValid) throw new ValidationError(fieldErrors);
return await this.createUserRepository.execute(userInput);
}
}
Passo 1: Entendendo a Classe
Esse primeiro passo é muito importante, pois para testar um trecho de código com assertividade, é necessário entender por completo todas as ações realizadas por ele. Portanto, segue uma breve explicação das ações realizadas pela classe.
- Essa classe é um UseCase responsável pela criação de um usuário.
- Implementa uma interface genérica de UseCase que não necessita de muita explicação.
- Recebe duas injeções de dependências (Dependency Injection), um validador de payload de usuário e um repositório de criação de usuário (Repository Pattern).
Possuindo apenas um método chamado“execute”, a classe realiza as seguintes ações:
- Recebe um UserInput como parâmetro.
- Valida se o parâmetro possui algum valor.
- Valida os campos do parâmetro recebido com o validador de usuário.
- Caso o parâmetro seja inválido, um erro de validação é emitido.
- Caso contrário, retorna o resultado da criação de um usuário utilizando o repositório.
Passo 2: Criando o Suite de Testes e Nomeando Testes
O suite de testes nada mais é que o bloco contendo todos os testes referentes a essa classe. Como uma boa prática na escrita de testes, é interessante manter um único bloco de teste para cada classe a ser testada.
Após isso, o próximo é um ponto muito importante que é o de identificação dos testes. Parece uma coisa simples, porém, isso afeta diretamente a compreensão de seus testes e identificação dos mesmos.
Com o Jest, existem dois tipos de identificação, uma é referente ao bloco de testes e outra referente a cada teste unitariamente.
// Identificação do bloco de teste
describe("NOME DA CLASSE A SER TESTADA", () => {
// Identificação do teste
test("Descrição das ações do teste", () => {
// Conteúdo do teste
});
});
Como já citado anteriormente, a construção de um teste deve seguir os mesmos princípios seguidos no desenvolvimento das próprias features, sendo assim, os testes devem ser de fácil entendimento e ter apenas uma única responsabilidade, pois a identificação do teste pode ser mais complicada, caso esses princípios não sejam seguidos.
Por exemplo, tente descrever de forma sucinta o seguinte teste:
// Identificação do bloco de teste
describe("GerenciarUsuario.ts", () => {
// Identificação do teste
test("DESCRIÇ O A SER PREENCHIDA", async () => {
const classeParaTeste = new GerenciarUsuario();
const usuarioCriado = await classeParaTeste.criarUsuario(
parametrosCorretos
);
const usuarioAtualizado = await classeParaTeste.atualizarUsuario(
usuarioCriado.id,
{ nome: "Novo Nome" }
);
const usuarioNaoAtualizado = await classeParaTeste.atualizarUsuario(
usuarioCriado.id,
{ nome: "" }
);
const usuarioDeletado = await classeParaTeste.deletarUsuario(
usuarioCriado.id
);
expect(usuarioCriado.id).toBeDefined();
expect(usuarioNaoAtualizado.mensagem).toBe("Campo nome deve ser válido");
expect(usuarioAtualizado.nome).toBe("Novo Nome");
expect(usuarioDeletado).toBeTruthy();
});
});
Considerando que a identificação é uma String e deve descrever de maneira clara e direta o que está sendo testado, quando há um teste complexo e que faz N coisas como esse, a identificação é muito mais complicada.
Sabendo disso, segue um teste que respeita os princípios comentados acima:
describe("CreateUserUseCase.ts", () => {
test("CreateUserUseCase should be defined", () => {
const createUserUseCase = new CreateUserUseCase();
expect(createUserUseCase).toBeDefined();
});
});
Note que como o teste realiza apenas uma validação, a descrição do mesmo é bem simples.
Passo 3: Criando uma Base para a Escrita dos Testes
Criação de Spies
A classe a ser testada, exige a injeção de duas dependências, então, para instanciar essa classe, se faz necessário um validador e de um repositório. Portanto, a criação das dependências fica da seguinte maneira:
class ValidateUserInputSpy {
async execute() {}
}
class CreateUserRepositorySpy {
async execute() {}
}
Como essas duas classes não são implementações reais, pode-se dizer que são Spies (Tecnicamente ainda não são spies, mas o momento de implementá-los chegará!).
Agora, com pequenos ajustes nas validações do Typescript, o teste de exemplo da etapa poderia ser executado com sucesso!
describe("CreateUserUseCase.ts", () => {
test("CreateUserUseCase should be defined", () => {
const validatorSpy = new ValidateUserInputSpy();
const repositorySpy = new CreateUserRepositorySpy();
const createUserUseCase = new CreateUserUseCase(
validatorSpy as unknown as Validator<UserInput>,
repositorySpy as unknown as Repository<UserInput, User>
);
expect(createUserUseCase).toBeDefined();
});
});
Obs: Por serem apenas simulações das classes verdadeiras, não existe a necessidade de implementa-las por completo.
Criação de Massas de Teste
Como já explicado no Passo 1, a classe recebe um UserInput, sendo esse objeto uma referência direta a uma interface. Portanto, para testar essa classe, é necessário definir esse parâmetro.
Sendo assim, vamos criar nosso input base:
interface UserInput {
name: string;
document: string;
birthDate: Date;
}
const userInput: UserInput = {
name: "Nome de Teste",
document: "999999999999",
birthDate: new Date("10-10-1980"),
};
E para finalizar, apenas a título de curiosidade, segue a definição das demais interfaces e implementações de erro utilizadas na classe:
class MissingParamError extends Error {
constructor(param: string) {
super();
}
}
class ValidationError extends Error {
constructor(fieldErrors: {}) {
super();
}
}
interface UseCase<Input, Output> {
execute: (input: Input) => Promise<Output>;
}
interface Repository<Input, Output> {
execute: (input: Input) => Promise<Output>;
}
interface Validator<Input> {
execute: (input: Input) => Promise<{ isValid: boolean; fieldErrors: {} }>;
}
Abstraindo a Criação da Base dos Testes
A partir desse ponto, tudo o que é necessário para o desenvolvimento dos testes foi definido, porém, como já dito anteriormente, os testes devem seguir os mesmos padrões de código aplicados nas funcionalidades. Um dos padrões populares entre os desenvolvedores é o de não repetir código, então, segue um exemplo de como ficariam dois testes, caso esse padrão não seja seguido:
const userInput: UserInput = {
name: "Nome de Teste",
document: "999999999999",
birthDate: new Date("10-10-1980"),
};
describe("CreateUserUseCase.ts", () => {
test("CreateUserUseCase should be defined", () => {
const validatorSpy = new ValidateUserInputSpy();
const repositorySpy = new CreateUserRepositorySpy();
const createUserUseCase = new CreateUserUseCase(
validatorSpy as unknown as Validator<UserInput>,
repositorySpy as unknown as Repository<UserInput, User>
);
expect(createUserUseCase).toBeDefined();
});
test("Should create a user", () => {
// Arrange
const validatorSpy = new ValidateUserInputSpy();
const repositorySpy = new CreateUserRepositorySpy();
const createUserUseCase = new CreateUserUseCase(
validatorSpy as unknown as Validator<UserInput>,
repositorySpy as unknown as Repository<UserInput, User>
);
// Act
const createdUser = await createUserUseCase.execute(userInput);
// Assert
expect(createdUser.id).toBeDefined();
});
});
É visível como os testes estão extremamente verbosos, com repetição desnecessária de criação de instâncias e coisas do tipo. Portanto, para resolver esse problema, é aplicado o Factory Design Pattern.
A aplicação desse padrão de código será bem simples, porém já será possível extrair todas as vantagens fornecidas por ele. Sendo assim, um método chamado makeSut será criado.
function makeSut() {
const validatorSpy = new ValidateUserInputSpy();
const repositorySpy = new CreateUserRepositorySpy();
const sut = new CreateUserUseCase(
validatorSpy as unknown as Validator<UserInput>,
repositorySpy as unknown as Repository<UserInput, User>
);
const userInput: UserInput = {
name: "Nome de Teste",
document: "999999999999",
birthDate: new Date("10-10-1980"),
};
return {
sut,
validatorSpy,
repositorySpy,
userInput,
};
}
Dessa maneira é possível, em uma única linha de código, criar as instâncias dos spies, a instância da classe em teste, no caso a constante sut (System Under Test, é um padrão de nomenclatura que auxilia na identificação da classe testada, evitando confusões com as demais instanciações no código) e a massa de teste.
Além de diminuir a verbosidade dos testes, caso seja necessário alterar alguma informação do userInput em algum teste em específico, por exemplo, isso não terá efeito em nenhum outro teste. Basicamente, o que esse método faz é encapsular cada teste e suas dependências, acabando com os problemas de conflitos entre testes.
De acordo com a necessidade, vários tipos de factories podem ser criados, como por exemplo uma factory que fornece usuários com campos diferentes ou coisas do tipo. Além disso, é nesse ponto que seus testes podem ser enriquecidos com bibliotecas como o Faker.
Aplicando isso nos testes de exemplo, é visível como eles ficam bem menos verbosos:
describe("CreateUserUseCase.ts", () => {
test("CreateUserUseCase should be defined", () => {
const { sut } = makeSut();
expect(sut).toBeDefined();
});
test("Should create a user", () => {
// Arrange
const { sut, userInput } = makeSut();
// Act
const createdUser = await sut.execute(userInput);
// Assert
expect(createdUser.id).toBeDefined();
});
});
Passo 4: Escrevendo os Testes
Relembrando a classe a ser testada:
class CreateUserUseCase implements UseCase<UserInput, User> {
constructor(
private readonly validateUserInput: Validator<UserInput>,
private readonly createUserRepository: Repository<UserInput, User>
) {}
async execute(userInput: UserInput) {
if (!userInput) throw new MissingParamError("userInput");
const { isValid, fieldErrors } = await this.validateUserInput.execute(
userInput
);
if (!isValid) throw new ValidationError(fieldErrors);
return await this.createUserRepository.execute(userInput);
}
}
Agora, para a escrita dos testes, deve-se testar a classe por inteiro, seguindo a ordem do começo ao fim.
Teste 1: Classe deve ser instanciável
Por se tratar de uma classe, o primeiro teste refere-se a instanciação da mesma.
describe("CreateUserUseCase.ts", () => {
test("CreateUserUseCase should be defined", () => {
const { sut } = makeSut();
expect(sut).toBeDefined();
});
});
Pela baixa complexidade desse teste, não há muito o que comentar, mas com ele já é possível identificar pontos como a descrição do teste que é concisa e direta, além da aplicação dos design patterns que torna o teste muito mais palatável e compreensível, por remover a verbosidade.
Basicamente, esse teste garante que o módulo sempre seja uma classe. Por ser muito simples, o teste não precisa atender aos requisitos do AAA.
Teste 2: Deve emitir um MissingParamError caso o userInput não seja provisionado
Esse segundo teste já parte para dentro do método execute. Ele precisa garantir que, no caso do parâmetro userInput ser indefinido, um MissingParamError é emitido.
describe("CreateUserUseCase.ts", () => {
test("CreateUserUseCase should be defined", () => {
const { sut } = makeSut();
expect(sut).toBeDefined();
});
test("Should throw a MissingParamError if input param was not provided", async () => {
// Arrange
const { sut } = makeSut();
// Act
const result = sut.execute(null as unknown as UserInput);
// Assert
await expect(result).rejects.toThrow(new MissingParamError("userInput"));
});
});
Utilizando de algumas estratégias do typescript é possível chamar a função com um parâmetro null, fazendo com que caia no caso de teste.
Note que foi possível aplicar o AAA e toda a ação desse caso de teste é validada, desde o tipo do erro emitido (MissingParamError), até o conteúdo do erro (“userInput”). Testando todas as características do caso é possível tornar a função bem mais íntegra, pois qualquer modificação nessa linha do código fará com que o teste falhe.
describe("CreateUserUseCase.ts", () => {
test("CreateUserUseCase should be defined", () => {
const { sut } = makeSut();
expect(sut).toBeDefined();
});
test("Should throw a MissingParamError if input param was not provided", async () => {
// Arrange
const { sut } = makeSut();
// Act
const result = sut.execute(null as unknown as UserInput);
// Assert
await expect(result).rejects.toThrow(new MissingParamError("userInput"));
});
});
Teste 3: Deve emitir um ValidationError com os campos de erro se o parâmetro de input for inválido
O terceiro teste proporciona uma discussão interessante sobre o escopo de testes. Por exemplo, pode-se dizer que para testar uma função, deve-se testar todas suas dependências, certo?
Errado!
O foco do bloco de testes atual é validar a classe CreateUserUseCase, não validar suas dependências como o validateUserInput e o createUserRepository. A validação do código das dependências da classe devem ter seu próprio arquivo de testes!
Nesse momento, precisamos validar apenas o comportamento da classe em teste, com os diferentes retornos possíveis das dependências.
describe("CreateUserUseCase.ts", () => {
# ...
test("Should throw a MissingParamError if input param was not provided", async () => {
// Arrange
const { sut } = makeSut();
// Act
const result = sut.execute(null as unknown as UserInput);
// Assert
await expect(result).rejects.toThrow(new MissingParamError("userInput"));
});
test("Should throw a ValidationError with field errors if input fields are invalid", async () => {
// Arrange
const { sut, validatorSpy, userInput } = makeSut();
const validatorResult = {
isValid: false,
fieldErrors: { name: "Name is incomplete", document: "Document is invalid" },
};
// Act
validatorSpy.valueToReturn = validatorResult;
const result = sut.execute(userInput);
// Assert
await expect(result).rejects.toThrow(
new ValidationError(validatorResult.fieldErrors)
);
});
});
Esse teste exige a implementação do ValidateUserInputSpy, portanto, segue a adaptação classe de spy para lidar com as novas necessidades do teste.
class ValidateUserInputSpy {
valueToReturn = { isValid: true };
async execute(usedParams) {
return this.valueToReturn;
}
}
Como já visto no teste anterior, aqui o tipo do erro retornado e o conteúdo do mesmo também são validados, proporcionando que o teste falhe, caso alguém remova o fieldErros de dentro do throw new ValidationError por exemplo. Além disso, o comportamento da função, no caso de um retorno de input inválido da dependência, também é validado.
Note também como o teste é encapsulado e como a manipulação do retorno da dependência é de fácil acesso.
Teste 4: Deve chamar o validador apenas uma vez com o parâmetro userInput
O teste anterior validou o caso de exceção desse trecho do código. Agora, será feita a validação dos parâmetros e das chamadas ao validador, considerando que seu retorno já foi validado.
describe("CreateUserUseCase.ts", () => {
# ...
test("Should throw a ValidationError with field errors if input fields are invalid", async () => {
// Arrange
const { sut, validatorSpy, userInput } = makeSut();
const validatorResult = {
isValid: false,
fieldErrors: { name: "Name is incomplete", document: "Document is invalid" },
};
// Act
validatorSpy.valueToReturn = validatorResult;
const result = sut.execute(userInput);
// Assert
await expect(result).rejects.toThrow(
new ValidationError(validatorResult.fieldErrors)
);
});
test("Should call validator only one time with user input", async () => {
const { sut, validatorSpy, userInput } = makeSut();
await sut.execute(userInput);
expect(validatorSpy.calls).toBe(1);
expect(validatorSpy.usedParams).toEqual(userInput);
});
});
Agora, o comportamento esperado do spy foi modificado novamente, portanto, serão adicionados novos campos necessários para o teste executar com perfeição.
class ValidateUserInputSpy {
usedParams;
calls = 0;
valueToReturn = { isValid: true };
async execute(usedParams) {
this.usedParams = usedParams;
this.calls++;
return this.valueToReturn;
}
}
Esse teste é bem mais simplificado, pois não necessita da alteração de nenhum retorno de dependência, já que a mesma tem um valor padrão de retorno para casos válidos (Essa também é uma boa dica, pois ajuda a remover a complexidade de alguns testes, tornando necessário a modificação do retorno das dependências apenas em casos de exceção).
Após esse teste, todos os cenários referentes ao validador foram testados, mesmo não tendo um teste explícito de sucesso para o validador, como em um teste de falha o mesmo emite um erro, caso o erro não seja emitido, significa que houve um sucesso na validação. Portanto, todos os casos foram validados.
Teste 5: Deve chamar o CreateUserRepository apenas uma vez com o parâmetro userInput
Esse teste é similar ao teste anterior, porém ele trabalha com a segunda dependência do projeto, que é um repository. Portanto, o teste é bem parecido.
describe("CreateUserUseCase.ts", () => {
...
test("Should call validator only one time with user input", async () => {
// Arrange
const { sut, validatorSpy, userInput } = makeSut();
// Act
await sut.execute(userInput);
// Assert
expect(validatorSpy.calls).toBe(1);
expect(validatorSpy.usedParams).toEqual(userInput);
});
test("Should call repository only one time with user input", async () => {
// Arrange
const { sut, repositorySpy, userInput } = makeSut();
// Act
await sut.execute(userInput);
// Assert
expect(repositorySpy.calls).toBe(1);
expect(repositorySpy.usedParams).toEqual(userInput);
});
});
Nesse caso, também é preciso implementar o CreateUserRepositorySpy para lidar com as necessidades de teste.
class CreateUserRepositorySpy {
usedParams;
calls = 0;
async execute(usedParams) {
this.usedParams = usedParams;
this.calls++;
}
}
A ideia desse tipo de teste é garantir que o parâmetro correto está sendo passado para a dependência e também garantir que a mesma seja chamada a quantidade de vezes necessária. Assim, caso um parâmetro diferente do esperado seja passado para a dependência, o teste falhará.
Teste 6: Deve retornar o resultado do do CreateUserRepository
Esse teste valida o retorno do método principal da nossa classe testada. Como o comportamento do método é de retornar diretamente o retorno do repository, então é necessário averiguar se isso acontece.
describe("CreateUserUseCase.ts", () => {
# ...
test("Should call repository only one time with user input", async () => {
// Arrange
const { sut, repositorySpy, userInput } = makeSut();
// Act
await sut.execute(userInput);
// Assert
expect(repositorySpy.calls).toBe(1);
expect(repositorySpy.usedParams).toEqual(userInput);
});
test("Should return repository result", async () => {
// Arrange
const { sut, repositorySpy, userInput } = makeSut();
const repositoryResult = { ...userInput, id: 10 };
// Act
repositorySpy.valueToReturn = repositoryResult;
const result = await sut.execute(userInput);
// Assert
expect(result).toEqual(repositoryResult);
});
});
Esse teste também necessita de uma alteração no spy, então, ele deve ser adaptado para atender as necessidades do teste.
class CreateUserRepositorySpy {
usedParams;
calls = 0;
valueToReturn;
async execute(usedParams) {
this.usedParams = usedParams;
this.calls++;
return this.valueToReturn;
}
}
Esse teste manipula o retorno do repository e verifica se o retorno da função testada é igual ao do repository. Caso o retorno não seja o mesmo do repositório e a função modificasse de alguma maneira o retorno, esse seria o momento de testar esse trecho de lógica da função.
Teste 7: Deve emitir uma exceção se o repository emitir um erro
Nesse teste será validado o comportamento do método com relação a erros externos do repository. Então, é esperado que o método emita uma exceção, caso algum erro inesperado aconteça no repository.
describe("CreateUserUseCase.ts", () => {
# ...
test("Should return repository result", async () => {
// Arrange
const { sut, repositorySpy, userInput } = makeSut();
const repositoryResult = { ...userInput, id: 10 };
// Act
repositorySpy.valueToReturn = repositoryResult;
const result = await sut.execute(userInput);
// Assert
expect(result).toEqual(repositoryResult);
});
test("Should throws an exception if repository throws an unexpected error", async () => {
// Arrange
const { sut, repositorySpy, userInput } = makeSut();
// Act
repositorySpy.shouldThrowError = true;
const result = sut.execute(userInput);
// Assert
await expect(result).rejects.toThrow();
});
});
Com isso, também é necessário adaptar o spy para o comportamento necessário.
class CreateUserRepositorySpy {
usedParams;
calls = 0;
valueToReturn;
shouldThrowError = false;
async execute(usedParams) {
this.usedParams = usedParams;
this.calls++;
if (this.shouldThrowError) throw new Error();
return this.valueToReturn;
}
}
Teste 8: Deve emitir uma exceção se o validator emitir um erro
Por último, mas não menos importante, também será validado o comportamento do método com relação a erros externos no validador. Portanto, é esperado que o método emita uma exceção, caso algum erro inesperado aconteça no validador.
describe("CreateUserUseCase.ts", () => {
# ...
test("Should throws an exception if repository throws an unexpected error", async () => {
// Arrange
const { sut, repositorySpy, userInput } = makeSut();
// Act
repositorySpy.shouldThrowError = true;
const result = sut.execute(userInput);
// Assert
await expect(result).rejects.toThrow();
});
test("Should throws an exception if validator throws an unexpected error", async () => {
// Arrange
const { sut, validatorSpy, userInput } = makeSut();
// Act
validatorSpy.shouldThrowError = true;
const result = sut.execute(userInput);
// Assert
await expect(result).rejects.toThrow();
});
});
Com isso, também é necessário adaptar o spy para o comportamento necessário.
class ValidateUserInputSpy {
usedParams;
calls = 0;
valueToReturn = { isValid: true };
shouldThrowError = false;
async execute(usedParams) {
this.usedParams = usedParams;
this.calls++;
if (this.shouldThrowError) throw new Error();
return this.valueToReturn;
}
}
Os dois últimos testes devem lidar com casos de exceções genéricas do método. Pode acontecer de haver um handler geral de erros na aplicação, que faz desnecessário a utilização de try catch nas demais funções. Portanto, caso alguém adicione um try catch nesse método e modifique o comportamento esperado dele, que é o de emitir um erro, os testes devem falhar.
Conclusão
Com o desenvolvimento dos testes dessa função, é possível trazer uma maior integridade ao código, fazendo com que ele seja resistente a qualquer tipo de alteração que possa comprometer o correto funcionamento.
Seguindo os passos listados acima, é possível construir uma base sólida de código totalmente contemplada por testes unitários seguindo os padrões e boas práticas da comunidade.
Lembrando que a implementação de testes unitários depende diretamente das implementações do código, portanto, se os métodos não seguem boas práticas de implementação, provavelmente os testes serão bem mais complexos de serem desenvolvidos.
Comments ()