如何通過測試提升 Python 程式碼的健壯性

語言: CN / TW / HK

0x00 前言
本文的更多的是寫給 Python 後端的程式設計師。來簡單分享一下我對寫測試的理解。

本期就聊聊測試這件小事情。

本文目錄如下:

▼ 如何通過測試提升 Python 程式碼的健壯性 : section
    0x00 前言 : section
  ▼ 0x01 測試的分類 : section
      後端主要關注哪些測試 : section
  ▼ 0x02 為什麼要寫測試 : section
      讓新手更快的瞭解程式碼 : section
      讓釋出程式碼的時候更加有底氣 : section
      讓程式更容易重構 : section
      加快團隊的開發速度 : section
  ▼ 0x03 為什麼不要寫測試 : section
      測試不能解決的問題 : section
      不適當的測試為什麼是負擔 : section
      並不是所有地方都容易測試的 : section
  ▼ 0x04 寫 Python 測試的一些注意事項 : section
      專案的環境隔離 : section
      測試的基本環境 : section
      單測 / 功測 / 端對端 : section
      如何處理外部服務 : section
      其他 Pytest 小技巧 : section
    0xEE 參考 : section

0x01 測試的分類
測試有很多種,按照測試設計的方法可以分為:1. 黑盒 2. 白盒

按照測試目的:

  1. 功能測試
    單元測試
    功能測試
    整合測試
    場景測試
    A/B 測試
  2. 非功能測試
    壓力測試
    安全性測試
    可訪問性測試
    其他
    迴歸測試
    易用性測試
    還有不少,懶得去整理了.....

程式碼覆蓋率顧名思義,就是測試用例覆蓋執行程式碼的比重。

後端主要關注哪些測試

  • 單元測試
  • 功能測試
  • 端對端測試
  • 效能測試

0x02 為什麼要寫測試
來講講測試的優點。

為什麼要寫測試來覆蓋程式碼。

  1. 適當的測試可以讓釋出程式碼的時候更有底氣。
  2. 適當的測試可以讓新手更快的瞭解程式碼。
  3. 適當的測試可以讓程式更容易重構。
  4. 適當的測試可以加快團隊的開發速度。

既不是不寫,也不是狂寫一氣。看到這裡你可能有些疑惑?寫測試還加快速度?Are you kidding?

一個一個來解釋吧。

舉個簡化版的例子,『使用者下單』到『使用者收貨』。

  1. 使用者『查詢產品』
  2. 使用者『使用優惠券』下單
  3. 使用者『線上支付』。當然,使用者也可以讓不付款,讓訂單失效。或者直接取消訂單。
  4. 商家『確認發貨』。
  5. 物流公司更新運單『發貨中』。
  6. 使用者『確認收貨』。當然,使用者也可發起退款。

讓新手更快的瞭解程式碼
測試用例裡的資料,往往是能跑通某段程式碼的最佳測試資料集合。

假如,有個程式設計師寫了 『下單-線上支付-確認收貨』的整合測試。作為剛接手這段程式碼的人。可以在最短的時間內,通過閱讀測試程式碼從而理解整個流程。

有 fixture, 新手可以在很短的時間內知道 setup 能讓專案跑起來的基本資料

當然,如果過多的寫了測試,也會導致閱讀起來比較困難。

讓釋出程式碼的時候更加有底氣
寫測試,是為了驗證程式碼執行正確。

一個流程,通常包含若干個子流程,子流程是對的,整個流程才是對的。

如果不寫測試對一些關鍵的流程進行全面的覆蓋,則會導致
修改或者新增了一個子流程,需要重新跑個流程進行人肉測試。
如果人肉測試太費事,則一般程式設計師就會跳過這個步驟導致線上出問題。

讓程式更容易重構
當你知道寫測試程式碼有這麼多優點的之後,你的第一反應是,這我都知道,但是,寫測試還能加快開發速度?

當然,你要知道,一個需要去維護的有價值的產品,往往需要不斷的修改流程。

一開始,PM 告訴你只需要下單買個東西,後來,要加上滿減券,再後來要加上各種型別的券,然後你要對接第三方服務,接下來你要對付各種不按照你設定的流程出牌的使用者….

寫測試,則是通過不斷的補充一些測試,實現整個流程的測試自動化。形成一套測試該專案的測試程式碼。流程長的令人髮指,你指望全靠人肉來測試?

    1. 當我修改或者新增子流程的時候,在已經構建出來的測試程式碼上,可以花少量的程式碼直接保證修改或者新增的子流程輸入和輸出被測試到位。
  1. 多人合作的時候,如果 A 原先維護了一套子流程,而 B 來改了一波 A 寫的子流程。在有適當的測試的情況下,基本上改出問題來,都會跑不過測試的。

當然,前提是

  1. A 用心寫了測試,而不是寫了僅僅能讓 A 的程式碼跑的過去的測試。
  2. 但是測試如果寫過多的話,也會造成團隊精力的分散。這下面談到測試的缺點的時候就會知道。

加快團隊的開發速度
雖然說,我寫的是加快團隊的開發速度,但實際上,也適用於個人。
除非,你是寫渲染頁面的…. 所見即所得。無需任何測試

0x03 為什麼不要寫測試
依照軟體界著名的『沒有銀彈』理論,說完了測試的優越性,也要來說說測試的侷限性,主要有三點:

  1. 測試不能解決什麼問題?
  2. 不適當的測試,往往是負擔。
  3. 並不是所有地方都容易測試的。

測試不能解決的問題
測試能確保程式碼的執行質量,但無法確保程式碼編寫質量,也無法保證產品設計邏輯上的問題。

也就是說

  1. 程式碼寫的爛,測試程式碼只能確保編寫程式碼是可以正常執行的。並不能改善程式碼質量。最多給爛程式碼的重構提供比較好的執行保證。
  2. 產品設計邏輯上的問題,測試程式碼也只能保證這個設計邏輯落地。

當你覺得測試程式碼寫起來比較難受的時候,你應該考慮重構一下你的程式了。

不適當的測試為什麼是負擔
人總要習慣的是:

  1. 東西,學是學不完的。未知的東西永遠存在。新的事物總是在出現,老的事物也不斷在演進。
  2. 時間有限,精力有限

放到測試上來說,測試,也是測不完的。

寫了一個 IF ELSE , 你需要測兩組,多寫了一個 IF ELSE, 你就要測四組。如果是一個比較複雜的流程的話,基本上全面測試就很難寫完了。

我的想法是:

  1. 挑選關鍵的地方進行測試
  2. 減少使用者不必要的資料獲取

並不是所有地方都容易測試的

並不是所有地方都容易測試的。

  1. 特別依賴其他服務商的業務。比如,支付寶 / 微信的預支付。微信小程式的登陸。
  2. 跨端的業務。

這類業務如果做的比較深入,需要 Mock 掉很多邏輯。

0x04 寫 Python 測試的一些注意事項
專案的環境隔離
從整體專案角度,程式碼的執行環境應該區分 Local/Test/Stage/Prod 四種環境。

  • 本地環境:開發者電腦上的環境
  • 測試環境:開發者電腦上 / 持續整合上的環境,之前比較喜歡用 GitlabCI, 後來 團隊上了 jenkins, 用起來也還行。
  • 預釋出環境:預釋出環境,對後端來說,通常情況下就是前端可以通過呼叫 API 的環境。
  • 生產環境:生產環境。

之所以要做這種區分,是因為不同的環境側重點不同。

  • Local 環境 針對開發者設定的,這個環境的程式碼變更比較頻繁。Web 應用 / Worker / Beat / Deamon 在本地環境中,一般報錯比較多,一般我會在禁掉日誌。
  • Test 環境 用於執行 make lint && make test,用於檢查 lint 相關程式碼並執行測試。
  • Stage 環境
  • Prod 環境 和 Stage 環境就比較接近了。但也不完全一致。比如生產環境的組織或商家的一些開發資料。

測試的基本環境
一般起一個 Docker-Compose 檔案,來快速初始化測試環境。

比如 WebApp / Celery Worker / Celery Beats / Redis / RabbitMQ / MySQL 可以 make start 直接起這些服務。

單測 / 功測 / 端對端
之前說,後端需要注意下面的測試

  • 單元測試
  • 功能測試
  • 端對端測試
  • 效能測試

效能測試一般可以通過監控來提前對系統在哪些地方有瓶頸。看場景,一般觀察監控會更加容易預測系統的瓶頸,這個更多偏向於調優,放到後面來說吧。

框架假設我們使用 Flask , 再假設有這麼一個 BBS(我知道你想吐槽為什麼又拿部落格 /BBS 舉例子,懶得交代過多的業務場景背景知識了,逃…)

  1. 組織 Organization 釋出了一個 Thread
  2. 使用者 User 在這個 Thread 進行了 Reply 『未註冊的使用者能看見』
  3. 管理員 Admin 發現了 User 似乎釋出了不該釋出的資訊。刪 Reply。『未註冊的使用者看不見 / 所有者是能看見的』
  4. 最後 User 進行申訴,Admin 發現其實發布的東西挺 OK 的,給予通過。『未註冊的使用者能看見』

    tests # 測試檔案目錄 ├── init.py ├── conftest.py # 這裡存放可能被子目錄引用到的集合 ├── e2e # 『端對端測試』 │ ├── init.py │ ├── test_viewer.py │ ├── test_user.py │ ├── test_admin.py │ └── test_organization.py ├── functional # 『功能測試』 │ ├── init.py │ ├── test_do_simple_reply.py │ ├── test_do_complex_reply.py │ └── test_helper.py ├── unit # 『單元測試』 | ├── init.py | ├── test_auth.py | └── test_calc_some_thing.py ├── test_auth_helper.py # 存放基本的用於切換身份的程式碼 ├── test_const.py └── test_factory_helper.py # 可以用來批量初始化資料

這個流程並不算複雜,但足以寫測試了。

  1. 在 test_factory_helper 完成資料的基本初始化。
  2. 在端對端測試中簡單測試瀏覽。包含未註冊使用者 viewer 的訪問,user/admin/org 的帶有效 / 無效 / 過期登陸憑據訪問
  3. 在 unit 中測試一些和業務聯絡不緊密的邏輯。比如,計算時間
  4. 在 functional 進行比較獨立的測試。有的時候也會把幾個功能拉起來做測試。相對獨立的測試,就是新建一個 User 的 Thread, 刪除 Reply, 拉起來測試就是 1/2/3/4 一個測試就完了。

前者比較簡單,後者相對而言更加靠近整合測試。各有利弊。我一般在關鍵流程上多做幾個拉起來測試的程式碼。

但拉起來測試要解決的問題就多了一個,即,使用者登陸認證。你呼叫某個 Service 的時候,是以匿名使用者 / 使用者身份 / Admin / Org 呼叫的。

即在呼叫不同的 Service 解決問題的時候,你可能需要快速的切換身份。切換完身份再速度切換回來。於是,test auth helper 出來了。helper 裡面有個 switch as 函式,每次需要切換身份的時候,把 g 變數裡面的登陸快照 g.user g.admin http://g.org push 到 LocalStack 棧裡 (from werkzeug.local import LocalStack), 呼叫完 Service 再 Pop 出來。

拉起來測試的效果是這樣子的。

def test_complex_process(org, user, admin):
    with switch_as_org(org) as org:     # 1. 組織 Organization 釋出了一個 Thread
        thread = publish_thread_by_org()
        with switch_as_user(user) as user: # 2. 使用者 User 在這個 Thread 進行了 Reply
            reply = reply_thread(thread)
            assert reply
            with switch_as_anonymous() as anonymous_user:
                _thread = see_thread(thread)
                assert reply in _thread.replies # 『未註冊的使用者能看見』
            with switch_as_admin() as admin: # 3. 管理員 Admin 發現了 User 似乎釋出了不該釋出的資訊。刪 Reply。
                delete_reply(reply)
                assert reply.deleled
            with switch_as_anonymous() as anonymous_user:『未註冊的使用者看不見』
                _thread = see_thread(thread)
                assert reply not in _thread.replies
            # 在這裡,我的身份還是 user
            _thread = see_thread(thread)
            assert reply in _thread.replies # 『Ower 使用者能看見』
        # 4. 最後 User 進行申訴,Admin 發現其實發布的東西挺 OK 的,給予通過。『未註冊的使用者能看見』

作為開發者,你只需要讓這個測試跑通就基本開發完畢了。在這個過程中,你也可以更好的梳理你的程式碼。

如何處理外部服務
在拉起來做測試的時候,假如我們多了一個流程,使用者可以通過微信支付讚賞 reply, 這就不得不依賴於外部的服務。

而拉起來做測試的時候,就會遇到一個非常尷尬的問題,因為我上面的介面都粒度都比較大,是讚賞這個流程裡面的非常小的流程,必須要走微信的 http 請求。

解決方式也很簡單。mock 掉請求微信的函式。手動呼叫一下支付回撥函式,即可。

當然,對於 http 請求,也可以使用 responses 這個神器來快速 mock 神器 requests 的 response

大致的用法如下

def mock_success_pay():
    def request_callback(request):
        headers = {}
        dispatch_callback(data=data)
        return 200, headers, resp_body

    responses.add_callback(
        responses.POST,
        PAY_URL,
        callback=request_callback,
        content_type="application/json",
    )

@responses.activate
def test_pay(user):
    mock_success_pay()
        switch_as_user(user) as u:
            order = pay_order(u)
        assert order.status == "PAID"

其他 Pytest 小技巧
有的時候 ipdb 比 pdb 用起來不止好了一點點。如何在 pytest 裡用上呢?

pytest -v --pdb --pdbcls=IPython.terminal.debugger:Pdb

以上就是本次分享的所有內容,想要了解更多 python 知識歡迎前往公眾號:Python 程式設計學習圈 ,傳送 “J” 即可免費獲取,每日干貨分享