那天是星期五晚上,11點47分。我的測試套件?一切順利。覆蓋率?高達94.7%。我的程式碼?太棒了! !我為自己感到驕傲…
我做的一切都是對的:
單元測試 - 已檢查
整合測試 - 已檢查
已涵蓋特殊情況 - 已確認
上個迭代周期裡那個奇怪的bug?已修復並測試—已驗證。
我的自信?爆棚——已驗證
我把工作推到生產環境,合上筆記型電腦,發出令人滿意的「啪嗒」一聲,然後收拾行囊。這個週末,我要征服卡納塔克邦的凱瓦拉貝塔山,這是一條海拔約1280公尺的健行路線。因為我習慣把筆記型電腦帶上山(別笑話我,我們每個人都有自己的小毛病),所以我想,萬一出了什麼事,至少在山頂還有手機訊號。
週六凌晨4點。我站在凱瓦拉貝塔山腳下,呼吸著清爽的山間空氣,感覺自己像是成長題材開發者紀錄片的主角。這時,我的手機震動起來。
Teams 通知:“生產環境已關閉。緊急!”
但是,嘿,我還有筆記型電腦!我這就……看看手機訊號……我爬快點,這樣訊號就更好了!
等我終於找到4G訊號還不錯的地方(大概在3000英尺高的地方,汗流浹背,就像黑色星期五的部署流水線一樣),我趴在一塊石頭上打開了筆記型電腦。想像一下:開發人員們從我身邊走過,享受著他們最愜意的無電子設備生活,而我卻像咕嚕抱著他的寶貝一樣,蹲在我的MacBook前。
這些原木…很有創意:
NoneType object has no attribute 'execute'
KeyError: 'user_preferences'
Connection timeout: Database not responding

我那94.7%的程式碼覆蓋率竟然沒涵蓋到這一點。我精心寫的單元測試竟然沒發現那一點。至於我周五晚上那點自信?現在就像印第安納瓊斯電影裡的巨石一樣,從山上滾落而去。

接下來我要說的觀點可能會引起爭議,也會在推特上被噴:單元測試固然必要,但並非萬能靈藥。它們更像是只保護軀幹的防彈背心。
在你厭惡地關閉這個頁面之前,請容許我解釋一下。
我的測試完美無缺,我的程式碼也完美無缺。問題出在哪裡呢?
環境差異:我的生產環境使用了不同的 Python 版本(3.9 對比 3.11),導致字典合併的處理方式不同。
競態條件:我的非同步資料庫連線存在競態條件,僅在負載較高時出現。
配置地獄:生產環境中的環境變數設定不同。
這些問題都沒有被單元測試發現。因為單元測試測試的是單元,是隔離的、模擬的、完美的小世界,在這個小世界裡,所有東西都完全按照你的指示執行。
真正的生產?那簡直就是一片無法無天的荒地,墨菲定律會騎著摩托車衝進你精心建造的沙堡。
不,絕對不行。放下武器!
關鍵在於:單元測試就像去健身房。就算你還會絆倒摔下樓梯,也不代表練腿日毫無意義。
單元測試捕獲到:
函數中的邏輯錯誤
凌晨兩點你忘記的那些極端狀況
重構時出現回歸
那次你不小心刪掉了一個關鍵的if語句
你同事的「快速修復」方法卻又弄壞了其他三樣東西。
他們沒發現的:
現實世界的混亂
系統整合問題
基礎設施問題
那台被惡魔附身的伺服器
宇宙不可避免的熱寂
坐在山頂上,除錯著生產程式碼,手機電量只剩12%的時候,我突然頓悟了。正確的測試方法如下:

不要只待在地下室進行單位測試,偶爾也要上樓看看。
壞的:
def test_user_service_calls_database_exactly_once():
# This test knows too much about HOW things work
mock_db.assert_called_once()
好的:
def test_user_service_returns_user_data():
# This test cares about WHAT happens
user = service.get_user(123)
assert user.id == 123
assert user.email is not None
def test_the_actual_freaking_database():
"""Yes, spin up a test database. Yes, it's slower.
Yes, it would've caught my production bug."""
db = create_test_database()
result = service.fetch_user_preferences(user_id=1)
assert result is not None # THIS WOULD'VE FAILED
我的程式碼假設資料庫連線始終存在。哈哈。
def test_graceful_degradation_when_everything_is_on_fire():
with mock.patch('db.connect', side_effect=TimeoutError):
# Does your app explode or handle it gracefully?
result = service.get_user(123)
assert result == None or isinstance(result, DefaultUser)
def test_third_party_api_still_returns_what_we_expect():
"""Because apparently they can just CHANGE THINGS"""
response = external_api.get_data()
assert 'user_id' in response # Would've caught the API change
assert isinstance(response['user_id'], int)
當我終於修復了這個漏洞(當時海拔3800英尺,電池電量僅剩4%)時,我意識到了一件事:
考試就像徒步旅行。你需要做好準備,但也要做好應對意外狀況的準備。
您的測試策略應包括:
單元測試(60%) :快速、專注、無所畏懼
整合測試(30%) :速度較慢、範圍更廣、更貼近實際。
端對端測試(10%) :耗時最長、範圍最廣、最痛苦,但也最有價值
手動測試:有時候你只需要四處點擊,看看哪裡出了問題。
監控:因為測試無法發現所有問題,但儀錶板可以在出現嚴重問題時發出警報。
這就是我接下來要說的,最勁爆的觀點,一定會讓我被開發者圈抵制:
別再追求100%的程式碼覆蓋率了,開始追求程式碼覆蓋率的置信度吧。
你更想要:
100% 單元測試覆蓋率,但沒有整合測試?
70% 的覆蓋率,且單元測試、整合測試和端對端測試的混合使用是否合理?
我選擇方案 B,這樣晚上就能睡得更好(如果我不在山上除錯設備的話)。
def test_user_cannot_access_deleted_account():
"""Given: A user with a deleted account
When: They try to log in
Then: They should get a meaningful error"""
# Your test here
每個人都會試著走上幸福之路。要與眾不同。要悲傷。
def test_what_happens_when_literally_everything_fails():
# Database down? Check.
# Cache unavailable? Check.
# API returning 500s? Check.
# Does your app at least log the error? Let's find out!
# Bad
test_email = "[email protected]"
# Good
test_email = "[email protected]" # Tests plus signs, subdomains, etc.
如果你的測試只在你的機器上執行,那它們基本上只是感覺而已。
不要花三個小時除錯一個測試。如果測試這麼複雜,也許你的程式碼太複雜了。
我最後在山上修復了那個bug(多虧了那不穩定的4G網路)。徒步旅行很美,即使我有一半的時間都在除錯。而且我還學到了重要的一課:

完美是優秀的敵人,但好的測驗總比你從未寫的完美測試好。
單元測試是必要的,但還不夠。它們只是工具箱中的一種工具,而不是全部。它們可以幫你發現拼字錯誤、邏輯錯誤,以及那些讓你懊惱不已的「天哪,我當時到底在想什麼」的時刻。但它們無法發現所有問題。
這樣也沒關係。
歸根究底,發展就是:
編寫(大部分情況下)能執行的程式碼
測試你能測試的內容
監控那些無法測試的事物
在海拔 4200 英尺的山頂上,電池電量僅剩 2%,隨時準備進行緊急修復。
從每一次生產火災中吸取教訓
編寫單元測試。編寫整合測試。編寫端對端測試。不要過度在意程式碼覆蓋率。測試所有可能出錯的路徑。監控生產環境。也許不要在周五晚上部署,尤其是在徒步旅行之前。一定要帶上行動電源。
單元測試就像安全氣囊。絕對必要,但你仍然需要小心駕駛。
現在請允許我告退,我要和我的 DevOps 團隊開會,討論「為什麼在周五晚上部署是反人類罪行」。
你也有過類似的考試恐怖經驗嗎?歡迎在評論區分享。我不想獨自承受這些痛苦。
我登頂了。景色很美,不虛此行。至於製作過程中的崩壞呢?那就不值了。

原文出處:https://dev.to/varshithvhegde/unit-tests-the-greatest-lie-we-tell-ourselves-2ehd