我們在之前的文章之中,已經反覆地強調了很多函數語言程式設計的優點,例如表達能力,延遲計算的好處之類的。但其實一個更大的有點其實是可測性。本篇文章也是傳達整個系列要表達的核心,我們不是要完全排除過程式、副作用等概念,而是有限的使用,並且能在現有程式碼的基礎上做改良。
緣起
下面,我們看一個例子:一個公司希望設計一個基於時間的排程器,它們可以提供一個比crontab
更完善的語法,比如可以基於每個月前三天、每週週末、每個月第二週的第一天之類這些表述。設計這個排程器時候,就會涉及到很多有關時間的函式,比如,下面是一個可能要實現的函式:
from datetime import datetime, timedelta
def yesterday_str() -> str:
"""獲取昨日的時間的字串(YYYYMMDD)
"""
return (
datetime.now() - timedelta(days=1)
).strftime("%Y%m%d")
這是一個最直觀的實現,但是,這個函式我們發現是不可測的。原因大家應該看出來,就是因為datetime.now()
是帶有副作用的。具體我們可以把測試中可能遇到的問題例舉如下:
單元測試例子中的問題
我們會如何寫這個函式的單元測試呢,很顯然,大部分人會這麼寫:
def test_yesterday_str():
assert yesterday_str() == (
datetime.now() - timedelta(days=1)
).strftime("%Y%m%d")
很顯然,這個單元測試一眼就看出了幾個問題:
- 事實上,我們只是重新寫了一遍原有的程式碼,並沒有真的測試。
- 即使我們承認這種寫法,也有一定概率在接近凌晨的時候(23:59:59秒時),這個測試不通過,但這又不是因為功能實現的問題導致的錯誤。
整合測試中的問題
在實際測試中,可能某些整合的部分更難測試到,比如我們下面一個呼叫上面函式的函式,它的功能是在每個月1號執行一個任務:
def run_at_first_day():
if yesterday_str()[-2:] == '01':
do_something()
這個例子不僅把副作用又一步步傳遞下去了,而且在測試中,我們如果不是在1號進行測試,我們就只能測到do_something
的邏輯而測不到run_at_first_day
這個排程的邏輯。而可以想象,在這個系統內,這種例子會非常多。
如何解決
常規解決方案
常規的解決方案,第一個就是修改系統時間。Python
中有一個FreezeGun
的模組,就是做類似事的:
from freezegun import freeze_time
import datetime
@freeze_time("2012-01-14")
def test():
assert datetime.datetime.now() == datetime.datetime(2012, 1, 14)
當然,這個解決方案是針對時間這個事的,我們遇到的副作用可能不止這一種,可能是讀取配置、資料庫互動等等,這種方案無法解決這些事。
另一類就是測試領域的概念,比如fake
、mock
、stub
之類的概念了,我們在下面的工作中當然也會用到fake
的概念,但是不需要糾結於這些複雜的概念。
把副作用函式作為引數
我們改寫成下面的方式,就發現整個函式變得可測了:
def yesterday_str(now_func = date.now) -> str:
assert yesterday_str() == (
now_func() - timedelta(days=1)
).strftime("%Y%m%d")
具體的測試寫法如下:
def fake_now(now_str):
def helper():
return datetime.strptime(now_str, "%Y-%m-%d")
return helper
def test_yesterday_str():
return yesterday_str(fake_now('2020-01-01')) == '2019-12-31'
我們發現這麼寫有諸多好處了:
- 整個函式變成了無副作用了,副作用被隔離在了引數裡面
- 因為無副作用了,我們只需要自己製作相應的「假」函式就可以模擬要的輸入了,特別是針對
Void -> A
這種型別的函式。 - 我們可以通過假函式的方式模擬任何一個狀態時的操作,這使得我們上面說的排程邏輯可以變得可以測試了。
- 我們在具體呼叫的時候,因為設定了引數的預設值,因此其具體使用的方法並沒發生變化。
這種把副作用寫在引數的方法,我們將在之後遇到類似的方案(無副作用的隨機數),以及在後續的文章中看到Monad如何解決此類問題。
不過,這篇文章引申出了「可測性」的概念,一般來說,沒有副作用的函式是絕對可測的,並且可以在單元測試階段完成測試的。帶有副作用的函式/方法會使得測試變得困難。因此,通過單元測試及覆蓋率的概念,我們可以將大多數問題暴露在上線前,這是非常Fancy的一種方式。如果加上型別推導,這種系統的可用性的判斷將會更加完美(當然,這是Python這種語言很難做到的,不過可以基於mypy
做類似的事)。
當然,這也是函數語言程式設計測試的開始,我們後面將會介紹另一個獨有的函數語言程式設計的測試概念——基於性質的測試(Property-based testing),然後介紹基於它受啟發的一些不錯的第三方模組和方法。