Testes Unitários com Jest e Typescript

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:

  1. Casos de exceção.
  2. Casos de sucesso.
  3. Parâmetros de métodos.
  4. Quantidade de vezes em que uma função é executada.
  5. Retornos.
  6. 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):

  1. 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.
  2. 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.
  3. 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.
describe("CreateUserUseCase.ts", () => {
  test("Should return a created user", async () => {
    // Arrange
    const userInput = { name: "Uncle Bob", document: "12122345564" };
    const createdUser = { ...userInput, id: 1234 };
    const saveUserMock = new SaveUserMock();
    const systemUnderTest = new CreateUserUseCase(saveUserMock);

    // Act
    saveUserMock.objectToReturn = createdUser;
    const result = await systemUnderTest.execute(userInput);

    // Assert
    expect(result).toEqual(createdUser);
  });
});

Exemplo de um caso de teste seguindo o Triple A (Arrange, Act, Assert)


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:

  1. Recebe um UserInput como parâmetro.
  2. Valida se o parâmetro possui algum valor.
  3. Valida os campos do parâmetro recebido com o validador de usuário.
  4. Caso o parâmetro seja inválido, um erro de validação é emitido.
  5. 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.