Python函數語言程式設計系列008:可測

三次方根發表於2021-10-19

我們在之前的文章之中,已經反覆地強調了很多函數語言程式設計的優點,例如表達能力,延遲計算的好處之類的。但其實一個更大的有點其實是可測性。本篇文章也是傳達整個系列要表達的核心,我們不是要完全排除過程式、副作用等概念,而是有限的使用,並且能在現有程式碼的基礎上做改良。

緣起

下面,我們看一個例子:一個公司希望設計一個基於時間的排程器,它們可以提供一個比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")

很顯然,這個單元測試一眼就看出了幾個問題:

  1. 事實上,我們只是重新寫了一遍原有的程式碼,並沒有真的測試。
  2. 即使我們承認這種寫法,也有一定概率在接近凌晨的時候(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)

當然,這個解決方案是針對時間這個事的,我們遇到的副作用可能不止這一種,可能是讀取配置、資料庫互動等等,這種方案無法解決這些事。

另一類就是測試領域的概念,比如fakemockstub之類的概念了,我們在下面的工作中當然也會用到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'

我們發現這麼寫有諸多好處了:

  1. 整個函式變成了無副作用了,副作用被隔離在了引數裡面
  2. 因為無副作用了,我們只需要自己製作相應的「假」函式就可以模擬要的輸入了,特別是針對Void -> A這種型別的函式。
  3. 我們可以通過假函式的方式模擬任何一個狀態時的操作,這使得我們上面說的排程邏輯可以變得可以測試了。
  4. 我們在具體呼叫的時候,因為設定了引數的預設值,因此其具體使用的方法並沒發生變化。

這種把副作用寫在引數的方法,我們將在之後遇到類似的方案(無副作用的隨機數),以及在後續的文章中看到Monad如何解決此類問題。

不過,這篇文章引申出了「可測性」的概念,一般來說,沒有副作用的函式是絕對可測的,並且可以在單元測試階段完成測試的。帶有副作用的函式/方法會使得測試變得困難。因此,通過單元測試及覆蓋率的概念,我們可以將大多數問題暴露在上線前,這是非常Fancy的一種方式。如果加上型別推導,這種系統的可用性的判斷將會更加完美(當然,這是Python這種語言很難做到的,不過可以基於mypy做類似的事)。

當然,這也是函數語言程式設計測試的開始,我們後面將會介紹另一個獨有的函數語言程式設計的測試概念——基於性質的測試(Property-based testing),然後介紹基於它受啟發的一些不錯的第三方模組和方法。

相關文章