Decidi compartilhar minha visão de testes de unidade parametrizados, como fazemos isso e como você provavelmente não faz (mas quer fazer).
Gostaria de escrever uma bela frase sobre o que deve ser testado corretamente, e os testes são importantes, mas muito material já foi dito e escrito antes de mim, vou apenas tentar resumir e destacar o que, na minha opinião, as pessoas raramente usam (entendem), para o qual basicamente se move.
O objetivo principal do artigo é mostrar como você pode (e deve) parar de bagunçar seu teste de unidade com código para criar objetos e como criar dados de teste declarativamente se mock (any ()) não for suficiente, e há muitas dessas situações.
Vamos criar um projeto maven, adicionar junit5, junit-jupiter-params e mokito a ele.
Para que não seja completamente chato, começaremos a escrever imediatamente a partir do teste, como os apologistas do TDD gostam, precisamos de um serviço que iremos testar declarativamente, qualquer um servirá, deixe ser HabrService.
Vamos criar um HabrServiceTest de teste. Adicione um link para o HabrService no campo da classe de teste:
public class HabrServiceTest {
private HabrService habrService;
@Test
void handleTest(){
}
}
crie um serviço via ide (pressionando levemente o atalho), adicione a anotação @InjectMocks ao campo.
Vamos começar diretamente com o teste: o HabrService em nosso pequeno aplicativo terá um único método handle () que terá um único argumento HabrItem, e agora nosso teste se parece com isto:
public class HabrServiceTest {
@InjectMocks
private HabrService habrService;
@Test
void handleTest(){
HabrItem item = new HabrItem();
habrService.handle(item);
}
}
Vamos adicionar um método handle () ao HabrService, que retornará o id de uma nova postagem no Habré depois de moderada e salva no banco de dados, e pega o tipo HabrItem, também criaremos nosso HabrItem, e agora o teste compila, mas trava.
O ponto é que adicionamos uma verificação para o valor de retorno esperado.
public class HabrServiceTest {
@InjectMocks
private HabrService habrService;
@BeforeEach
void setUp(){
initMocks(this);
}
@Test
void handleTest() {
HabrItem item = new HabrItem();
Long actual = habrService.handle(item);
assertEquals(1L, actual);
}
}
Além disso, quero ter certeza de que, durante a chamada do método handle (), ReviewService e PersistanceService foram chamados, foram chamados estritamente um após o outro, funcionaram exatamente 1 vez e nenhum outro método foi chamado mais. Em outras palavras, assim:
public class HabrServiceTest {
@InjectMocks
private HabrService habrService;
@BeforeEach
void setUp(){
initMocks(this);
}
@Test
void handleTest() {
HabrItem item = new HabrItem();
Long actual = habrService.handle(item);
InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);
inOrder.verify(reviewService).makeRewiew(item);
inOrder.verify(persistenceService).makePersist(item);
inOrder.verifyNoMoreInteractions();
assertEquals(1L, actual);
}
}
Adicione reviewService e persistenceService aos campos de classe, crie-os, adicione os métodos makeRewiew () e makePersist () a eles, respectivamente. Agora tudo compila, mas é claro que o teste é vermelho.
No contexto deste artigo, as implementações ReviewService e PersistanceService não são tão importantes, a implementação HabrService é importante, vamos torná-la um pouco mais interessante do que é agora:
public class HabrService {
private final ReviewService reviewService;
private final PersistenceService persistenceService;
public HabrService(final ReviewService reviewService, final PersistenceService persistenceService) {
this.reviewService = reviewService;
this.persistenceService = persistenceService;
}
public Long handle(final HabrItem item) {
HabrItem reviewedItem = reviewService.makeRewiew(item);
Long persistedItemId = persistenceService.makePersist(reviewedItem);
return persistedItemId;
}
}
e usando as construções when (). then () bloqueamos o comportamento dos componentes auxiliares, como resultado, nosso teste ficou assim e agora está verde:
public class HabrServiceTest {
@Mock
private ReviewService reviewService;
@Mock
private PersistenceService persistenceService;
@InjectMocks
private HabrService habrService;
@BeforeEach
void setUp() {
initMocks(this);
}
@Test
void handleTest() {
HabrItem source = new HabrItem();
HabrItem reviewedItem = mock(HabrItem.class);
when(reviewService.makeRewiew(source)).thenReturn(reviewedItem);
when(persistenceService.makePersist(reviewedItem)).thenReturn(1L);
Long actual = habrService.handle(source);
InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);
inOrder.verify(reviewService).makeRewiew(source);
inOrder.verify(persistenceService).makePersist(reviewedItem);
inOrder.verifyNoMoreInteractions();
assertEquals(1L, actual);
}
}
Um mockup para demonstrar o poder dos testes parametrizados está pronto.
Adicione um campo com o tipo de hub, hubType ao nosso modelo de solicitação para o serviço HabrItem, crie um enum HubType e inclua vários tipos nele:
public enum HubType {
JAVA, C, PYTHON
}
e para o modelo HabrItem, adicione um getter e um setter ao campo HubType criado.
Suponha que um switch esteja oculto nas profundezas de nosso HabrService, que, dependendo do tipo de hub, faz algo desconhecido com a solicitação e, no teste, queremos testar cada um dos casos do desconhecido, a implementação ingênua do método seria assim:
@Test
void handleTest() {
HabrItem reviewedItem = mock(HabrItem.class);
HabrItem source = new HabrItem();
source.setHubType(HubType.JAVA);
when(reviewService.makeRewiew(source)).thenReturn(reviewedItem);
when(persistenceService.makePersist(reviewedItem)).thenReturn(1L);
Long actual = habrService.handle(source);
InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);
inOrder.verify(reviewService).makeRewiew(source);
inOrder.verify(persistenceService).makePersist(reviewedItem);
inOrder.verifyNoMoreInteractions();
assertEquals(1L, actual);
}
Você pode torná-lo um pouco mais bonito e conveniente tornando o teste parametrizado e adicionando um valor aleatório de nosso enum como um parâmetro, como resultado, a declaração de teste ficará assim:
@ParameterizedTest
@EnumSource(HubType.class)
void handleTest(final HubType type)
bem, declarativamente, e todos os valores de nosso enum serão definitivamente usados em alguma próxima execução de testes, a anotação tem parâmetros, podemos adicionar estratégias para incluir, excluir.
Mas talvez eu não tenha convencido você de que testes parametrizados são bons. adicionar à
a solicitação original do HabrItem é um novo campo editCount, no qual o número de milhares de vezes que os usuários do Habr editam seu artigo será escrito antes de postar, para que você goste pelo menos um pouco e suponha que em algum lugar nas profundezas do HabrService haja algum tipo de lógica que faz o desconhecido algo, dependendo de quanto o autor tentou, e se eu não quiser escrever 5 ou 55 testes para todas as opções editCount possíveis, mas quiser testar declarativamente, e em algum lugar em um lugar imediatamente indicar todos os valores que gostaria de verificar ... Não há nada mais simples, e usando a API de testes parametrizados, obtemos algo assim na declaração do método:
@ParameterizedTest
@ValueSource(ints = {0, 5, 14, 23})
void handleTest(final int type)
Há um problema, queremos coletar dois valores declarativamente nos parâmetros do método de teste de uma vez, você pode usar outro método excelente de testes parametrizados @CsvSource, perfeito para testar parâmetros simples, com um valor de saída simples (extremamente conveniente para testar classes de utilitário), mas o que se o objeto fica muito mais complicado? Digamos que ele terá cerca de 10 campos, e não apenas primitivos e tipos Java.
A anotação @MethodSource vem em nosso socorro, nosso método de teste tornou-se visivelmente mais curto e não há mais setters nele, e a fonte da solicitação de entrada é alimentada para o método de teste como um parâmetro:
@ParameterizedTest
@MethodSource("generateSource")
void handleTest(final HabrItem source) {
HabrItem reviewedItem = mock(HabrItem.class);
when(reviewService.makeRewiew(source)).thenReturn(reviewedItem);
when(persistenceService.makePersist(reviewedItem)).thenReturn(1L);
Long actual = habrService.handle(source);
InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);
inOrder.verify(reviewService).makeRewiew(source);
inOrder.verify(persistenceService).makePersist(reviewedItem);
inOrder.verifyNoMoreInteractions();
assertEquals(1L, actual);
}
a anotação @MethodSource tem a string generateSource, o que é? este é o nome do método que irá coletar o modelo necessário para nós, sua declaração ficará assim:
private static Stream<Arguments> generateSource() {
HabrItem habrItem = new HabrItem();
habrItem.setHubType(HubType.JAVA);
habrItem.setEditCount(999L);
return nextStream(() -> habrItem);
}
Por conveniência, movi a formação de um fluxo de argumentos nextStream para uma classe de teste de utilitário separada:
public class CommonTestUtil {
private static final Random RANDOM = new Random();
public static <T> Stream<Arguments> nextStream(final Supplier<T> supplier) {
return Stream.generate(() -> Arguments.of(supplier.get())).limit(nextIntBetween(1, 10));
}
public static int nextIntBetween(final int min, final int max) {
return RANDOM.nextInt(max - min + 1) + min;
}
}
Agora, ao iniciar o teste, o modelo de solicitação HabrItem será adicionado declarativamente ao parâmetro do método de teste e o teste será executado tantas vezes quanto o número de argumentos gerados por nosso utilitário de teste, em nosso caso de 1 a 10.
Isso pode ser especialmente conveniente se o modelo estiver no fluxo de argumentos é coletado não por hardcode, como em nosso exemplo, mas com a ajuda de randomizadores. (Vida longa aos testes flutuantes, mas se forem, há um problema).
Na minha opinião, já está tudo ótimo, o teste agora descreve apenas o comportamento dos nossos stubs, e os resultados esperados.
Mas aqui está o azar, um novo campo, texto, um array de strings é adicionado ao modelo HabrItem, que pode ou não ser muito grande, não importa, o principal é que não queremos bagunçar nossos testes, não precisamos de dados aleatórios, queremos um modelo estritamente definido, com dados específicos, coletando-os em um teste ou em qualquer outro lugar - não queremos. Seria legal se você pudesse pegar o corpo de uma solicitação json de qualquer lugar, por exemplo de um carteiro, fazer um arquivo simulado com base nele e formar um modelo declarativamente no teste, especificando apenas o caminho para o arquivo json com os dados.
Excelente. Usamos a anotação @JsonSource, que terá um parâmetro de caminho, com um caminho de arquivo relativo e classe de destino. Heck! Não existe tal anotação em testes parametrizados, mas eu gostaria.
Vamos escrever nós mesmos.
ArgumentsProvider é responsável por processar todas as anotações que vêm com @ParametrizedTest em junit, vamos escrever nosso próprio JsonArgumentProvider:
public class JsonArgumentProvider implements ArgumentsProvider, AnnotationConsumer<JsonSource> {
private String path;
private MockDataProvider dataProvider;
private Class<?> clazz;
@Override
public void accept(final JsonSource jsonSource) {
this.path = jsonSource.path();
this.dataProvider = new MockDataProvider(new ObjectMapper());
this.clazz = jsonSource.clazz();
}
@Override
public Stream<Arguments> provideArguments(final ExtensionContext context) {
return nextSingleStream(() -> dataProvider.parseDataObject(path, clazz));
}
}
MockDataProvider é uma classe para analisar arquivos json fictícios, sua implementação é extremamente simples:
public class MockDataProvider {
private static final String PATH_PREFIX = "json/";
private final ObjectMapper objectMapper;
public <T> T parseDataObject(final String name, final Class<T> clazz) {
return objectMapper.readValue(new ClassPathResource(PATH_PREFIX + name).getInputStream(), clazz);
}
}
O provedor de simulação está pronto, o provedor de argumentos para nossa anotação também, resta adicionar a própria anotação:
/**
* Source- ,
* json-
*/
@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@ArgumentsSource(JsonArgumentProvider.class)
public @interface JsonSource {
/**
* json-, classpath:/json/
*
* @return
*/
String path() default "";
/**
* ,
*
* @return
*/
Class<?> clazz();
}
Hooray. Nossa anotação está pronta para uso, o método de teste agora é:
@ParameterizedTest
@JsonSource(path = MOCK_FILE_PATH, clazz = HabrItem.class)
void handleTest(final HabrItem source) {
HabrItem reviewedItem = mock(HabrItem.class);
when(reviewService.makeRewiew(source)).thenReturn(reviewedItem);
when(persistenceService.makePersist(reviewedItem)).thenReturn(1L);
Long actual = habrService.handle(source);
InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);
inOrder.verify(reviewService).makeRewiew(source);
inOrder.verify(persistenceService).makePersist(reviewedItem);
inOrder.verifyNoMoreInteractions();
assertEquals(1L, actual);
}
no mock json, podemos produzir o máximo e rapidamente um monte de objetos de que precisarmos, e em nenhum lugar a partir de agora haverá código que desvie da essência do teste, para a formação de dados de teste, é claro, você geralmente pode fazer com mocks, mas nem sempre.
Resumindo, gostaria de dizer o seguinte: muitas vezes trabalhamos como costumávamos trabalhar, durante anos, sem pensar que algumas coisas podem ser feitas de maneira linda e simples, muitas vezes usando a API padrão das bibliotecas que usamos há anos, mas não conhecemos todas as suas capacidades.
PS O artigo não é uma tentativa de conhecimento dos conceitos de TDD, eu queria adicionar dados de teste à campanha de narrativa para torná-la um pouco mais clara e interessante.