在開發的時候,有時候會需要 Scheduler 執行一些定期任務,例如:撈資料到本地端存放、檢查並繼續未完成任務 …等。今天主要討論如何測試 Schedler,這個主題也與時間有點關係,但是跟昨天的主題不太一樣,話不多說,我們直接看一下測試 Scheduler 會碰到什麼問題吧。
我們常會用 Scheduler 來執行定期更新資料的任務,像下面這個例子中,我們每五秒會的更新使用者的錢包,讓使用者的錢包常常維持最新的狀態。(P.S. 其實更新資料用 Push 策略是比較效率的做法,而不是使用 Pull 策略,但這邊請先忽略這件事 )
class UpdateWalletScheduler {
final WalletRepository walletRepository;
UpdateWalletScheduler(this.walletRepository);
void start() {
Timer.periodic(const Duration(seconds: 5), (timer) {
walletRepository.update();
});
}
}
讓我們為這個 Scheduler 寫這個測試,如果之前的所有測試儀樣,準備測試替身,呼叫 SUT 方法,驗證結果。
@GenerateNiceMocks([MockSpec<WalletRepository>()])
main() {
test("update wallet after 5 seconds", () {
var mockWalletRepository = MockWalletRepository();
UpdateWalletScheduler(mockWalletRepository).start();
verify(mockWalletRepository.update()).called(1);
});
}
很快地就會發現測試失敗了,mockWalletRepository.update() 並沒有成功被呼叫到。[範例連結]
檢查一下後會發現,程式執行完 start 後,要等五秒之後才會執行 mockWalletRepository.update(),但是測試卻是在 start 之後就馬上驗證,那當然會驗證失敗。那我們應該怎麼修改測試呢?依照最直覺的方式,那我們就老老實實等五秒吧。
@GenerateNiceMocks([MockSpec<WalletRepository>()])
main() {
test("update wallet after 5 seconds", () async {
var mockWalletRepository = MockWalletRepository();
UpdateWalletScheduler(mockWalletRepository).start();
await Future.delayed(const Duration(seconds: 5));
verify(mockWalletRepository.update()).called(1);
});
}
在執行 start 之後等待五秒再驗證,測試確實成功了。[範例連結]
但是這個測試執行花費時間很長,開發人員得真的等五秒才會通過,如果我們有很多需要等待一定時間的測試,整體執行時間會變得很長。一旦測試時間花得越久,開發人員就會越來越不願意頻繁執行。
除了之前講到的可重複性之外,單元測試還必須執行快速,為什麼需要執行快速呢?當我們每修改一小段程式碼,我們就可以執行單元測試來確認,確認我們這次修改有沒有弄壞東西,快速執行,快速驗證。當測試錯誤的時候,因為我們只有改一小段程式碼,所以我們可以很快發現哪邊改壞了。執行快速的單元測試,可以提供開發人員即時的回饋,縮短開發回饋循環,可以讓我們每一個修改都更有信心。
想像一下如果測試執行時間很長,我們肯定會懶得頻繁執行,想改多一點程式碼後,再來一次執行,結果測試錯了還要回頭找到底是哪裡改壞了,最初是想節省時間,最後反倒是花更多時間。還記得 UserRepository 在測試中直接呼叫遠端 Server 的例子嗎?如果遠端伺服器正在忙,沒空回應,也會卡著我我們的測試,讓測試時間執行很久。
回到我們剛剛的範例,我們應該如何修改呢?與時間流逝有關的測試,我們可以使用 fake_async 套件,這個套件是由官方維護的套件,可以用於 Future、Stream、Timer 等非同步操作,讓我們用它來修改一下原本的測試。
@GenerateNiceMocks([MockSpec<WalletRepository>()])
main() {
test("update wallet after 5 seconds", () async {
fakeAsync((async) {
var mockWalletRepository = MockWalletRepository();
UpdateWalletScheduler(mockWalletRepository).start();
async.elapse(const Duration(seconds: 5));
verify(mockWalletRepository.update()).called(1);
});
});
}
而修改方法也很簡單,與 clock 的使用方式有點像,只要把測試包在 fakeAsync 方法中,然後當測試執行 start 之後,呼叫 async.elapse 假裝時間經過 5 秒,最後測試通過得到綠燈。[範例連結]
用套件雖然可以很好的解決我們的問題,那我們有沒有其他方式呢?
讓我們想一下,為什麼這個測試會這麼不好測試?因為我們用到了 Timer 這個框架提供的物件,當我們使用框架或套件的東西時,有可能會變得不好測試,因為這些東西在設計之初可能沒有考慮測試場景。在寫單元測試中,我想要知道的是我們的邏輯是否正確,而不是去測試第三方套件的程式碼邏輯是否正確,在上面使用 fake_async 的測試中,在測試我們自己的邏輯過程中,也同時測試了 Timer 是不是經過五秒之後就會來呼叫我們的方法。
所以我們也可以考慮直接測試任務內容即可。以下面的例子來說,我們可以透過抽取方法的方式,將主要任務抽出成獨立一個方法,並在測試中直接測試這個方法。
class Scheduler {
final WalletRepository walletRepository;
Scheduler(this.walletRepository);
void start() {
Timer.periodic(
const Duration(seconds: 5),
(timer) => execute(),
);
}
void execute() {
walletRepository.update();
}
}
修改之後,就像下面這個測試一樣,直接測試 execute 方法的正確性。[範例連結]
@GenerateNiceMocks([MockSpec<WalletRepository>()])
main() {
test("should update wallet", () {
var mockWalletRepository = MockWalletRepository();
UpdateWalletScheduler(mockWalletRepository).execute();
verify(mockWalletRepository.update()).called(1);
});
}
讓我們比較一下上面介紹這兩個方法
使用 fake_async
只測主要任務
如何取捨使用 fake_async 模擬時間流逝,或者直接主要任務的部分,則由觀眾朋友的信心而定,未來的文章中應該會再次談論到這個問題,這邊就先不多說。
當我們寫單元測試時,需要注意測試執行是否有過慢的問題,當測試執行太久,就應該思考是否有方法可以加速它,避免測試越跑越慢,使得開發人員不想執行。在時間流逝有關的測試當中,我們可以選擇使用 fake_async 來解決,也可以選擇測試主要任務的部分就好,端看開發者當下的狀況決定。