测试多线程和异步代码

你好 本周的任务是使用与外部系统的异步交互为Spring Boot应用程序编写集成测试。 刷新了许多有关调试多线程代码的材料。 乔纳森·霍尔特曼(Jonathan Halterman)的文章“测试多线程和异步代码”(以下是我的翻译)引起了关注。

感谢shalommanschroederFTOH提供了原始文章中最重要的代码注释。

如果编写代码的时间足够长,或者可能不是,那么您可能遇到了需要测试多线程代码的脚本。 通常认为线程和测试不应混用。 这通常是因为 待测试的内容只是在多线程系统中启动的,无需使用线程即可单独进行测试。 但是,如果您不能将它们分开,或者更多,如果多线程是您要测试的代码的一部分,该怎么办?

我在这里告诉您,尽管测试中的线程不是很常见,但使用得很充分。 尽管如何实际测试多线程代码是另一回事,但是软件警察不会因为在单元测试中启动线程而逮捕您。 一些出色的异步技术(例如Akka和Vert.x)提供了测试套件来减轻这种负担。 但是除此之外,测试多线程代码通常需要与典型的同步单元测试不同的方法。

我们平行


第一步是启动要检查结果的任何多线程操作。 例如,让我们使用一个假设的API在消息总线上注册消息处理程序并在总线上发布消息,这些消息将在单独的线程中异步传递给我们的处理程序:

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

看起来不错 测试开始时,总线应该在另一个线程中将我们的消息传递给处理程序,但这不是很有用,因为我们不检查任何内容。 让我们更新测试以确认消息总线是否按预期传递了我们的消息:

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

看起来更好。 我们进行测试,它是绿色的。 好酷! 但是收到的消息未在任何地方打印,某处有问题。

等一下


在上面的测试中,当一条消息在消息总线上发布时,它通过总线在另一个线程中传递给处理程序。 但是,当诸如JUnit的单元测试工具运行测试时,它对消息总线流一无所知。 JUnit只知道运行测试的主线程。 因此,当消息总线正忙于尝试传递消息时,测试将完成主测试线程中的执行,并且JUnit报告成功。 如何解决呢? 我们需要主测试线程来等待消息总线传递我们的消息。 因此,我们添加一个sleep语句:

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

我们的测试为绿色,并且按预期方式打印了已接收的表达式。 好酷! 但是一秒钟的睡眠意味着我们的测试执行了至少一秒钟,并且没有任何好处。 我们可以减少睡眠时间,但是冒着在收到消息之前完成测试的风险。 我们需要一种在主测试线程和消息处理程序线程之间进行协调的方法。 查看java.util.concurrent包,我们一定会找到可以使用的包。 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(); 

在这种方法中,我们在主测试线程和消息处理程序线程之间共享CountDownLatch。 主线程被迫等待阻止程序。 测试线程在收到消息后,通过在阻止程序上调用countDown()来释放挂起的主线程。 我们不再需要睡一秒钟。 我们的测试需要的时间完全相同。

好开心吗


借助我们的新魅力CountDownLatch,我们开始编写多线程测试,例如最新的fashionistas。 但是很快,我们注意到我们的一个测试用例被永久阻止并且没有结束。 这是怎么回事? 考虑消息总线的情况:阻止程序使您等待,但是只有在收到消息后才释放它。 如果总线不工作并且消息从未传递,则测试将永远不会结束。 因此,让我们向阻止程序添加超时:

 latch.await(1, TimeUnit.SECONDS); 

1秒钟后,带有TimeoutException异常的被阻止测试失败。 最后,我们将发现问题并修复测试,但决定保留超时。 如果再次发生这种情况,我们宁愿将测试锁定一秒钟并崩溃,而不是永远阻塞并且根本无法完成。
我们在编写测试时注意到的另一个问题是,即使它们可能不应该通过,它们似乎都可以通过。 这怎么可能? 再次考虑消息处理测试:

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

我们应该使用CountDownLatch来协调测试与主测试线程的完成,但是asserts呢? 如果验证失败,JUnit会知道吗? 事实证明,由于我们不在主测试线程中执行验证,因此任何有缺陷的检查都不会被JUnit完全注意到。 让我们尝试一些脚本来测试一下:

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

测试是绿色的! 那么,我们现在该怎么办? 我们需要一种将任何测试错误从消息处理程序流发送回主测试流的方法。 如果消息处理程序线程中发生故障,我们需要将其重新出现在主线程中,以便测试按预期进行。 让我们尝试这样做:

 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(); 

快速启动,是的,测试失败了,应该如此! 现在,我们可以返回并向所有测试用例添加CountDownLatches,try / catch和AtomicReference块。 好酷! 实际上,它不酷,看起来像样板。

切掉垃圾


理想情况下,我们需要一个API,该API允许我们协调线程之间的等待,检查和恢复执行,以便无论验证在哪里失败,单元测试都可以按预期通过或失败。 幸运的是,ConcurrentUnit提供了一个轻量级的框架来完成此任务:服务员。 让我们最后一次修改上面的消息处理测试,看看ConcurrentUnit的Waiter能为我们做什么:

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

在此测试中,我们看到Waiter取代了CountDownLatch和AtomicReference。 使用Waiter,我们可以阻塞主测试线程,执行测试,然后继续执行主测试线程,以便测试可以完成。 如果检查失败,则调用waiter.await将自动释放锁并引发失败,即使检查是从另一个线程执行的,也将导致测试通过或失败。

更平行


现在我们已经成为经过认证的多线程测试人员,我们可能想要确认正在发生几个异步操作。 ConcurrentUnit的Waiter使这一过程变得简单:

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

在这里,我们在总线上发布了两条消息,并验证了两条消息都已传递,从而使Waiter等待两次调用resume()。 如果未传递消息,并且在1秒内未两次调用resume,则测试将失败并显示TimeoutException错误。
使用此方法的一个一般技巧是确保您的超时时间足够长,可以完成所有并发操作。 在正常情况下,当被测系统按预期工作时,超时并不重要,并且仅在由于任何原因导致系统故障时才生效。

总结


在本文中,我们了解到多线程单元测试不是邪恶的,并且相当容易做到。 当我们阻塞主测试线程,从其他线程执行检查,然后恢复主线程时,我们了解了通用方法。 我们了解了ConcurrentUnit ,它可以简化此任务。
测试愉快!

@middle_java翻译

Source: https://habr.com/ru/post/zh-CN472152/


All Articles