小酌重构系列[9]——分解依赖

概述

编写单元测试有助于改善代码的质量,在编写单元测试时,某些功能可能依赖了其他代码(比如调用了其他组件)。
通常我们只想测试这些功能本身,而不想测试它所依赖的代码。

为什么呢?
单元测试的目标是验证该功能是否正确,然而功能所依赖的代码是处于功能范围外的,这些代码可能是一些外部的组件,单元测试无法验证这些外部组件的准确性。
单元测试因调用“依赖的代码”出错而失败时,会影响测试结果的判断,我们无法确定功能本身是否是正确的。
也许功能是正确的,但调用依赖的代码出错时,这个单元测试仍然会被认为是失败的。
如果要测试这些被依赖的代码,我们应该另外地为这些代码编写单元测试。

如何解决?
“依赖的代码”成了我们编写这类单元测试的拦路石,我们可以通过2种方式来解决这个问题:
1. Mock依赖的代码
2. 分解依赖

Mock的强大是毋庸置疑的,然而Mock不是万能的,它也是有限制的,我们不能在单元测试中Mock静态类。
但是,通过“分解依赖”可以解决这个问题,本文将通过一个示例来演示这2种方式。

示例

重构前

这段代码描述了一个场景——“饲养动物”,它包含2个类:
AnimalFeedingService(动物饲养服务),以及静态类Feeder(饲养员)。
AnimalFeedingService描述了“饲养”行为,Feeder类描述了“补充食物”行为。
在饲养动物时,如果动物的食盆是空的,则饲养员需要补充食物。

/// <summary>
/// 动物饲养服务
/// </summary>
public class AnimalFeedingService
{
    private bool FoodBowlEmpty { get; set; }

    public void Feed()
    {
        if(FoodBowlEmpty)
            Feeder.ReplenishFood();
    }
}

/// <summary>
/// 饲养员
/// </summary>
public static class Feeder
{
    /// <summary>
    /// 补充食物
    /// </summary>
    public static void ReplenishFood()
    {

    }
}

单元测试代码(基于xUnit和Rhino.Mocks框架)

public class AnimalFeedingServiceTests
{
    [Fact]
    public void TestFeed()
    {
        AnimalFeedingService service = new AnimalFeedingService();
        // 测试Feed()方法
        service.Feed();
    }
}

由于Feeder是一个静态类,所以在为AnimalFeedingService编写单元测试时,我们无法mock Feeder类,而且Feeder类的功能我们也不想在这个单元测试中验证。
如果在调用Feeder.ReplenishFood()时就出错了,这个单元测试的执行就是失败的。
同时,Feed()方法的正确性也无法验证。

重构后

为了能在单元测试中解除对Feeder类的依赖,我们可以为Feeder类添加包装接口IFeederService,然后让AnimalFeedingService依赖于这个包装接口。这样在AnimalFeedingServiceTest类中,我们就不需要考虑Feeder类了。

显示代码

/// <summary>
/// 动物饲养服务
/// </summary>

public class AnimalFeedingService
{
    private bool FoodBowlEmpty { get; set; }

    public IFeederService FeederService { get; set; }

    public AnimalFeedingService(IFeederService feederService)
    {
        this.FeederService = feederService;
    }

    public void Feed()
    {
        if (FoodBowlEmpty)
            FeederService.ReplenishFood();
    }
}

/// <summary>
/// 饲养服务接口
/// </summary>
public interface IFeederService
{
    void ReplenishFood();
}

/// <summary>
/// 饲养服务实现
/// </summary>
public class FeederService : IFeederService
{
    public void ReplenishFood()
    {
        Feeder.ReplenishFood();
    }
}

/// <summary>
/// 饲养员
/// </summary>
public static class Feeder
{
    public static void ReplenishFood()
    {

    }
}

单元测试代码(基于xUnit和Rhino.Mocks框架)

public class AnimalFeedingServiceTests
{
    [Fact]
    public void TestFeed()
    {
        // 基于Rhino.Mocks框架的MockRepository
        var mocks = new MockRepository();
        // Mock IFeederService
        var feederService = mocks.DynamicMock<IFeederService>();
        AnimalFeedingService service = new AnimalFeedingService(feederService);
        // 测试Feed()方法
        service.Feed();
    }
}

重构后的AnimalFeedingServiceTests,由于IFeederService是Mock的,所以IFeederService的ReplenishFood()方法根本不会被调用,因此Feeder的ReplenishFood()方法也不会被调用。
调用Feed()方法时,Feeder.ReplenishFood()方法被忽略了,这可以让我们更加关注于Feed()方法本身的逻辑。

包装模式

以上这段代码,实际上是一个简单的包装模式(Wrapper Pattern),包装模式不能算是一个具体的设计模式,因为在适配器模式(Adapter Pattern)、装饰模式(Decorator Pattern)、代理模式(Proxy Pattern)、门面模式(Facade Pattern)等都使用了包装类,这几个设计模式我在这里就不具体描述了。通常情况下,包装类用于封装其他class或component,包装类可以为上层调用提供便利性,并使底层class或component的运用变得更安全。

包装模式的UML结构大致如下。它包含3个部分:调用方(Client)、包装(Wrapper)、组件(Component)

再来谈谈“分解依赖”这个概念,依照这个结构,我们可以很清楚地知道:为了解除Client和Component之间的依赖,我们在Client和Component之上添加了一个Wrapper,让Client依赖于Wrapper。请注意,由于我们更加倾向于面向接口编程,所以通常这个Wrapper是一个包装接口。再举一个常用的例子,当我们在编写一些API时,我们只需要通过Wrapper向Client公开API的规格(名称、输入输出参数),API的内部实现则通过Wrapper隔离开来。

时间: 05-07

小酌重构系列[9]——分解依赖的相关文章

小酌重构系列[4]&mdash;&mdash;分解方法

概述 "分解方法"的思想和前面讲到的"提取方法"."提取方法对象"基本一致.它是将较大个体的方法不断的拆分,让每个"方法"做单一的事情,从而提高每个方法的可读性和可维护性.分解方法可以看做是"提取方法"的递归版本,它是对方法反复提炼的一种重构策略. 分解方法 下图表示了这个重构策略,第1次提炼和第2次提炼都采用了"提取方法"这个策略. 何时分解方法? "分解方法"最终

小酌重构系列[19]——分解大括号

概述 if else, for, while等是程序中最常用的语句,这些语句有一个共同点——它们的逻辑都封装在一对“{}”包围的代码块中.在实现复杂的业务逻辑时,会较多地用到这些语句,可能会形成多层的代码嵌套.代码的嵌套层数越大,代码的缩进层次就越深,这将会降低代码的可读性.如下图所示,如果我们想理解绿色if代码块的逻辑,需要先了解前3个代码块是如何工作的. N层嵌套的代码不仅可读性差,也难以维护.当需要变更某一层的代码时,因前后层次的逻辑制约,很容易改出有问题的代码.本文要讲的“分解大括号”策

小酌重构系列[11]&mdash;&mdash;提取基类、提取子类、合并子类

概述 继承是面向对象中的一个概念,在小酌重构系列[7]--使用委派代替继承这篇文章中,我"父子关系"描述了继承,这是一种比较片面的说法.后来我又在UML类图的6大关系,描述了继承是一种"is a kind of"关系,它更偏向于概念层次,这种解释更契合继承的本质.本篇要讲的3个重构策略提取基类.提取子类.合并子类都是和继承相关的,如果大家对继承的理解已经足够深刻了,这3个策略用起来应该会得心应手. 提取基类 定义:如果有超过一个类有相似的功能,应该提取出一个基类,并

小酌重构系列[15]&mdash;&mdash;策略模式代替分支

前言 在一些较为复杂的业务中,客户端需要依据条件,执行相应的行为或算法.在实现这些业务时,我们可能会使用较多的分支语句(switch case或if else语句).使用分支语句,意味着"变化"和"重复",每个分支条件都代表一个变化,每个分支逻辑都是相似行为或算法的重复.当追加新的条件时,我们需要追加分支语句,并追加相应的行为或算法. 上一篇文章"使用多态代替条件判断"中,我们讲到它可以处理这些"变化"和"重复&qu

小酌重构系列目录汇总

为了方便大家阅读这个系列的文章,我弄了个目录汇总. 方法.字段重构 移动方法 (2016-04-24) 提取方法.提取方法对象 (2016-04-26) 方法.字段的提升和降低 (2016-05-01) 分解方法 (2016-05-02) 为布尔方法命名 (2016-05-03) 引入对象参数 (2016-05-04) 类.接口重构 使用委派代替继承 (2016-05-07) 提取接口 (2016-05-08) 解除依赖 (2016-05-09) 分离职责 (2016-05-11) 提取基类.提

小酌重构系列[8]&mdash;&mdash;提取接口

前言 世间唯一"不变"的是"变化"本身,这句话同样适用于软件设计和开发.在软件系统中,模块(类.方法)应该依赖于抽象,而不应该依赖于实现. 当需求发生"变化"时,如果模块(类.方法)依赖于具体实现,具体实现也需要修改:如果模块(类.方法)依赖于接口,则无需修改现有实现,而是基于接口扩展新的实现. 面向实现?面向接口? 接口可以被复用,但接口的实现却不一定能被复用. 面向实现编程,意味着软件的模块(类.方法)之间的耦合性非常高,每次遭遇"

小酌重构系列[12]&mdash;&mdash;去除上帝类

关于上帝类 神说:"要有光",就有了光.--<圣经>.上帝要是会写程序,他写的类一定是"上帝类".程序员不是上帝,不要妄想成为上帝,但程序员可以写出"上帝类".上帝是唯一的,上帝的光芒照耀人间,上帝是很爱面子的,他知道程序员写了"上帝类",抢了他的风头,于是他降下神罚要惩戒程序员.--既然你写了"上帝类",那么就将你流放到艰难地修改和痛苦的维护的炼狱中,在地狱之火中永久地熬炼. 你看,上帝也是有

小酌重构系列[7]&mdash;&mdash;使用委派代替继承

概述 子类可以继承父类的字段.属性和方法,使用"继承"可以较大程度地复用代码.在使用继承时,务必要确定代码中定义的"父类"和"子类"确实存在客观的"父子关系",而不要去做"为了代码复用而使用继承"的事情,这是舍本逐末的做法,也是滥用继承的体现.滥用继承会破坏类之间客观存在的关系,也会模糊代码所体现的语义. 使用委派代替继承 继承的误区 当多个类具有相似的属性.方法时,使其中一个类变成基类,其他的类去继承该基

小酌重构系列[21]&mdash;&mdash;避免双重否定

避免双重否定 在自然语言中,双重否定表示肯定.但是在程序中,双重否定会降低代码的可读性,使程序不易理解,容易产生错觉.人通常是用"正向思维"去理解一件事情的,使用双重否定的判断,需要开发者以"逆向思维"的方式去理解它的含义.另外,在写程序时,"!"符号很容易被疏忽和遗漏,一不小心则会编写出错误的代码,从而产生bug.所以,在程序中,我们应当尽量避免使用双重否定. 优惠券是否未被使用? 还是以在线商城给用户发放优惠券为例,由于优惠券的初始状态是未被