当前位置: 首页 > 科技观察

关于写异步代码测试用例的一些思考

时间:2023-03-17 17:21:18 科技观察

如果说异步代码不好写是一个共识,那么写异步代码测试用例就更难了。最近刚做完一个Flaky测试,所以想分享一些写异步测试用例的心得。在本文中,我们将探讨异步测试用例的一个常见问题——如何强制执行某些线程的顺序,如何强制某些线程操作先于其他线程执行。通常我们不想强制线程的顺序,因为这违反了多线程的原则。所谓多线程就是实现并发,让CPU根据当前的资源和应用状态选择最佳的执行顺序。但是在测试中,为了保证测试结果的稳定性,必须明确线程的顺序。测试节流器(Throttler)在软件行业中,节流器是指一种用于限制并发操作数和预留资源的模式,例如连接池、网络缓存或CPU密集型操作。与其他同步工具不同,节流器的作用是启动一种“快速失败”机制,该机制会导致过多的请求立即失败而不是等待。“快速失败”机制很重要,因为切换操作、等待操作会消耗资源——端口、线程、内存等。下面是一个节流阀的简单实现(基本上是一个信号量包,应该是等待、重试等。在实际应用中)classThrottledExceptionextendsRuntimeException("Throttled!")defapply(f:=>Unit):Unit={if(!semaphore.tryAcquire())thrownewThrottledExceptiontry{f}finally{semaphore.release()}}}现在我们开始基本单元测试:测试单线程油门(我们使用测试框架specs2)。在此示例中,我们将验证顺序调用不会超过油门的最大限制(maxCount变量如下所示)。注意这里我们使用的是单线程,所以我们没有验证油门的“failfast”功能,这里的油门都处于不饱和状态。事实上,我们只会测试油门在非饱和条件下不会停止运行。classThrottlerTestextendsSpecification{“Throttler”应该{“executesequential”innewctx{varinvocationCount=0for(i<-0tomaxCount){throttler{invocationCount+=1}}invocationCountmustbe_==(maxCount+1)}}traitctx{valmaxCount=3valunthrottler=newThrottler}(}测试concurrentthrottle在前面的例子中,throttle是没有饱和的,因为throttle一般在单线程是不会饱和的,我们来测试下throttlevalve在多线程环境下是否还能正常工作,设置如下:vale=Executors.newCachedThreadPool()隐式valec:ExecutionContext=ExecutionContext.fromExecutor(e)privatevalwaitForeverLatch=newCountDownLatch(1)overrideafter:Any={waitForeverLatch.countDown()e.shutdownNow()}defwaitForever():Unit=try{waitForeverLatch.await()}catch{case_:InterruptedException=>caseex:Throwable=>throwex}ExecutionContext用于构造Future,waitForever方法用于保持线程直到测试结束前释放锁。在接下来的fun行动,我们将关闭执行服务。以下是测试节流阀多线程行为的示例:“throwexceptiononcereachedthelimit[naive,flaky]”innewctx{for(i<-1tomaxCount){Future{throttler(waitForever())}}throttler{}mustthrowA[ThrottledException]我们创建最多maxCount个线程(调用Future{})来调用waitForever函数,该函数将一直持续到测试结束。然后我们围绕油门执行另一个操作-maxCount+1。预期的行为是此时应该抛出ThrottledException。但是,也许预期的异常并没有发生,因为relay的最后一次调用可能会在未来之前执行(未来会抛出异常,但这不是预期的结果)。上述测试的问题在于,我们无法确定所有线程都已启动并阻塞在waitForever函数中,直到throttle按照预期抛出异常,然后导致throttle被违反。为了解决这个问题,我们需要一些方法来等待所有期货开始。有一种我们大多数人都熟悉的方法:只需添加一个睡眠函数并等待一段合适的时间。"throwexceptiononcereachedthelimit[naive,bad]"innewctx{for(i<-1tomaxCount){Future{throttler(waitForever())}}Thread.sleep(1000)throttler{}mustthrowA[ThrottledException]}好了,现在这个测试差不多了会通过,但这种方法仍然是错误的,原因有二:测试至少会持续到我们设置的“适当时间”。在极少数情况下,例如当机器处于高负载下时,这个适当的时间可能不够用。如果您仍然想知道,Google有更多原因。更好的方法是将线程(未来)的开始与我们的预期同步。让我们使用java.util.concurrent中的CountDownLatch类:“throwexceptiononcereachedthelimit[working]”innewctx{valbarrier=newCountDownLatch(maxCount)for(i<-1tomaxCount){Future{throttler{barrier.countDown()waitForever()}}}barrier.await(5,TimeUnit.SECONDS)mustbeTruethrottler{}mustthrowA[ThrottledException]}我们使用CountDownLatch来处理屏障同步。wait方法阻塞主线程,直到闩锁计数达到0。当其他线程运行时(我们将这些其他线程表示为futures),每个future调用countDown方法将闩锁计数减1。一旦计数达到0,所有futures遇到了waitForever方法。通过这种方式,我们可以确保节流器内部的线程数量达到最大(maxCount)。另一个线程尝试进入节流器将导致异常。我们有特定的方法来设置我们的测试,测试将有一个主线程进入节流器。主线程可以恢复到这一点(闩锁计数为0并等待CountDownLatch释放等待线程)。如果发生意外情况,我们会使用稍高的超时来防止发生无限阻塞。如果发生这样的事情,我们的测试就会失败。这个超时不会影响测试时间,除非发生意外,否则我们不应该等待。结论在测试异步程序时,通常需要在特定的测试用例中指定多个线程之间的执行顺序。不使用任何同步策略的测试是不可靠的,有时成功有时失败。使用Thread.sleep可以减少测试出错的几率,但并不能完全解决问题。在大多数情况下,当需要保证测试中多个线程的执行顺序时,可以使用CountDownLatch代替Thead.sleep。使用CountDownlatch的好处是可以指定释放(持有)线程的时机,有两个好处:保证顺序执行,使测试结果更可靠;加快测试程序的执行。即使是普通的等待操作,比如waitForever函数,虽然也可以使用Thread.sleep(Long.MAX_VALUE)等函数实现,但最好不要这样做,以保证程序的健壮性。完整代码可以在GitHub中找到。