Testando código multithread e assíncrono

Oi Nesta semana, a tarefa foi escrever um teste de integração para um aplicativo Spring Boot usando interação assíncrona com sistemas externos. Atualizamos muito material sobre a depuração de códigos multithread. O artigo “Testando código multithread e assíncrono” de Jonathan Halterman, cuja tradução é dada abaixo, chamou a atenção.

Obrigado a shalomman , schroeder e FTOH pelos comentários mais importantes sobre o código do artigo original.

Se você escreve código por tempo suficiente ou talvez não, provavelmente encontrou um script no qual precisa testar o código multiencadeado. Geralmente, acredita-se que threads e testes não devem ser misturados. Isso geralmente acontece porque o que deve ser testado começa dentro de um sistema multithread e pode ser testado individualmente sem o uso de threads. Mas e se você não puder separá-los, ou mais, se multithreading é esse aspecto do código que você está testando?

Estou aqui para lhe dizer que, embora os threads nos testes não sejam muito comuns, eles são bastante usados. A polícia do software não o prenderá por iniciar um encadeamento em um teste de unidade, apesar de como realmente testar o código multithread é outro problema. Algumas excelentes tecnologias assíncronas, como Akka e Vert.x, fornecem kits de teste para aliviar esse fardo. Além disso, o teste de código multiencadeado geralmente requer uma abordagem diferente de um teste de unidade síncrona típico.

Vamos paralelo


A primeira etapa é iniciar qualquer ação multithread para a qual você deseja verificar o resultado. Por exemplo, vamos usar uma API hipotética para registrar um manipulador de mensagens em um barramento de mensagens e publicar uma mensagem no barramento, que será entregue ao nosso manipulador de forma assíncrona em um thread separado:

messageBus.registerHandler(message - > { System.out.println("Received " + message); }); messageBus.publish("test"); 

Parece bom. Quando o teste é iniciado, o barramento deve entregar nossa mensagem ao manipulador em outro encadeamento, mas isso não é muito útil, pois não verificamos nada. Vamos atualizar nosso teste para confirmar que o barramento de mensagens entrega nossa mensagem conforme o esperado:

 String msg = "test"; messageBus.registerHandler(message -> { System.out.println("Received " + message); assertEquals(message, msg); }; messageBus.publish(msg); 

Parece melhor. Executamos nosso teste e é verde. Legal! Mas a mensagem recebida não foi impressa em nenhum lugar, algo estava errado em algum lugar.

Espera um segundo


No teste acima, quando uma mensagem é publicada no barramento, ela é entregue pelo barramento ao manipulador em outro encadeamento. Mas quando uma ferramenta de teste de unidade, como a JUnit, executa um teste, ela não sabe nada sobre os fluxos do barramento de mensagens. O JUnit conhece apenas o encadeamento principal no qual executa o teste. Portanto, enquanto o barramento de mensagens está ocupado tentando entregar a mensagem, o teste conclui a execução no encadeamento de teste principal e o JUnit reporta o sucesso. Como resolver isso? Precisamos do segmento de teste principal para aguardar o barramento de mensagens entregar nossa mensagem. Então, vamos adicionar uma declaração de suspensão:

 String msg = "test"; messageBus.registerHandler(message -> { System.out.println("Received " + message); assertEquals(message, msg); }; messageBus.publish(msg); Thread.sleep(1000); 

Nosso teste é verde e a expressão Recebido é impressa conforme o esperado. Legal! Mas um segundo de sono significa que nosso teste é realizado por pelo menos um segundo e não há nada de bom nele. Poderíamos reduzir o tempo de sono, mas corremos o risco de concluir o teste antes de receber uma mensagem. Precisamos de uma maneira de coordenar entre o thread de teste principal e o thread do manipulador de mensagens. Olhando para o pacote java.util.concurrent , temos certeza de encontrar o que podemos usar. E o CountDownLatch ?

 String msg = "test"; CountDownLatch latch = new CountDownLatch(1); messageBus.registerHandler(message -> { System.out.println("Received " + message); assertEquals(message, msg); latch.countDown(); }; messageBus.publish(msg); latch.await(); 

Nesta abordagem, compartilhamos o CountDownLatch entre o thread de teste principal e o thread do manipulador de mensagens. O thread principal é forçado a esperar no bloqueador. O thread de teste libera o thread principal pendente chamando countDown () no bloqueador após receber a mensagem. Não precisamos mais dormir por um segundo. Nosso teste leva exatamente o tempo necessário.

Tão feliz?


Com nosso novo encanto, o CountDownLatch, começamos a escrever testes multiencadeados, como os últimos fashionistas. Mas, rapidamente, percebemos que um de nossos casos de teste está bloqueado para sempre e não termina. O que está havendo? Considere o cenário do barramento de mensagens: o bloqueador faz você esperar, mas é liberado somente após o recebimento da mensagem. Se o barramento não funcionar e a mensagem nunca for entregue, o teste nunca terminará. Então, vamos adicionar um tempo limite ao bloqueador:

 latch.await(1, TimeUnit.SECONDS); 

Um teste bloqueado falha após 1 segundo com uma exceção TimeoutException. No final, encontraremos o problema e corrigiremos o teste, mas decidimos deixar os tempos limites no lugar. Se isso acontecer novamente, preferimos que nosso teste seja bloqueado por um segundo e travado, do que bloqueado para sempre e não será concluído.
Outro problema que notamos ao escrever testes é que todos parecem passar mesmo quando provavelmente não deveriam. Como isso é possível? Considere o teste de processamento de mensagens novamente:

 messageBus.registerHandler(message -> { assertEquals(message, msg); latch.countDown(); }; 

Deveríamos ter usado o CountDownLatch para coordenar a conclusão do nosso teste com o thread de teste principal, mas e as declarações? Se a validação falhar, a JUnit saberá sobre isso? Acontece que, como não realizamos validação no segmento de teste principal, quaisquer verificações falhas permanecem completamente despercebidas pelo JUnit. Vamos tentar um pequeno script para testar isso:

 CountDownLatch latch = new CountDownLatch(1); new Thread(() -> { assertTrue(false); latch.countDown(); }).start(); latch.await(); 

O teste é verde! Então, o que fazemos agora? Precisamos de uma maneira de enviar quaisquer erros de teste do fluxo do manipulador de mensagens de volta ao fluxo de teste principal. Se ocorrer uma falha no encadeamento do manipulador de mensagens, precisamos que ele reapareça no encadeamento principal para que o teste seja invertido, conforme o esperado. Vamos tentar fazer isso:

 CountDownLatch latch = new CountDownLatch(1); AtomicReference<AssertionError> failure = new AtomicReference<>(); new Thread(() -> { try { assertTrue(false); } catch (AssertionError e) { failure.set(e); } latch.countDown(); }).start(); latch.await(); if (failure.get() != null) throw failure.get(); 

Início rápido e sim, o teste falha, como deveria! Agora podemos voltar e adicionar os blocos CountDownLatches, try / catch e AtomicReference a todos os nossos casos de teste. Legal! Na verdade, não é legal, parece um clichê.

Cortar o lixo


Idealmente, precisamos de uma API que permita coordenar a espera, a verificação e a retomada da execução entre encadeamentos, para que os testes de unidade possam passar ou falhar conforme o esperado, independentemente da falha da validação. Felizmente, o ConcurrentUnit fornece uma estrutura leve que faz exatamente isso: Garçom. Vamos adaptar o teste de processamento de mensagens acima pela última vez e ver o que o Waiter da ConcurrentUnit pode fazer por nós:

 String msg = "test"; Waiter waiter = new Waiter(); messageBus.registerHandler(message -> { waiter.assertEquals(message, msg); waiter.resume(); }; messageBus.publish(msg); waiter.await(1, TimeUnit.SECONDS); 

Neste teste, vemos que o Waiter substituiu nossos CountDownLatch e AtomicReference. Com o Waiter, bloqueamos o segmento de teste principal, realizamos o teste e, em seguida, retomamos o segmento de teste principal para que o teste possa ser concluído. Se a verificação falhar, chamar waiter.await automaticamente liberará o bloqueio e causará uma falha, o que fará com que o teste seja aprovado ou reprovado, como deveria, mesmo que a verificação tenha sido realizada a partir de outro encadeamento.

Ainda mais paralelo


Agora que nos tornamos testadores multithread certificados, podemos querer confirmar que várias ações assíncronas estão ocorrendo. O garçom da ConcurrentUnit simplifica:

 Waiter waiter = new Waiter(); messageBus.registerHandler(message -> { waiter.resume(); }; messageBus.publish("one"); messageBus.publish("two"); waiter.await(1, TimeUnit.SECONDS, 2); 

Aqui publicamos duas mensagens no barramento e verificamos que as duas mensagens foram entregues, fazendo com que o Waiter aguarde que o resumo () seja chamado 2 vezes. Se as mensagens não forem entregues e o resumo não for chamado duas vezes em 1 segundo, o teste falhará com um erro de TimeoutException.
Uma dica geral dessa abordagem é garantir que seus tempos limite sejam longos o suficiente para concluir qualquer ação simultânea. Sob condições normais, quando o sistema em teste funciona como esperado, o tempo limite não importa e entra em vigor somente no caso de uma falha do sistema por qualquer motivo.

Sumário


Neste artigo, aprendemos que o teste de unidade multithread não é ruim e é bastante fácil de fazer. Aprendemos sobre a abordagem geral quando bloqueamos o segmento de teste principal, realizamos verificações de outros segmentos e, em seguida, retomamos o segmento principal. E aprendemos sobre o ConcurrentUnit , que pode facilitar esta tarefa.
Teste feliz!

Traduzido por @middle_java

Source: https://habr.com/ru/post/pt472152/


All Articles