Testes unitários com Mock para as classes de serviços.
Serão demonstradas neste guia de teste, as 4 funções de um CRUD e exceção, para que assim, seja possível que se tenha uma ideia básica de como continuar com a implementação de testes.
Um teste é composto por 3 fases: Preparação, Execução e Validação.
- Preparação: nessa etapa é criado todos os argumentos necessários e configurações para que o teste possa representar o mais próximo do cenário real;
- Execução: aqui onde se utiliza os argumentos produzidos na preparação e executamos o metodo do serviço a qual queremos testar;
- Validação: a fase mais importante de um teste, onde ocorre todas as verificações da execução do teste e se ocorreu como esperado;
Para testarmos um serviço, vamos começar criando a classe de teste, caso a classe de teste não exista.
A nomenclatura da classe de teste deve seguir um padrão "{nome da classe do serviço}" + "Test",
por exemplo: "TechnicalDebtServiceTest".
O local em que esse arquivo deve está é no path do projeto em "src/test/java/org/{nome_do_projeto}" dentro de um diretório que o serviço faça parte, para o exemplo em questão o diretório é "technicalDebt", resultando no seguinte path: "src/test/java/org/tracy/technicaldebt", logo após adicionamos a anotação @Tag("Service") e
@ExtendWith(MockitoExtension.class) em cima do "public class".
@Tag("Service")
@ExtendWith(MockitoExtension.class)
public class TechnicalDebtServiceTest {
}
Agora precisamos pegar os atributos da classe de serviço para a qual os testes devem ser feitos e passar como atributos da classe teste, os atributos que forem outros serviços e repositórios, devem receber a anotação @Mock.
@Tag("Service")
@ExtendWith(MockitoExtension.class)
public class TechnicalDebtServiceTest {
@Mock
private ITAssetService assetService;
@Mock
private BusinessProcessService businessProcessService;
@Mock
private BusinessMetricService businessMetricService;
...
}
Após todos os atributos necessários para a classe de serviço estiverem "mockados" deve ser injetado todos os mocks em uma instância do serviço, isso pode ser feito através da anotação @InjectMocks ou através de um método de construção anotado por um @Beforeach, damos preferência ao @InjectMocks.
@Tag("Service")
@ExtendWith(MockitoExtension.class)
public class TechnicalDebtServiceTest {
@Mock
private ITAssetService assetService;
@Mock
private BusinessProcessService businessProcessService;
@Mock
private BusinessMetricService businessMetricService;
...
@Mock
private BusinessCanvasService businessCanvasService;
@InjectMocks
private TechnicalDebtService technicalDebtService;
}
Com todos as dependências injetadas no serviço para ser testado, partimos para criação do teste unitário, na mesma classe. Iniciamos o método de teste com uma nomenclatura a qual deve de forma mais clara possível identificar a validação do teste.
Todos os testes devem ter como retorno o tipo void, e uma anotação obrigatória @Test e uma opcional @DisplayName (pode haver outras anotações opcionais), a anotação @Test marca o método como teste, fazendo com que o testrunner execute os métodos anotados, além de poder executar cada um isoladamente. Já o @DisplayName dá uma título ao teste, para que na interface seja visto com mais detalhes o que o teste se propõem a fazer, nesta anotação você pode escrever em qualquer língua, desde que fique claro e compatível.
Como primeiro exemplo, será testado a criação de uma TechnicalDebt, então, para o nome do método de teste algo parecido com "shouldSaveTechnicalDebt", com o retorno void e duas anotações, no DisplayName evidenciando a validação do teste:
@Test
@DisplayName("Deve salvar uma divida tecnica")
void shouldSaveTechnicalDebt() {
}
Agora vamos para a primeira etapa do teste, a preparação, aqui vamos olhar para o fluxo método "save" do serviço "TechnicalDebtService" e criar as entidades e configurações que precisaremos para a segunda e terceira etapa.
No método save, vemos dois parâmetros "TechnicalDebt" e "Feedback", os quais precisamos então criá-los na preparação do teste, a primeira etapa. Então, em uma breve análise notamos que precisamos mockar o retorno do saveAndFlush() do repositório "technicalDebtRepository". Para o teste em questão é necessário mockar outros comportamentos de outras classes (serviços e repositórios), mas para esse guia não ficar extenso não serão detalhados.
@Transactional
public TechnicalDebt save(TechnicalDebt technicalDebt, Feedback feedback) {
initializeLists(technicalDebt);
if(technicalDebt.getBusinessPriority() == null){
technicalDebt.setBusinessPriority(BusinessPriorityValue.UNDEFINED.value);
}
if(technicalDebt.getIssue() != null && technicalDebt.getIssue().getAssignedTo() != null){
technicalDebt.setAssignedTo(technicalDebt.getAssignedTo());
}
List<TechnicalDebtImpact> impactsToChange = technicalDebt.getTechnicalDebtImpacts();
technicalDebt.setTechnicalDebtImpacts(new LinkedList<>());
TechnicalDebt technicalDebtAfterSave = this.technicalDebtRepository.saveAndFlush(technicalDebt);
persistTechImpacts(technicalDebt);
updateTechnicalDebtImpacts(technicalDebtAfterSave, impactsToChange);
updateAndSaveBusinessPriority(technicalDebtAfterSave, "TD_CREATE", feedback);
this.technicalDebtRepository.flush();
return technicalDebtAfterSave;
}
Na primeira etapa criamos os objetos e configuramos os retornos mocks dos atributos anotados com @Mock através do método "when()" do Mockito seguido de um ".return()" com o objeto simulado no retorno real do método da classe mockada, mas primeiro, precisamos construir esses retornos, e para facilitar a construção de tais objetos utilizamos os métodos estáticos, builders, para poupar tempo e linhas de código. Na linha 148 o comportamento do repositório é simulado, utilizando o "when()", note que é preciso especificar qual método a classe mockada está chamando e passar como parâmetro a classe que o método espera, e no ".return()", a simulação do retorno, o objeto technicalDebt.
@Test
@DisplayName("Deve salvar uma dívida técnica")
void shouldSaveTechnicalDebt() {
ConfigItem configItemReturn = createConfigItem().id(1L).build();
TechnicalDebt technicalDebt = createTechnicalDebt().build();
Feedback feedback = createFeedBack().build();
PriorityCanvas priorityCanvas = createPriorityCanvas().build();
when(configItemService.findById(anyLong())).thenReturn(configItemReturn);
when(technicalDebtRepository.saveAndFlush(any(TechnicalDebt.class))).thenReturn(technicalDebt);
when(technicalDebtRepository.save(any(TechnicalDebt.class))).thenReturn(technicalDebt);
when(priorityCanvasRepository.findAllByOrganizationId(anyLong()))
.thenReturn(Collections.singletonList(priorityCanvas));
}
Com as entidades criadas e retornos simulados como esperado, continuamos para a segunda etapa do teste, a execução, bem simples, apenas chamamos o método a qual queremos testar e passamos seus devidos parâmetros.
@Test
@DisplayName("Deve salvar uma dívida técnica")
void shouldSaveTechnicalDebt() {
...
when(configItemService.findById(anyLong())).thenReturn(configItemReturn);
when(technicalDebtRepository.saveAndFlush(any(TechnicalDebt.class))).thenReturn(technicalDebt);
when(technicalDebtRepository.save(any(TechnicalDebt.class))).thenReturn(technicalDebt);
when(priorityCanvasRepository.findAllByOrganizationId(anyLong()))
.thenReturn(Collections.singletonList(priorityCanvas));
technicalDebtService.save(technicalDebt, feedback);
ArgumentCaptor<TechnicalDebt> technicalDebtArgumentCaptor = ArgumentCaptor.forClass(TechnicalDebt.class);
ArgumentCaptor<PriorityLog> priorityLogArgumentCaptor = ArgumentCaptor.forClass(PriorityLog.class);
Depois da preparação e execução, chegou a vez da validação dos resultados, em alguns casos, é preciso saber como o objeto chegou ao repositório/serviço, momento que o objeto é passado por parâmetro do método, e para isso é necessário a criação de objetos do tipo ArgumentCaptor, ele é capaz de capturar os objetos nos parâmetros e é usado em conjunto com o método "verify()".
O método verify verifica as ocorrências das chamadas aos métodos da classe que foram utilizados durante a execução do teste, que por padrão é "times(1)", uma ocorrência do método da instância, o times é o segundo parâmetro do método verify(), em seguida utiliza-se o "." mas o método que deseja verificar a ocorrência, que para esse exemplo vamos focar no "saveAndFlush()", linha 158, que é passado como parâmetro um ArgumentCaptor do tipo TechnicalDebt, que é justamente a classe que chega para ser salva no repositório.
Com o método "capture()" do ArgumentCaptor para capturar o valor e depois utilizamos o método "getValue()" para pegar o valor capturado e armazenar em uma variável, essa variável será utilizado para verificação dos campos.
ArgumentCaptor<TechnicalDebt> technicalDebtArgumentCaptor = ArgumentCaptor.forClass(TechnicalDebt.class);
ArgumentCaptor<PriorityLog> priorityLogArgumentCaptor = ArgumentCaptor.forClass(PriorityLog.class);
verify(technicalDebtRepository).saveAndFlush(technicalDebtArgumentCaptor.capture());
TechnicalDebt beforeSaveTechnicalDebt = technicalDebtArgumentCaptor.getValue();
verify(impactService).saveAll(any());
verify(technicalDebtRepository).save(technicalDebtArgumentCaptor.capture());
TechnicalDebt afterSaveTechnicalDebt = technicalDebtArgumentCaptor.getValue();
verify(logRepository).save(priorityLogArgumentCaptor.capture());
PriorityLog priorityLog = priorityLogArgumentCaptor.getValue();
A verificação dos campos é feita através dos assertions, aqui se compara os valores atuais dos valores esperados, dessa maneira validando campo a campo. É importante que todos os campos sejam validados, pois, durante o fluxo é possível que algum valor seja alterado, e essa alteração possa ser parte do fluxo ou um bug gerado, é aqui que está a importância do teste unitário, ele garante que para aquele fluxo o comportamento seja o esperado.
@Test
@DisplayName("Deve salvar uma divida tecnica")
void shouldSaveTechnicalDebt() {
...
verify(technicalDebtRepository).saveAndFlush(technicalDebtArgumentCaptor.capture());
TechnicalDebt beforeSaveTechnicalDebt = technicalDebtArgumentCaptor.getValue();
verify(impactService).saveAll(any());
verify(technicalDebtRepository).save(technicalDebtArgumentCaptor.capture());
TechnicalDebt afterSaveTechnicalDebt = technicalDebtArgumentCaptor.getValue();
verify(logRepository).save(priorityLogArgumentCaptor.capture());
PriorityLog priorityLog = priorityLogArgumentCaptor.getValue();
assertAll("beforeSaveTechnicalDebt",
() -> assertThat(beforeSaveTechnicalDebt.getName(), is(technicalDebt.getName())),
() -> assertThat(beforeSaveTechnicalDebt.getDescription(), is(technicalDebt.getDescription())),
() -> assertThat(beforeSaveTechnicalDebt.getBusinessPriority(), is(technicalDebt.getBusinessPriority())),
() -> assertThat(beforeSaveTechnicalDebt.getTechnicalPriority(), is(technicalDebt.getTechnicalPriority())),
() -> assertThat(beforeSaveTechnicalDebt.getCheckedByUser(), is(technicalDebt.getCheckedByUser())),
() -> assertThat(beforeSaveTechnicalDebt.getEnabled(), is(technicalDebt.getEnabled())),
() -> assertThat(beforeSaveTechnicalDebt.getType(), is(technicalDebt.getType()))
);
assertAll("afterSaveTechnicalDebt",
() -> assertThat(afterSaveTechnicalDebt.getTechnicalPriority(), is(technicalDebt.getTechnicalPriority())),
() -> assertThat(afterSaveTechnicalDebt.getName(), is(technicalDebt.getName())),
() -> assertThat(afterSaveTechnicalDebt.getDescription(), is(technicalDebt.getDescription())),
() -> assertThat(afterSaveTechnicalDebt.getBusinessPriority(), is(technicalDebt.getBusinessPriority())),
() -> assertThat(afterSaveTechnicalDebt.getTechnicalPriority(), is(technicalDebt.getTechnicalPriority())),
() -> assertThat(afterSaveTechnicalDebt.getCheckedByUser(), is(technicalDebt.getCheckedByUser())),
() -> assertThat(afterSaveTechnicalDebt.getEnabled(), is(technicalDebt.getEnabled())),
() -> assertThat(afterSaveTechnicalDebt.getType(), is(technicalDebt.getType()))
);
assertAll("priorityLog",
() -> assertThat(priorityLog.getTrigger(), is("TD_CREATE")),
() -> assertThat(priorityLog.getTdType(), is("TechnicalDebtType Name")),
() -> assertThat(priorityLog.getOldBusinessPriority(), is(1000)),
() -> assertThat(priorityLog.getNewBusinessPriority(), is(100)),
() -> assertThat(priorityLog.getOldTechnicalPriority(), is(1000)),
() -> assertThat(priorityLog.getNewTechnicalPriority(), is(1000)),
() -> assertThat(priorityLog.getComment(), is("FeedBack Comment")),
() -> assertFalse(priorityLog.getPrioritizedByImpact()),
() -> assertFalse(priorityLog.getPrioritizedByEffort()),
() -> assertFalse(priorityLog.getPrioritizedByType()),
() -> assertFalse(priorityLog.getPrioritizedByAge())
);
}
Builders são os métodos que fazem instância da classe e que você pode alterar os campos enquanto chama o método, como na linha 142 onde o campo "id" está sendo setado com o valor 1L. Os builders ficam no path referente aos testes, em: "src/test/java/org/tracy/builders". Builders podem possuir outros builders para facilitar a sua construção, mas cuidado para não gerar recursão entre eles.
public class ConfigItemBuilder {
public static ConfigItem.Builder createConfigItem() {
return ConfigItem.builder()
.id(1L)
.name("ConfigItem Name")
.description("ConfigItem Description")
.type(createConfigItemType().build())
.status(Status.OPERATIONAL)
.children(Collections.singletonList(createConfigItemChildren().build()))
.organization(createOrganization().build())
.affectedITAssets(Collections.singletonList(createITAsset().build()))
.teams(Collections.singletonList(createTeam().build()));
}
}