欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

编写优秀的单元测试(四)测试替身

程序员文章站 2022-04-27 17:29:30
...

简介

一说到测试替身,我们总会不由自主的想到mock,我们在上一章简单提了一下,测试替身是 桩(Stub) 伪造对象(fake)测试间谍(spy) 模拟对象(mock)的总称。而使用测试替身的根本目的是使用替身替换一个模块的真实协作者,以期隔离被测试对象。

我们说,引入测试替身的根本原因就是:将测试代码和周围隔离开。

下面我们先分别看一下几种类型的测试替身:

测试替身的类型1:桩

桩(Stub) 测试桩是单元测试中常用的技术,我们经常能看到所谓的“打桩”。桩是指用最简单的代码来替代真实实现。

换句话说,如果A依赖了B的一个很复杂的方法的返回值,而我们要测试A,这个返回值是多少我们并不太关心,就可以写出一个B1(桩函数),只是用了B的桩而不是B,这样就可以完成测试,这里我们注意,使用桩可以达到下面三个效果:

  1. 隔离。如果A依赖了B,B依赖了C D,通过对于B打桩,就切断了A和C D的联系
  2. 补齐。如果A依赖了B,而B是其他语言写的,或者必须依赖某运行环境,就可以对B打桩替代B,让A可以运行下去
  3. 控制。如果A依赖B的某个返回值,通过对B打桩,直接设定返回值可以让A运行下去。

我们看一个桩的例子:假设我们要测试的类A需要向服务器写入日志,写日志需要调用继承自ILogger的实现的Log方法,而写入日志需要服务器的支持,那我们想要测试A,就可以对于测试的对象“打桩”。

public class LoggerStub : ILogger
{
    public LogLevel GetLevel()
    {
        return LogLevel.WRAN;
    }

    public void Log(LogLevel level, string message)
    {
    }
}

如上面的代码,LoggerStub就是一个测试桩,他是ILogger的最简单实现,既不调用服务器打印日志,也不做任何事情,仅仅是为了A的测试而存在,当然这适用于要测试A而不关心打印什么日志的情况。

测试替身的类型2:伪造对象

伪造对象是真实协作者的简单版本,它看起来像鸭子,叫起来像鸭子,却不是鸭子本身。

我们举个例子说,如果我们的数据存储在一个大型的数据库中,数据库给我们提供了API,但是这个数据库本身需要部署环境,需要有网络,我们可以使用伪造对象在内存中模拟这样的一个存储实体,实现对应的API用来测试,下面我们具体看一下:

大型数据库提供了下面这些API:

public interface IUserRepository
{
    void Save(User user);
    User FindUserByID(long id);
    User FindUserByName(string name);
}

我们实现的伪造对象如下:

public class FakeUserRepository : IUserRepository
{
    private Dictionary<long, User> _dic = new Dictionary<long, User>();
    public User FindUserByID(long id)
    {
        User user;
        _dic.TryGetValue(id, out user);
        return user;
    }

    public User FindUserByName(string name)
    {
        return _dic.Where(x => x.Value.Name == name).Single().Value;
    }

    public void Save(User user)
    {
        if (_dic.ContainsKey(user.ID) == false)
        {
            _dic.Add(user.ID, user);
        }
    }
}

我们使用伪造对象同样实现了外部数据库实现的功能,但是我们提供的是低级版本,简单版本,但是用于测试足够了。伪造对象和打桩在解除依赖的时候十分的有用。

测试替身的类型3:测试间谍

测试间谍是一种加强的伪造对象,它不仅长得像鸭子,叫声像鸭子,它还能告诉你正常鸭子不会告诉你的鸭子的秘密。当我们的测试类没有返回值或者想测试这个类的一些私有的状态的时候,就需要用到测试间谍。

我们再次拿一个日志测试类进行举例,测试类如下:

public class DLog
{
    private DLogTarget[] _targets;

    public DLog(DLogTarget[] targets)
    {
        _targets = targets;
    }

    public void Write(string message)
    {
        foreach (var item in _targets)
        {
            item.Write(message);
        }
    }
}
public interface DLogTarget
{
    void Write(string message);
}

如上面的代码,DLog是一个待测类,这个类用于向所有的DLogTarget发送日志,如果我们想知道DLog类能否驱动DLogTarget进行正确的操作,正常的DLogTarget不会告诉我们,而间谍DLogTarget会告诉我们。

编写间谍类

public class SpyTarget : DLogTarget
{
    private List<string> log = new List<string>();
    public void Write(string message)
    {
        log.Add(message);
    }
    public bool received(string message)
    {
        return log.Contains(message);
    }
}

我们看到,使用间谍类,模拟了一个DLogTarget,它可以根据我们的需要告诉我们想知道的一切。下面我们写测试类:

public class DLogTest
{
    [Fact]
    public void WriteToAll()
    {
        SpyTarget spy1 = new SpyTarget();
        SpyTarget spy2 = new SpyTarget();
        DLogTarget[] targets = { spy1, spy2 };
        DLog dLog = new DLog(targets);
        dLog.Write("test");
        Assert.True(spy1.received("test"));
        Assert.True(spy2.received("test"));
    }
}

可以看到,间谍的加入,让我们测试到了DLog 的行为,间谍报告了我们想知道的信息。

模拟对象(Mock)

模拟对象是特殊的间谍对象。他不仅是像间谍对象那样暴露自己的隐私信息以供查询,还会模拟被模拟对象的行为与待测试对象进行交互,他比模Fake多的是“可以配置的针对不同参数的响应”,即 Mock对象可以做到传入不同的参数的时候,给予不同的响应,且如何响应式可以配置的。

例如,一个网络接口,当我们传入1的时候返回A,传入2的时候返回B,要实现这样的一个模拟对象,就叫Mock。

一般来说,每种语言都有自己成熟的Mock库,我们在使用Mock的时候最好能够调用线程的Mock库来简化我们的开发,在.NET中,我们推荐使用Moq

测试替身之间的关系

经过上面的讲解我们总结出集中模拟对象的关系,我们复习一下:

  • 桩是最简单的代码代替真实实践,通常都不实现,只做占位用,让程序可以编译
  • 伪造对象是加强过的桩,它是真实实现的简单版,用简单版模拟真实对象,使之看起来像鸭子
  • 测试间谍是特殊的伪造对象,它不仅看起来像鸭子,还会向我们透露鸭子内部的信息
  • 模拟对象是特殊的间谍对象。他可以针对不同的输入进行不同的输出,看起来就跟真的交互者一样

如何选择测试替身

通过上面的介绍,我们熟悉了每种测试替身的概念以及他们之间的关系,看起来如何选择测试替身已经变得很清晰了。

我们注意一个原则:测试替身应该因地制宜的混合使用,他们的选择没有定法,我们应该按照自己的需求选择相对简单的测试替身。

测试替身的作用

使用测试替身就是为了隔离被测试代码,这一点我们已经清楚了,我们在平常的修改BUG过程中,也经常会使用这种方法,将一个系统拆成一小部分一小部分测试,保持一部分不变,测试另一部分,这种思想很好理解。而测试替身的作用却不止这些,它的作用主要有:

  • 隔离被测试代码
  • 加速执行测试
  • 使执行变得确定
  • 模拟特殊情况
  • 访问隐藏信息

下面我们通过一个例子来看看测试机替身的几个作用。

实例分析

  • 谁是协作者

    我们看下面的这个例子
    编写优秀的单元测试(四)测试替身
    在这个例子中,有四个类 Car Engine Directions Route。那么,如果我们要测试Car,谁是协作者呢?

    这里我们注意下,所谓的协作者,我们指的是“直接协作者”,即直接与被测试类交互的类。所以我们发现,Directions类虽然出现在了上面的程序中,却不是直接与Car交互,而是通过Route与Car交互,所以只要替换了Route,Directions就也被替换了。所以,在这个例子中,协助者是Engine Route,被测试者是Car,如下图所示:
    编写优秀的单元测试(四)测试替身
    我们在识别协助者的时候,需要站在抽象的角度上,选出与被测试对象直接交互的对象进行替换。

  • 加速执行测试

    在上面的例子中,如果对于路径的选取需要通过一系列的算法算出最优路径,而这个计算可能消耗较多时间,那使用测试替身直接赋予其一个路径进行测试无疑是可以加速执行测试的,测试时间的缩短最终会体现在开发效率上。

  • 使测试确定

    继续看上面的例子,如果我们不使用测试替身替换Route,从A点到B点的路径就可能有无数多个,推荐的路径可能受到时间的影响,即8:00早高峰的时候可能推荐走一个路径,11:00的时候就推荐另一个路径了,这些路径的长短,拥堵程度不同,自然测试结果就有可能不同,而使用了测试替身,就可以使原来不确定的路径变得确定,这也就是我们说的,使测试确定。

  • 模拟特殊

    如果我们想测试一下Car在Route路线规划失败的时候的动作,我们如果不使用测试替身,可能需要在Route规划路线的时候断网,或者认为修改Route中已经写好的代码来进行测试,如果我们使用了测试替身,则可以简单的在替身中引发一个异常来模拟路径规划失败的情况。这只是举一个例子,实际在使用过程中,很多特殊情况我们都可以通过替身来模拟而不用借助外部环境或者某个特定的触发条件,这使我们测试程序稳定性变得异常的方便。

  • 暴露隐藏信息

    前面几点都比较好理解,暴露隐藏信息这一点一定不能掉以轻心,这一点十分重要,它可以帮助我们测试待测试对象中私有对象的状态。

    要做到这一点,在写代码的时候首先就要注意对象之间的耦合,使用必要的设计模式来降低耦合。

    举例说明一下,如果在上面的例子中我们想测试Car 的 Engine在调用了start方法之后是不是真的启动了,我们需要访问Engine对象,但是Engine对象又是私有的,这时候我们可以使用测试替身来构建可以测试的Engine

    public class CarTest
    {
        [Fact]
        public void EngineIsStarts()
        {
            TestEngine engine = new TestEngine();
            new Car(engine).Start();
            Assert.True(engine.IsRunning());
        }
    }
    public class TestEngine : Engine
    {
        private bool _isRunning;
        public void Start()
        {
            _isRunning = true;
        }
        public bool IsRunning()
        {
            return _isRunning;
        }
    }
    

    通过对于Engine的继承创建了TestEngine,用于揭示正常Engine不会暴露的信息。

相关标签: 单元测试