聊聊测试替身

概述

在对被测系统(System Under Test,简称SUT)进行单元测试(Unit Testing,简称UT)的过程中,经常会出现这种情况,SUT有依赖的组件(Depended On Component,简称DOC),而这个依赖的组件你无法控制或者还未实现。为了让单元测试顺利进行,通常会使用测试替身(Test Double)技术来隔离依赖的组件,从而提高单元测试的效率。


test-double.gif

一提到替身,很多人马上就会想到成龙大哥,他在很多电影里都用了替身。一般情况下,当电影制片人要拍摄的片段对于主演而言存在潜在的风险时,他们就会雇佣特技替身来代替现场演员。特技替身经过专门训练,能够满足剧情的特定要求,知道如何从高空跌落、如何撞车及剧情要求的所有特技,但却不会演戏。特技替身与现场演员需要像到什么程度取决于剧情的类型,通常体型与现场演员大致相似的人就可以当替身。

对于测试而言,可以用特技替身的对等物——测试替身来代替实际的DOC。在测试脚手架(Fixture)的安装(Setup)阶段,用测试替身取代实际的DOC。依据执行的测试类型,可以硬编码测试替身的行为,或者通过配置来生成测试替身的行为。测试替身不必完全与实际的DOC一样运行,它只需要提供相同的API来代替实际的DOC。当SUT与测试替身交互时,SUT并不知道测试替身不是实际的DOC,但我们却完成了测试的目标。

在单元测试运行的过程中,SUT与测试用例和测试替身的交互如下图所示:


door.png

说明如下:

  • SUT与测试用例交互的部分叫前门,与测试替身交互的部分叫后门
  • 站在SUT的角度,测试用例调用前门的输入叫直接输入,前门输出的返回值叫直接输出
  • 站在SUT的角度,后门调用测试替身的输入叫间接输出,测试替身输出的返回值叫间接输入

测试替身的使用时机主要有以下三种场景:

  • DOC无法提供SUT的间接输出
  • DOC无法提供必要的间接输入来执行SUT
  • 单元测试执行缓慢

测试替身可以细分为以下五类:

  • 测试桩(Test Stub)
  • 测试间谍(Test Spy)
  • 仿制对象(Mock)
  • 伪造对象(Fake)
  • 哑元对象(Dummy Object)

接下来,我们将分别较深入的聊聊这五类测试替身。

测试桩(Test Stub)

stub.png

许多情况下,SUT运行所处的上下文对SUT的行为影响很大。要充分控制SUT的间接输入,必须用可以控制的测试桩来取代该上下文。测试桩不会返回内容给测试用例,也不会验证SUT的间接输出。

在单元测试中,执行SUT之前,先安装测试桩,让SUT可以使用它取代实际的实现方式。在测试执行过程中,SUT调用测试桩时,它会返回前面定义的值。

gomonkey是笔者开源的一个Go语言的测试桩框架,我们看一个简单的测试用例:

var (
    outputExpect = "xxx-vethName100-yyy"
)

func TestApplyFunc(t *testing.T) {
    Convey("TestApplyFunc", t, func() {

        Convey("one func for succ", func() {
            patches := ApplyFunc(fake.Exec, func(_ string, _ ...string) (string, error) {
                    return outputExpect, nil
                })
            defer patches.Reset()
            output, err := fake.Exec("", "")
            So(err, ShouldEqual, nil)
            So(output, ShouldEqual, outputExpect)
        })
   })
}

我们对用例TestApplyFunc的测试桩简单说明一下:

  • ApplyFunc的调用是对函数fake.Exec的打桩操作,即测试桩的安装;
  • patches.Reset的调用是对函数fake.Exec打桩的回滚操作,即测试桩的销毁;
  • 用例在执行过程中,为了简单起见,直接调用了测试桩,这与用例先调用SUT,再由SUT调用测试桩的效果完全一样,测试桩返回刚才安装时定义的值。

我们使用gomonkey的打桩操作,可以看作是可配置的测试桩。除此之外,还有一种方式是硬编码的测试桩。

现在还清晰的记得,笔者在很多年之前曾用过这种方式。当时,我们用C语言来开发嵌入式通信软件,对于平台的一些API在测试代码中使用硬编码的方式来打桩,比如:

int SendAsynMsg(char* msg, int len, PID* pid)
{
     return 0;
}

通常,这些硬编码的测试桩仅在运行单元测试时才被构建进来,为SUT提供固定的间接输入,这时我们假定平台是安全可靠的,所有的关注点都是业务代码本身的正确性。而在制作正式版本时,SUT依赖的是真实的平台代码,测试桩不会被构建进来。

测试间谍(Test Spy)

spy.png

许多情况下,SUT运行所处的上下文对SUT的行为影响很大,可以使用测试替身捕获SUT对其他组件所做的间接输出调用,用于后续的测试验证。测试间谍是通过观察点实现行为验证的简单直观方法,该观察点提供SUT的间接输出,因此测试可以验证它们。

执行SUT之前,可以安装测试间谍表示SUT使用的DOC。测试间谍作为观察点,用来记录执行SUT时它所做的方法调用。在结果验证阶段,测试比较SUT传递给测试间谍的实际值与测试的预期值。

我们看一个例子cargo,来自笔者2018年在github上写的一个库ddd-sample-in-python。

测试间谍类SpyCargoProvider的实现:


class SpyCargoProvider(Provider):

    def __init__(self):
        self._cargo_id = 0
        self._after_days = 0

    def confirm(self, cargo):
        self._cargo_id = cargo.id
        self._after_days = cargo.after_days

    @property
    def cargo_id(self):
        return self._cargo_id

    @property
    def after_days(self):
        return self._after_days

SpyCargoProvider继承了抽象类Provider:

class Provider(object):

    @abstractmethod
    def confirm(self, *args):
        raise NotImplementedError

在领域服务CargoService中对Provider的实例对象(子类对象)进行了获取及使用:

class CargoService(object):

    def __init__(self):
        self._cargo_repo = get_cargo_repo()
        self._cargo_provider = get_cargo_provider()

    def create(self, cargo_id, days):
        cargo = CargoFactory().create(cargo_id, days)
        self._cargo_repo.add(cargo_id, cargo)
        self._cargo_provider.confirm(cargo)

    def delay(self, cargo_id, days):
        cargo = self._cargo_repo.get(cargo_id)
        if cargo is not None:
            cargo.delay(days)
            self._cargo_repo.update(cargo_id, cargo)
            self._cargo_provider.confirm(cargo)

说明:

  • 在构造函数中获取了Provider的单例对象
  • 在create和delay两个方法中调用了Provider实例对象的confirm方法

我们再看一下测试类CargoTest的实现:

class CargoTest(unittest.TestCase):

    def setUp(self):
        set_cargo_provider(SpyCargoProvider())
        self._cargo_id = 1
        self._after_days = 20

    def test_create_cargo(self):
        create_cargo(self._cargo_id, self._after_days)
        provider = get_cargo_provider()
        after_days = get_cargo_after_days(self._cargo_id)
        self.assertEqual(self._cargo_id, provider.cargo_id)
        self.assertEqual(self._after_days, provider.after_days)
        self.assertEqual(self._after_days, after_days)
        destroy_cargo(self._cargo_id)

    def test_delay_cargo(self):
        create_cargo(self._cargo_id, self._after_days)
        delay_cargo(self._cargo_id, 5)
        provider = get_cargo_provider()
        after_days = get_cargo_after_days(self._cargo_id)
        self.assertEqual(self._cargo_id, provider.cargo_id)
        self.assertEqual(self._after_days + 5, provider.after_days)
        self.assertEqual(self._after_days + 5, after_days)
        destroy_cargo(self._cargo_id)

说明:

  • 在测试安装阶段注入了测试间谍
  • 在两个用例(test_create_cargo和test_delay_cargo)中,都对SUT的间接输出通过测试间谍进行了断言

仿制对象(Mock)

mock.png

仿制对象是实现行为验证同时避免相似测试用例进行测试代码复制的有效方法,它将验证SUT间接输出的任务全部委托给测试替身。

将实现相同接口的仿制对象定义为SUT依赖的对象,然后在测试过程中,用仿制对象响应SUT。在执行SUT之前,先安装仿制对象,让SUT可以使用它取代实际的实现方式。在SUT执行过程中,当调用到仿制对象时,它会使用相等性断言将接收到的实际参数与预期参数进行比较,如果它们不匹配,就让测试失败,而测试根本不需要任何断言。

使用仿制对象写的测试与传统测试看起来不太一样,因为在执行SUT之前必须指定所有预期行为。对于测试自动化新手来说,这让测试变得更难写和更难理解。

我们看一个gomock的示例:

func TestObjDemo(t *testing.T) {
    Convey("test obj demo", t, func() {
        Convey("create obj", func() {
            ctrl := NewController(t)
            defer ctrl.Finish()
            mockRepo := mock_db.NewMockRepository(ctrl)
            mockRepo.EXPECT().Retrieve(Any()).Return(nil, ErrAny)
            mockRepo.EXPECT().Create(Any(), Any()).Return(nil)
            mockRepo.EXPECT().Retrieve(Any()).Return(objBytes, nil)
            patches := ApplyFuncReturn(redisrepo.GetInstance, mockRepo)
            defer patches.Reset()
            ...
        })

        Convey("bulk create objs", func() {
            ctrl := NewController(t)
            defer ctrl.Finish()
            mockRepo := mock_db.NewMockRepository(ctrl)
            mockRepo.EXPECT().Create(Any(), Any()).Return(nil).Times(5)
            patches := ApplyFuncReturn(redisrepo.GetInstance, mockRepo)
            defer patches.Reset()
            ...
        })

        Convey("bulk retrieve objs", func() {
            ctrl := NewController(t)
            defer ctrl.Finish()
            mockRepo := mock_db.NewMockRepository(ctrl)
            objBytes1 := ...
            objBytes2 := ...
            objBytes3 := ...
            objBytes4 := ...
            objBytes5 := ...
            mockRepo.EXPECT().Retrieve(Any()).Return(objBytes1, nil)
            mockRepo.EXPECT().Retrieve(Any()).Return(objBytes2, nil)
            mockRepo.EXPECT().Retrieve(Any()).Return(objBytes3, nil)
            mockRepo.EXPECT().Retrieve(Any()).Return(objBytes4, nil)
            mockRepo.EXPECT().Retrieve(Any()).Return(objBytes5, nil)
            patches := ApplyFuncReturn(redisrepo.GetInstance, mockRepo)
            defer patches.Reset()
            ...
        })
        ...
    })
}

说明如下:

  • 每个测试用例(第二层Convey)有独立的Mock控制器
  • 先创建Mock对象实例mockRepo,然后再指定该对象实例的所有预期行为
  • 使用gomonkey接口ApplyFuncReturn让仓储redisrepo的单例接口GetInstance返回mockRepo对象实例

伪造对象(Fake)

fake

伪造对象是为SUT依赖的组件提供的功能性更简单更轻量级的实现方式,在测试中让它来取代实际的DOC。伪造对象只需要给SUT提供对等的业务,这样SUT就不知道它并没有使用实际的DOC。

当SUT依赖不可用的组件(未开发完成),依赖让测试变得困难或缓慢的组件,同时测试需要的行为序列比在测试桩或仿制对象中实现的行为序列更复杂时,就应该使用伪造对象。它创建轻量级实现方式应该比构建和编码相应的仿制对象更容易,至少从长远来看,如果值得构建伪造对象的话。

使用伪造对象可以避免过度指定SUT的间接输出,因为不用编码测试内的DOC所需的调用序列。SUT可以改变调用DOC方法的次数,而不会导致测试失败。如果需要控制SUT的间接输入或验证SUT的间接输出,就应该使用仿制对象或测试桩。

对于伪造对象来说,最常见的就是伪造数据库。
我们举一个简单的例子:Cargo作为领域对象,需要持久化到MySql数据库,但在单元测试中,我们使用FakeCargoRepo来代替真实的CargoRepo

class FakeCargoRepo(object):

    def __init__(self):
        self._repo = {}

    def add(self, id, obj):
        if self._repo.has_key(id):
            return False
        self._repo[id] = obj
        return True

    def remove(self, id):
        if self._repo.has_key(id):
            del self._repo[id]

    def update(self, id, obj):
        if self._repo.has_key(id):
            self._repo[id] = obj
            return True
        return False

    def get(self, id):
        return self._repo[id]

哑元对象(Dummy Object)

SUT的有些方法签名需要对象作为参数,如果测试和SUT都不关注这些对象,就可以使用哑元对象,就像使用空对象或Object类的实例那样简单,仅仅是为了能够调用SUT而必须传入的一个东西。

哑元对象实际上不是真正的测试替身,而是值模式的一个选项,所以在测试替身的分类图中我们将哑元对象放在了虚框里:


category.gif

下面是一个使用哑元对象的Java测试代码示例:

public void testInvoiceAddLineItem() {
      final int QUANTITY = 1;
      Product product = new Product("Dummy Product Name",
                                    getUniqueNumber());
      Invoice inv = new Invoice(new DummyCustomer());
      LineItem expItem = new LineItem(inv, product, QUANTITY);
      // Exercise
      inv.addItemQuantity(product, QUANTITY);
      // Verify
      List lineItems = inv.getLineItems();
      assertEquals("number of items", lineItems.size(), 1);
      LineItem actual = (LineItem)lineItems.get(0);
      assertLineItemsEqual("", expItem, actual);
}

说明:

  • 创建Product对象实例时,传入的字符串"Dummy Product Name"可以看作哑元对象
  • 创建Invoice对象实例时,传入的new DummyCustomer()对象实例可以看作哑元对象

小结

为了便于读者对测试替身的快速理解,我们简单小结一下:

类型 解释
测试桩(Test Stub) 响应SUT的请求,返回预先设定的值
测试间谍(Test Spy) 在测试桩的基础上,记录SUT的间接输出,测试用例来验证
仿制对象(Mock) 在测试间谍的基础上,自行断言,有问题直接抛异常
伪造对象(Fake) 用简单的或假的方式来实现DOC,比如用内存数据库来代替真实的重量级数据库
哑元对象(Dummy Object) 对测试不起实质性的作用,仅用来填充SUT的参数列表

或许你在使用测试替身时,实际上并不关注具体使用的是哪种类型的,但如果能深入思考这个问题,并付诸于实践,就会产出更高质量更低成本的测试代码。

你可能感兴趣的:(聊聊测试替身)