我们在之前的文章之中,已经反复地强调了很多函数式编程的优点,例如表达能力,延迟计算的好处之类的。但其实一个更大的有点其实是可测性。本篇文章也是传达整个系列要表达的核心,我们不是要完全排除过程式、副作用等概念,而是有限的使用,并且能在现有代码的基础上做改良。

缘起

下面,我们看一个例子:一个公司希望设计一个基于时间的调度器,它们可以提供一个比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),然后介绍基于它受启发的一些不错的第三方模块和方法。