背景
經(jīng)常做pytest插件開發(fā)的話, 一定會看到不少如下代碼片段:
def pytest_configure(config):
...
# prevent ... on slave nodes (xdist)
if not hasattr(config, 'slaveinput'):
...
其實這些代碼都是為了兼容一個叫pytest-xdist的插件的.簡單介紹一下這款插件, pytest-xdist這款插件允許用戶將測試并發(fā)執(zhí)行(進程級并發(fā)). 主要開發(fā)者是pytest目前的核心開發(fā)人員Bruno Oliveira, 截至寫作時, 該項目已有371個star, 應(yīng)用于4150個項目. 需要注意的是, 由于插件是動態(tài)決定測試用例執(zhí)行順序的,為了保證各個測試能在各個獨立線程里正確的執(zhí)行, 用例的作者應(yīng)該保證測試用例的獨立性(這也符合測試用例設(shè)計的最佳實踐).
流程
這里介紹了插件的執(zhí)行原理, 我作了簡單的翻譯并且加了一部分注解.
和大多數(shù)的分布式系統(tǒng)相似, xdist里有master和worker的概念.master負責(zé)整個測試任務(wù)的調(diào)度, 測試報告等工作, 而worker則是實際執(zhí)行測試的宿主進程.
具體的測試執(zhí)行的流程如下:
在test session的起始階段,
xdist會spawn一個或者多個worker進程. master和worker間的通信基于 execnet 和它的gateways. worker的解釋器可以是本地或者遠程的.-
收集測試項:
每個worker是個迷你的
pytest runner對象. workers這時會執(zhí)行一個完整test collection過程, 然后將結(jié)果發(fā)回到master(master本身不做測試收集工作). -
測試收集檢查:
master收到這些節(jié)點發(fā)回的結(jié)果后, 執(zhí)行一些sanity檢查以確保所有worker節(jié)點都收集到相同的測試項(包括順序). 當(dāng)所有的檢查都通過后, 再將這些測試項轉(zhuǎn)換為一個簡單的索引列表, 每個索引對應(yīng)一個測試項的在原來測試集中的位置. 這個方案可行的原因是所有的節(jié)點都保存著相同的測試集, 并且使用這種方式可以節(jié)省帶寬, 因為master只需要告知節(jié)點需要執(zhí)行的測試項對應(yīng)的索引, 而不用告知完整的測試項信息.
FAQ環(huán)節(jié)其實提到, 在各個node上單獨執(zhí)行測試收集工作是因為如果在master上執(zhí)行測試收集,那么就需要作很多序列化處理, 因為worker是進程級的. 這會使問題復(fù)雜化, 并且使pytest變得不易于維護.
-
測試分發(fā):
- 如果
dist-mode是each, 那么這時master只需將完整的列表發(fā)送給每個節(jié)點. - 如果
dist-mode是load, 那么這時master會將大約25%的測試項以輪詢的方式發(fā)往各個worker. 剩余的測試項則會等待workers執(zhí)行完測試以后分發(fā), 見下文.
注意:
pytest_xdist_make_scheduler這個hook可以用于實現(xiàn)自定義的分發(fā)邏輯. - 如果
-
測試執(zhí)行:
workers 重寫了
pytest_runtestloop: pytest的默認實現(xiàn)基本上是循環(huán)執(zhí)行所有在session這個對象里面收集到的測試項, 但是在xdist里, workers實際上是等待master為其發(fā)送需要執(zhí)行的測試項的. 當(dāng)worker收到測試任務(wù), 就順序執(zhí)行pytest_runtest_protocol. 值得注意的一個細節(jié)是:workers 必須始終保持至少一個測試項在的任務(wù)隊列里, 以兼容pytest_runtest_protocol(item, nextitem)hook的參數(shù)要求.為了將nextitem傳給hook, worker會在執(zhí)行最后一個測試項前等待master的更多指令.如果它收到了更多測試項, 那么久可以安全的執(zhí)行pytest_runtest_protocol, 因為這時nextitem參數(shù)已經(jīng)可以確定. 如果它收到一個 "shutdown"信號, 那么就將nextitem參數(shù)設(shè)為None, 然后執(zhí)行pytest_runtest_protocol. -
測試分發(fā)(Load模式):
當(dāng)測試項在 workers里的開始/結(jié)束執(zhí)行時, 測試結(jié)果會發(fā)回到master, 這樣其他pytest hooks比如
pytest_runtest_logstart和pytest_runtest_logreport就可以正常執(zhí)行.master (處于load的dist-mode時)在節(jié)點執(zhí)行完一個測試后, 基于測試執(zhí)行時長以及每個節(jié)點剩余測試項綜合決定是否向這個節(jié)點發(fā)送更多的測試項. -
測試結(jié)束:
當(dāng)master沒有更多待執(zhí)行測試項時, 它會發(fā)送一個"shutdown"信號給所有workers, worker將剩余的測試項執(zhí)行完畢并退出進程. master則一直等待workers全部退出, 當(dāng)然此時任然需要處理諸如
pytest_runtest_logreport等事件.
Best Practice
在了解了pytest-xdist的實現(xiàn)原理后, 為了保證開發(fā)的插件能夠正常與其配合(沒辦法, 這個插件太流行了), 建議在插件開發(fā)時:
對于只需在master上執(zhí)行的代碼, 比如
report類插件, 通常只需在master節(jié)點上初始化一遍并處理各個report對象. 我們可以通過判斷hasattr(config, 'slaveinput')來確定是否為worker節(jié)點, 區(qū)分處理相邏輯;-
由于測試執(zhí)行實際是在各個worker節(jié)點上執(zhí)行的, 在
pytest_runtest_makereport等hooks里要避免對象實例化操作, 因為你的實例化對象在序列化時會報錯, 比如某些測試使用了下面的conftest.py文件:import pytest class SomeThing(object): pass @pytest.hookimpl(hookwrapper=True) def pytest_runtest_makereport(item, call): outcome = yield report = outcome.get_result() report.something = SomeThing() def pytest_runtest_logreport(report): print('something: %r' % report.something)那么當(dāng)你使用pytest -n執(zhí)行時, 就會報類似這樣的錯誤:
INTERNALERROR> raise DumpError("can't serialize {}".format(tp))
INTERNALERROR> execnet.gateway_base.DumpError: can't serialize <class 'conftest.SomeThing'>正確的做法是, 將需要保存的數(shù)據(jù)保存到
report對象, 比如下面這段代碼可以將測試執(zhí)行的時間戳保存在report對象里, 之后worker便會將report同步給master節(jié)點:def pytest_runtest_makereport(item, call): outcome = yield report = outcome.get_result() if report.when == "call": report.call_start = call.start report.call_end = call.stop 目前發(fā)現(xiàn)除了自定義的類以外, 諸如
datetime類型也是不能直接序列化的, 遇到這種情況可以考慮將其保存為timestamp, 之后再做類型轉(zhuǎn)換操作.還有一種典型的錯誤是, 將諸如
pytest_runtest_makereport的hook函數(shù)寫成類的方法, 由于此類hook函數(shù)是在worker節(jié)點執(zhí)行的, 如果這個類只在master節(jié)點上進行了實例化, 相當(dāng)于寫了個無效的hook函數(shù), 而且這時雖然程序不會報任何錯, 這點要特別注意.
總之, 牢記config對象是進程間獨立的, 但是report對象之間的值可以互相同步的, 但是要避免同步對象;