關於寫非同步程式碼測試用例的一些思考
如果說非同步程式碼不好寫是共識的話,那麼寫非同步程式碼測試用例就更難了。最近我剛剛完成了一個 Flaky 測試,所以想和大家分享一些關於寫非同步測試用例的想法。
這篇文章裡,我們會探索一個關於非同步測試用例的常見問題 —— 如何強制規定某些執行緒的順序,如何強制某一個執行緒操作早於另一些執行。通常我們並不想強行規定執行緒之間的順序,因為這違背了多執行緒的原則,所謂多執行緒就是為了做到併發,從而使得 CPU 可以根據當前資源及應用狀態選擇最佳的執行順序。但是在測試中,為了確保測試結果的穩定性,又必須明確執行緒順序。
測試節流閥(Throttler)
在軟體業裡節流閥指的是用於限制併發操作個數,預留資源的模式,好比連線池,網路快取,或者 CPU 密集型操作。和其他同步工具不同的是,節流閥的角色是啟動“快速失敗”機制,即促使超額請求立即失敗,而不是等待。“快速失敗”機制之所以重要,是因為切換操作,等待操作會消耗資源 —— 埠,執行緒,記憶體等。
以下就是一個節流閥的簡單實現(基本上是訊號量的包裝,實際應用中應該是等待,重試等等)
class ThrottledException extends RuntimeException("Throttled!") class Throttler(count: Int) { private val semaphore = new Semaphore(count) def apply(f: => Unit): Unit = { if (!semaphore.tryAcquire()) throw new ThrottledException try { f } finally { semaphore.release() } } }
現在我們開始基本的單元測試:測試單執行緒的節流閥(我們使用測試框架 specs2)。本例裡,我們會驗證順序呼叫是否會超過節流閥的最大限制(maxCount變數如下所示)。注意,這裡我們用的是單執行緒,所以我們並不驗證節流閥的“快速失敗”功能,這裡的節流閥都處於不飽和狀態。事實上,我們只會測試節流閥在不飽和狀態下不會終止操作。
class ThrottlerTest extends Specification { "Throttler" should { "execute sequential" in new ctx { var invocationCount = 0 for (i <- 0 to maxCount) { throttler { invocationCount += 1 } } invocationCount must be_==(maxCount + 1) } } trait ctx { val maxCount = 3 val throttler = new Throttler(maxCount) } }
測試併發節流閥
前一個例子裡,節流閥處於不飽和狀態,因為單執行緒裡節流閥一般都不會飽和。下面我們來測試一下多執行緒環境下節流閥是否還能工作良好。
設定如下:
val e = Executors.newCachedThreadPool() implicit val ec: ExecutionContext=ExecutionContext.fromExecutor(e) private val waitForeverLatch = new CountDownLatch(1) override def after: Any = { waitForeverLatch.countDown() e.shutdownNow() } def waitForever(): Unit = try { waitForeverLatch.await() } catch { case _: InterruptedException => case ex: Throwable => throw ex }
ExecutionContext 用來構建 Future,waitForever 方法用來持有執行緒,直到測試結束前的鎖釋放。接下來的函式裡,我們會關閉一個執行服務。
以下就是一個測試節流器多執行緒行為的例子:
"throw exception once reached the limit [naive,flaky]" in new ctx { for (i <- 1 to maxCount) { Future { throttler(waitForever()) } } throttler {} must throwA[ThrottledException]
我們建立了 maxCount 個執行緒(呼叫 Future{})來呼叫 waitForever 函式,該函式會一直直到道測試結束。然後我們繞開節流閥執行另一個操作 —— maxCount + 1。預期的行為是,此時應該丟擲 ThrottledException 例外。但是,也許預期的例外並不發生,因為接力器的最後的一個呼叫可能會比 future 裡的先執行(future 裡會丟擲例外,但是這不是預期結果)。
上面這個測試的問題是,在像期望中那樣節流閥丟擲異常然後導致節流閥被違反之前,我們無法確定所有的執行緒都已經開始並且在 waitForever 函式中被阻塞。為了修復這個問題,我們需要一些方法去等待所有 future 開始。這有一個我們大多數都很熟悉的一種方法:只要增加一個 sleep 函式等待一些合適的時間。
"throw exception once reached the limit [naive, bad]" in new ctx { for (i <- 1 to maxCount) { Future { throttler(waitForever()) } } Thread.sleep(1000) throttler {} must throwA[ThrottledException] }
好了,現在這個測試幾乎都能通過了,但是這個方法還是錯的因為下面這兩個原因:
測試持續的時間至少會和我們設定好的”合適的時間”差不多久。
在非常罕見的情況下,比如機器處於高負載的時候,這個合適的時間不一定足夠。
如果你仍然感到疑惑,可以搜尋一下 Google 更多的原因。
一個更好的方式是將我們的執行緒(future)的開始和我們期望的東西同步起來。我們來使用 java.util.concurrent 裡面的 CountDownLatch 類:
"throw exception once reached the limit [working]" in new ctx { val barrier = new CountDownLatch(maxCount) for (i <- 1 to maxCount) { Future { throttler { barrier.countDown() waitForever() } } } barrier.await(5, TimeUnit.SECONDS) must beTrue throttler {} must throwA[ThrottledException] }
我們使用 CountDownLatch 處理障礙同步。這個等待的方法會阻塞主執行緒直到鎖存計數變為 0。隨著其它執行緒的執行(我們把這些其它執行緒表示為 future),每一個 future 都會呼叫 countDown 方法使鎖存計數減 1。一但計數變為 0,所有的 future 就都已經執行到 waitForever 方法中了。
通過那一點,我們可以確保 throttler 是飽和的,內部有最大數量(maxCount)的執行緒。另一個執行緒試圖進入 throttler 將導致異常。我們有一個確定的方式建立我們的測試,測試會有一個主執行緒進入 throttler。主執行緒可以恢復到這個點(門閂計數為 0 並等 CountDownLatch 釋放等待執行緒)。
如果一些意想不到的事情發生,我們使用超時略高保障避免無限阻塞發生。如果這樣的事情發生,我們的測試就失敗了。這個超時不會影響到測試時間,除非發生意外情況,否則,我們都不應該等待。
結論
測試非同步程式時,通常需要在具體的測試用例中指定多個執行緒之間的執行順序。不使用任何同步策略的測試是不可靠的,測試結果有時成功有時失敗。使用 Thread.sleep 降低了測試出錯的概率,但沒有完全解決這個問題。
在大多數情況下,當需要在測試中保證多個執行緒的執行順序時,可以使用 CountDownLatch 代替 Thead.sleep。使用 CountDownlatch 的好處是通過它可以指定釋放(保持)執行緒的時機,有兩個優點:確保按順序執行使測試結果更可靠;加快了測試程式的執行速度。即使對於普通的 waiting 操作,比如 waitForever 函式,儘管也可以使用 Thread.sleep(Long.MAX_VALUE) 這樣的函式實現,但為了保證程式的健壯性最好不要這樣做。
完整的程式碼可以在 GitHub 中找到。
相關文章
- 關於測試用例的一些迷茫
- 程式碼測試用例指南
- 介面測試用例編寫和測試關注點
- postman寫測試用例Postman
- 怎樣寫測試用例?
- 測試用例編寫方法
- 關於CodeReview的一些思考View
- 萬能測試用例及測試用例編寫方法(待更新)
- 關於 Web Content-Security-Policy Directive 透過 meta 元素指定的一些測試用例Web
- 關於 Web Content-Security-Policy Directive 通過 meta 元素指定的一些測試用例Web
- 關於程式碼質量退化的思考
- 關於 Go 程式碼結構的思考Go
- 關於同步的一點思考-下
- 關於寫部落格的思考
- 關於面試的思考面試
- 關於類的初始化以及類的例項化一些思考
- 關於 Masonry 的一些思考(下)
- 開發測試用例:手動擼程式碼 VS 填鴨式編寫
- 天天灌水,來寫點關於程式語言的思考。
- 關於單元測試的一些想法
- IDEA中用junit寫基本測試用例Idea
- 如何優雅編寫測試用例
- 程式碼寫作測試
- 如何能編寫優秀的測試用例
- pytest 能否執行 nose 寫的測試用例
- 關於賬號安全的一些思考
- 關於 Angular HttpClient 的單例特性的思考AngularHTTPclient單例
- 測試——水杯的測試用例
- 關於程式碼版本管理的思考和建議
- 寫給測試小白:怎麼快速找到bug?怎麼寫測試用例?
- 關於介面測試自動化的總結與思考
- 軟體測試用例編寫(含思路)
- 關於重寫equals()和hashCode()的思考
- 關於作業系統的一些思考作業系統
- 關於REACT正規化的一些思考React
- 關於 12306 售票的一些思考研究
- 關於aspnetcore中介軟體的一些思考NetCore
- 關於微服務劃分的一些思考微服務
- 關於Code Review的一些思考總結View