單元測試——使用 Test Client 與 pytest 測試 API
2024 iThome 鐵人賽
這是 Django Ninja 系列教學的第 29 篇。
「請問你們的專案有單元測試嗎?」
面試中如果你提出這個問題,可能會讓面試官面有難色。
測試的重要性,大部分開發者都心知肚明。只是願意認真對待的人未必很多。
但如果真心想提高程式碼品質、減少 bug,讓專案更容易維護,那單元測試依舊是不可或缺的工具。
良好的測試不僅能幫助我們及早發現問題,還能在專案重構或新增功能時,確保現有的功能不會被破壞。
雖然寫測試會增加初期的開發時間,而且維護上也需要花費心力——這本來就不是一件輕鬆的事。但長期而言,它能為專案帶來持續的健全與穩定性。
所以,我們還是好好寫測試吧!
本文大綱
這是整個系列中唯一一篇有全文大綱的教學。
原因是,本文要提及的事項較多,畢竟單元測試這麼大的主題,怎麼可能靠一篇 2500 字的文章說完。限於篇幅,無法一一詳談——但也不能直接省略。
所以需要有一個供讀者鳥瞰的全文輪廓,讓你更容易了解、吸收。大綱如下:
- 單元測試的理想與現實。
- Django API 測試重要概念說明。
- Test Client 的意義與用途。
- pytest 和 pytest-django 簡介。
- pytest fixtures 與測試函式。
- 測試程式碼的實作與解說。
- 結語。
簡單來說,本文不會講解所有的程式碼改動,而是在必要時提及。其餘部分,由我直接實作並收錄在範例專案中,讓讀者自行參考。
在有限的篇幅中,帶你了解整體概念比關注細節更重要。當你掌握了基本概念,再去看程式碼會更加得心應手。
有關單元測試的更多討論,歡迎參考這篇心得〈為什麼你「應該」寫單元測試——《Python 工匠》筆記〉。這是一本立論紮實的好書,相信你會有所收獲。
本文所有的程式碼改動,可參考這個 PR。
一、單元測試的理想與現實
我覺得,討論單元測試,就必須先直面現實。
在軟體測試領域,充斥著各種關於測試的狂熱與教條主義,有時反而讓人卻步。
單元測試的理想
理論上,撰寫單元測試應該是每位開發者都要做的事(我確實是這麼想的) 。
此外,還有「測試驅動開發(TDD)」的理念,這是一種以測試為主導的開發模式,要求在撰寫功能程式碼之前,先撰寫測試。
甚至有少部分人認為,測試覆蓋率就是要 100%。因為如果不是 100%,比如 70%,那我們就可以問:「為什麼不是其它數字?」
現實:大部分人不在乎那些理想
然而,現實中,我們很少能看到理想的測試——甚至常常沒有測試。
現實中的專案因為時間、資源等諸多限制,往往不願投入心力去撰寫測試。
一些老舊專案,由於前期沒有測試基礎,後續要再補上測試變得更加困難(畢竟都亂成一團💩了),這就是我們常說的「技術債」。
再者,過度的「測試理想主義」有時也會讓初學者望而卻步。許多新手在接觸測試時,會擔心自己無法達到 100% 的覆蓋率,因此對測試產生了抗拒或懷疑。
這樣的完美主義往往有害無利,我們需要在理想與現實之間找到一個折衷。
折衷:務實的測試策略
在實際開發中,我們應秉持著一個實用且可行的測試策略,重點放在測試專案中最核心的功能,例如 API 的呼叫與 200 回應。
多數情況下,只要能覆蓋 60-70% 功能,就已經能明顯提高專案程式碼的品質,並為後續開發提供一定的安全感——這真的很重要。
不必追求完美的測試覆蓋率,只要願意開始行動,測試就能發揮它應有的價值。
二、Django API 測試重要概念說明
回到專案本身。
雖然本文無法提及太多 API 單元測試的具體細節,但重要的概念仍不可略過。以下一一說明。
Test Client 的意義與用途
Test client 對 API 的測試至關重要,因為它能模擬真實的 HTTP 請求——注意,只是模擬。
API 測試和一般的程式碼測試略有不同,一般的測試,只要寫好相關的測試函式、邏輯並執行即可。但在 API 測試中,還需要一個「假的客戶端」來模擬請求的發送。
手動測試 API,我們通常會使用 API client,比如 Postman。而自動化的單元測試,則需要把這個「假的客戶端」直接寫在測試程式碼中——即 test client。
它相當於一個「專案內部的 API client」,而且能自動執行。
Django Ninja 有提供自己的 test client,但我建議你先不要用,因為它還不夠健全。
在範例專案中,我使用的是 Django 內建的 test client——歷史悠久、穩定可靠。
pytest 簡介
pytest(對,它的 p 是小寫,同 pyenv)是一個廣受歡迎的 Python 測試框架,擁有自己的生態系——包含大量實用的外掛。
相較於 Python 內建的unittest
模組,pytest 的語法更直觀、使用上的靈活性更好。尤其是它的 fixtures、參數化測試等功能,讓測試的撰寫更加簡單、高效。
pytest-django
pytest-django 是一個專為 Django 設計的 pytest 整合套件。它提供了豐富的 Django 整合功能,包括許多內建的 fixtures 和實用的裝飾器。
其中又以@pytest.mark.django_db
裝飾器最常用,它能自動管理測試過程中的資料庫狀態。
它讓 pytest 在每次測試執行前自動建立一個全新的資料庫,並在測試結束後刪除。這確保每次測試的環境一致,防止資料殘留導致的測試結果不準確。
pytest Fixtures 與測試函式
Fixtures 是 pytest 提供的一種機制,用來設定測試所需的初始環境。它們本質上是函式,但用法卻不像一般的函式。只要事先定義好,即可在測試函式中作為參數引用。
Fixtures 可以定義在 Django app 的tests.py
中,但我們通常將它們放在可供全專案共用的conftest.py
模組。
測試 API 時,我們經常需要一些初始資料,例如使用者、產品等。這些資料可以透過 fixtures 自動產生,無需每次手動重建。
如此一來,撰寫測試的效率提高,還避免了重複的狀態設定。
五、測試程式碼實作與解說
本篇我實作 3 個 fixture 和 3 個測試函式,它們都與 user 有關,容我擇要解說其中的細節。
強大而靈活的 pytest Fixtures
這是專案的 3 個 fixture,定義在conftest.py
中:(為減少篇幅我省略了 docstring)
1 | import pytest |
在這段程式碼中:
client
:提供了一個可以用來模擬發送請求的 Django test client。(未認證)user
:自動建立一個測試用的使用者,供測試函式甚至其它 fixture 引用。authenticated_client
:引用上述的 2 個 fixture,組合並模擬了一個登入過的 client,這樣才能測試那些有「認證保護」的 API。
Fixtures 的定義、組合與使用,是 pytest 的一大特色。
不僅能簡化測試的環境設定,還能提高測試程式碼的可讀性——把測試狀態和測試邏輯分開,這也是一種「關注點分離」。
在實際的測試函式中,我們只需要將所需的 fixtures 作為參數傳入,pytest 會自動處理它們的初始化和清理工作。
這種設計大大減少了重複程式碼,讓測試更加專注於 API 的邏輯驗證而非環境設定。
測試函式
最後是測試函式,我們看其中的兩個就好:(我省略了參數的 type hints,讓你聚焦於 fixtures 本身)
1 | def test_get_users(authenticated_client) -> None: |
選擇這兩個函式是有教學用意的:
test_login_user
函式測試「使用者登入」API,該 API 是給「未登入」的用戶存取,所以引用一般的 client(未認證)即可。- 函式也引用了 user fixture,因為登入成功的前提是:該用戶已經「存在」。
- 而 user fixture 的作用正是在測試開始前,先建立該用戶。
test_get_users
測試的是「有認證保護」的 API,需要登入才能存取,所以我們引用了authenticated_client
。- 這個測試函式「只有」引用 authenticated_client,但實際的測試結果會是:清單中存在一個用戶。(程式碼不包含這部分)
- 因為 authenticated_client 引用了 user 和 client 這兩個 fixture。所以只要引用了authenticated_client,就相當於引用了上述二者。
該「引用」哪些 fixture,就看各函式需要什麼樣的測試狀態與條件。
Fixtures 本身可以重複使用,這樣的設計讓測試本身也非常「模組化」——這是 pytest 如此受歡迎的原因之一。
執行單元測試
最後,來跑一下測試!
你可以在專案的根目錄直接使用pytest
指令,或透過 VS Code 的 Testing UI 來執行單元測試:
VS Code - Testing
Beautiful!
六、結語
理想與現實總有差距,透過務實的測試策略,我們可以在不過度追求完美的前提下,為專案提供足夠的品質保證。
Test client 和 pytest 等工具,讓 API 測試變得簡單、有條理。測試覆蓋率不必是百分之百,只要能達到一定水準,就可以為開發過程帶來巨大的助力。
本系列教學已接近尾聲。我們探討了 Django Ninja 的核心功能與進階特性——從路由設計到單元測試。這是一個辛苦但充實的過程——無論對你我而言。
下一篇,也就是最後一篇。我們要簡單回顧整個系列,並分享我在本次鐵人賽的創作與完賽心得。