如果測試每次都要真的對外發送請求,會有一連串問題:測試變慢、測試結果依賴外部服務是否正常運作、外部內容一旦改版測試就會莫名其妙失敗。要測試的是「解析邏輯對不對」,不是「網路連得通嗎」——這兩件事該用不同的方式驗證。
class MockResponse:
def __init__(self, content: bytes, headers: dict):
self._content = content
self.headers = headers
async def read(self):
return self._content
def raise_for_status(self):
pass
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
pass
這個類別手刻了 async with 語法需要的 __aenter__/__aexit__,還有 HTTP client 常見介面裡的 read()、raise_for_status()——它不是在模擬「網路請求」這件事本身,而是在模擬「呼叫端實際會用到的那組介面」,讓被測試的程式碼以為自己在跟真實的 HTTP client 互動,實際上拿到的是完全可控的假資料。
get_fixture 依請求網址分派對應的假資料def get_fixture(url: str) -> bytes:
if 'source-a' in url:
return read_file('fixtures/source-a/page.html')
if 'source-b' in url:
return read_file('fixtures/source-b/page.html')
...
@patch.object(Http, 'get')
async def test_collect_items(mock_get, mocker: MockFixture):
mock_get.side_effect = lambda url: get_fixture(url)
...
這種設計讓測試碼可以針對「同一個爬蟲,面對不同網址該回傳什麼假資料」建立一份對照表,而不用為每個測試案例各自手刻一份 mock 設定——測試資料的組織方式,本身也是值得花心思設計的一環。
手刻 mock 最容易犯的錯,是只模擬了「目前這段程式碼用到的方法」,漏掉了一些邊界情況會用到、但當下沒注意到的介面細節——例如某個呼叫路徑其實會呼叫 response.headers,但 mock 只實作了 read()。這種遺漏平常不會被發現,直到程式碼的某個分支第一次真的走到那條路徑,測試才會因為 AttributeError 失敗,而且失敗訊息通常不會直接告訴你「這是 mock 沒做完整」,需要一點除錯功夫才看得出來。
你測試過依賴外部 HTTP 呼叫的程式碼嗎?用的是手刻 mock、現成的 mock 函式庫,還是真的發請求打測試伺服器?手刻的 mock 有沒有涵蓋到你目前還沒踩到、但介面上其實存在的方法?
明天講測試檔案系統操作:用假的檔案系統,不用真的寫磁碟。