teprunner測試平臺Django引入pytest完整原始碼

dongfanger發表於2021-04-01

本文開發內容

pytest登場!本文將在Django中引入pytest,原理是先執行tep startproject命令建立pytest專案檔案,然後從資料庫中拉取程式碼寫入檔案,最後呼叫pytest命令執行用例。為了提高執行效率,用例執行是並行的,採用了多執行緒和多程式,兩個都有,這在最後有個單獨小結進行比較完整的說明。因為用例執行是非同步的,所以前端並不知道什麼時候執行完才能拿到執行結果,可以發多個HTTP請求輪詢,但這種方式並不優雅,本文將採用WebSocket來實現用例結果查詢。具體內容為:

  • cases/<int:pk>/run執行用例介面
  • ws/teprunner/cases/<int:case_id>/result/用例結果查詢介面
  • projects/<int:pk>/export下載專案環境介面
  • 前端新增WebSocket請求

知識點涉及有點多:tep、pytest、同步、非同步、多執行緒、多程式、回撥函式、WebSocket、長連線、全雙工、ASGI、WSGI、打ZIP包、檔案位元組流傳輸。

編寫後端程式碼

編輯requirements.txt,新增tep和channels:

tep==0.6.9
channels==3.0.3

tep是用來建立pytest專案的,channels是用來實現WebSocket的。

編輯teprunner/urls.py檔案,新增HTTP路由:

首先實現run介面,新建teprunner/views/run.py檔案:

這是執行用例的主體流程:

  1. 第一步從請求中獲取用例id、執行環境、執行人,這裡演示了獲取user資料的兩種方式:介面傳參和從token中解析。然後根據project_id,run_env,user_id定義了pytest專案的路徑。
  2. 第二步使用tep startproject建立專案檔案,清空fixtures和tests目錄,目的有兩個:一是清掉tep預設fixtures和示例cases,防止對平臺產生干擾;二是保證每次執行目錄都是乾淨的,就不用單獨去處理前端手動刪掉fixture/case後,檔案殘留的問題。然後從資料庫中拉取環境變數、fixtures等資料更新檔案。
  3. 起多個執行緒,分別執行用例,執行前先拉取用例程式碼寫入檔案,這裡是單條用例執行,之所以要用for迴圈,是因為用例遲早是要批量執行,在設計時就考慮到,避免後面走彎路。然後刪掉資料庫執行結果,通過subprocess起子程式呼叫pytest命令,最後線上程的回撥函式中根據pytest_result儲存用例結果到資料庫中。

注意!run_case介面不會直接返回結果,前端是用WebSocket來查詢結果的。

圖中很多函式和類是我封裝的,一個一個拆解來看:

這裡定義了Django中存放pytest專案的目錄檔案,project_temp_name是按照project_id、env_name、user_id來劃分的,目的是讓執行目錄儘量隔離開來,不要相互影響,借鑑了Docker容器的思想,可以把這個目錄視為用例執行容器。繼續:

tep startproject命令建立pytest專案,pytest檔案有特定組織方式,比如conftest.py檔案等,tep提供了腳手架一條命令建立專案結構。繼續:

fixture_env_vars.py裡面存放了tep的環境變數,Django這裡每次都從資料庫的env_var表中獲取資料,動態更新到檔案裡面。setdefault是個騷操作,這行程式碼等價於:

if env_name in mapping.keys():
    mapping[env_name][name] = value
else:
    mapping[env_name] = {name: value}

繼續:

分別從資料庫中獲取程式碼寫入fixture檔案和case檔案,把前端傳參的執行環境寫入conf檔案。繼續:

清空fixtures目錄,清空tests目錄。繼續:

pull_tep_files是寫環境變數,寫fixture檔案,寫conf檔案三步的集合,複用程式碼。pull_case_files通過yield定義為了生成器,它和list的區別是不會一次把所有資料產生到記憶體中,而是每次用的時候產生一次,節約記憶體開銷。delete_case_result用於執行用例前刪除case_result表裡面已經存在的這條用例的資料。case_result按照用例id和執行人存的多條,每個執行人都有一條屬於自己的執行資料,避免資料相互干擾,返給前端的是執行時間最新的那一條!

繼續:

subprocess.getoutput()可以執行shell命令並返回執行結果,這裡就拿到了pytest控制檯日誌,這個函式是線上程池中非同步執行的,主執行緒不能一直等待它執行,所以需要有個回撥函式,等它自己執行完了去呼叫這個回撥函式。save_case_result就是個回撥函式,它的入參pytest_result等於pytest_subprocess函式返回的元組,拆包後就能拿到outout、cmd、case_id、run_env、run_user_nickname,從中解析出result和elapsed後,就可以存庫了,無則新增,有則更新。

run介面做好了,再介面做下載環境介面,編輯teprunner/views/project.py:

打包的程式碼是從網上找的,把source_dir打包成zip_filename檔案。繼續:

file_iterator函式也是網上找的,把二進位制檔案讀取為位元組流,傳輸給前端,需要使用StreamingHttpResponse物件並新增Content-TypeContent-Disposition。紅框的程式碼跟run介面類似,區別在於目錄換成了export_temp_dir(),且不包含測試用例,生成zip檔案後會把匯出臨時目錄刪掉,防止衝突。

兩個HTTP介面做完了,開始實現WebSocket。WSGI一種閘道器介面,是Python為了解決Web伺服器端與客戶端之間的通訊問題而產生的,不支援WebSocket;ASGI是WSGI的擴充套件,意思是非同步閘道器介面,支援WebSocket。編輯teprunnerbackend/urls.py檔案:

新增了WebSocket路由。編輯teprunnerbackend/asgi.py檔案:

新增websocket的URLRouter,http保持預設。編輯teprunnerbackend/settings.py檔案:

INSTALLED_APPS中新增channels,繼續:

新增ASGI應用配置和CHANNEL配置。CHANNEL_LAYERS是一種通訊系統,允許多個Consumer例項之間互相通訊,以及與外部Django程式實現互通。學習版這裡使用的InMemory。

生產中不建議使用InMemory,可能會有效能問題,而是應該使用Redis:

CHANNEL_LAYERS = {
    "default": {
        "BACKEND": "channels_redis.core.RedisChannelLayer",
        "CONFIG": {
            "hosts": [("127.0.0.1", 6379)],
        },
    },
}

最後,編輯teprunner/views/case.py檔案:

CaseResultView是繼承了JsonWebsocketConsumer,可以接受和傳送JSON的WebSocket消費者。這裡只是簡單使用了channels來實現用例結果查詢,connect()在建立連線時,從url中拿到case_id,作為房間名,在channel_layer中建立了房間。disconnect()在斷開連線時,把房間從channel_layer中移除。繼續:

receive_json是在後端收到前端訊息時呼叫的。WebSocket是長連線,在建立連線後,不會斷開,可以繼續傳遞訊息;WebSocket是全雙工,不只是客戶端向伺服器發訊息,伺服器也能向客戶端發訊息。這裡服務端會給客戶端發4次訊息:

  • 第1次,返回用例描述和用例建立人。
  • 第2次,準確說會有多次,當查詢資料庫沒有結果時,會返回計時,前端效果是計時從1s遞增。
  • 第3次,如果查詢資料庫有結果,返回用例結果。
  • 第4次,60s後還沒有結果,返回超時資訊。

其中CaseResult是用order_by('-run_time')取的最新一條。最後的self.close()不是必須的,這裡加上是因為頻繁建立和關閉連線時,如果只是前端發起close(),後端可能會關閉不及時導致channels報錯,後端也加上close()能一定程度上避免報錯。

編寫前端程式碼

新建.env檔案:

新增HTTP和WebSocket後端地址,裡面以鍵值對的形式寫出環境變數,鍵名需要以VUE_APP_ 開頭。vue-cli打包時會自動尋找這些環境變數,注入到編輯上下文環境中。編輯vue.config.js檔案:

把target替換成.env裡面的環境變數。

編輯views/teprunner/case/CaseResult.vue檔案:

socketUrl用到了.env中的環境變數。通過new WebSocket建立socket物件,使用send()傳送訊息,傳了token。onmessage接收後端發過來的訊息。

每次開啟彈窗建立WebSocket連線,每次關閉彈窗斷開WebSocket連線:

前後端是在以用例id作為房間名的房間中,相互傳遞訊息的。多個瀏覽器的資料不會互串,因為Django Server預設是多執行緒!

多執行緒和多程式

每次瀏覽器發起請求到Django Server,Django都會新起一個執行緒來處理,這是非同步的,意味著多個瀏覽器連續發多個請求,每個請求的上下文都是獨立的,也不會阻塞等待。

如果Server不是用的Django Server而是用的Nginx,需要結合WSGI才能實現多執行緒。

在WebSocket通訊時,每個房間都是單個執行緒自己建立的,資料不會互串,具體原理還沒有研究,這個結論我是測試過的:修改後端程式碼返回隨機值,多個瀏覽器開啟同一個Case的結果,後開啟的Case結果並不會影響已經開啟的Case結果。

同理,多個瀏覽器同時執行用例,預設它們就是並行不是序列的,不會存在等待執行的情況,從前面程式碼可以知道,pytest命令是用subprocess子程式方式呼叫的,為了看到效果,我找了一個比較慢的Case,用多個瀏覽器執行了一下:

赤裸裸的多程式!pytest多程式靠譜麼?靠譜,因為pytest-xdist就只支援多程式,以下是擷取的官方Github的Issue:

threads是執行緒,processes是程式,pytest-xdist沒有使用執行緒。

如果想要多臺機器分散式執行用例,就要用pytest-xdist。

批量執行用例的情況略有不同,當批量執行用例時,前端只會有一個瀏覽器發起一次請求,讓後端拿多個Case來執行,Django只會分配一個執行緒來處理這個請求!如果我們在這個View裡面只是for迴圈去執行用例,那麼這些用例一定是序列的:雖然是用的subprocess,但是啟用subprocess的只有這一個執行緒,必須前一個執行完,才啟動下一個。這就是為什麼要再定義執行緒池的原因:

本文還沒有開發批量執行用例的模組,但後端已經實現了這個擴充套件,只需要再生成一個CaseList就能跑批量了。

小結

本文把pytest引入到了測試平臺中,已經可以跑Case了。文章涉及到的知識點有點繁雜,對我來說這一版也做了不少優化,反覆實踐和測試,參考資料加了很多。完整原始碼請到GitHub上獲取,按照README命令就能直接把前後專案跑起來看效果。做到這裡,teprunner測試平臺已經不是個花架子了,而是有著pytest核心引擎驅動的真測試平臺。它一定不是你做測試平臺的終點,但也許能成為做測試平臺的起點,也許能成為撬動地球的支點。

參考資料:

前端原始碼 https://github.com/dongfanger/teprunner-frontend

後端原始碼 https://github.com/dongfanger/teprunner-backend

https://github.com/pytest-dev/pytest-xdist/issues/409

https://blog.csdn.net/weixin_42329277/article/details/80741589

https://www.cnblogs.com/xiao987334176/p/14361893.html

https://juejin.cn/post/6844904195758243848

https://segmentfault.com/q/1010000022975655

https://channels.readthedocs.io/en/stable/topics/channel_layers.html

https://segmentfault.com/a/1190000018096988

https://www.jianshu.com/p/65807220b44a

相關文章