我的TDD实践:可测试性驱动开发
 

2009-11-12 作者:赵劼 来源:博客园

 

TDD(测试驱动开发,Test Driven Development)是重要的敏捷实践之一,它的基本原理是用测试来带动开发,先写测试代码,再写开发代码,最后重构。许多TDD推广和实践者认为,这种方式易于带来高质量的代码。而如今,TDD也慢慢有了Test Driven Design,也就是测试驱动设计的意味。也就是说,它更像是一种设计方式了。这些理论我很愿意相信,也很支持,但是从实际角度来说,我还是较难接受正统的TDD行为。不过,我也在实际开发过程中总结出……怎么说呢,应该说是更适合我自己的实践方式,在此希望能和大家交流一下。

我难以接受正统TDD方式的原因,在于我总是过于习惯在拿到一个需求的时候,在脑海里率先出现设计。而正统TDD的要求应该是先从测试代码开始,但是我脑海中已经出现了设计“草图”之后,写出来的测试也已经有相当明确的“导向性”了——那么,即使我先写测试,又有什么意义呢?而且,我在写测试的时候,总是在想 “哎,这个测试真多余,反正最终代码不会仅仅是这样的”。对于我来说,我只能采取正统TDD方式的“形”,而是在接受不了它的“神”

至今我还在疑惑,因为我觉得普通开发人员像我这样情况其实应该也有不少,那么对于像我这样的人,又该如何采用TDD的方式来开发项目呢?最终我放弃了使用TDD,不过单元测试是一定保留了下来的。

于是,我还是先写代码,再写测试,用测试来检查代码的实现和“期望”是否相符。接着,为了提高项目测试的可测试性,我会不断重构代码,分离职责,构造一些功能明确的辅助类等等。慢慢的慢慢的,似乎我觉得最后得到的成果还是相当有模有样的。忽然有一天,我觉得自己的做法也已经形成了一些“套路”,我一时兴起在推特上“宣称”我在使用一种叫做“测试导向开发”的方式,因为我时刻考虑代码该如何测试,为此而不断改变我的设计。

测试导向开发,即Test Targeting Development或TTD。当然最后一个D改为Design似乎也没有什么问题。

与传统TDD的开发方式不同,我的TTD方式还是先写代码,后写测试。只不过,我会时刻关注自己的代码是否容易测试,并不断重构产品代码和测试代码。基本上它的步骤是:

  1. 写产品代码
  2. 为产品代码写测试
  3. 发现测试不容易写,于是重构产品代码
  4. 重构测试
  5. ……

一般来说,这几个步骤的执行顺序都比较随意,唯一的目的便是在产品开发过程中,让产品代码得到更多的测试覆盖率。这会迫使我们编写更加容易测试的代码,而我慢慢发现这个要求很接近于著名的SOLID原则:

单一职责原则(Single Resposibility Principle):如果一个类的职责不单一,我写单元测试的时候就要准备一个复杂的初始数据,然后劳心劳力地推测出它的输出是什么。此时,我会把一部分职责抽象成外部类,然后再某种方式交由原来的类使用。在单元测试的时候,我可以为新生成的外部类构造Stub,也可以为这个外部类做额外的单元测试。

开/闭原则(Open/Close Principle):这个似乎和单元测试的关系不大,符合这个原则更多是为了更好的产品设计。当然,单元测试本身也需要产品提供一定的“开”点。

里氏替换原则(Liskov Substitution Principle):这个……和单元测试关系不大。

接口分离原则(Interface Segregation Principle):只有通过接口和具体实现类分离之后,才能在测试时为接口提供Mock或Stub。例如,把职责提取到外部类的时候,我会为外部类构建一个接口。而原来类要使用外部类的功能,便是通过接口来访问的。

依赖倒转原则(Dependency Inversion Principle):这个就不用说了,大大简化了单元测试的编写难度。值得注意的是,依赖注入不等同于“依赖注入容器”的使用。例如,我会为待测试的类添加一个用于注入辅助对象的构造函数,然后在单元测试时传入辅助对象的Stub。这其实也就是“依赖注入”。

在推特上 “发布”我的TTD之后,有朋友告诉我说其实这也是TDD啊:Testability Driven Development,可测试性驱动开发。哎,真神奇。在以下,我会使用一个简单的示例来展示“可测试性驱动开发”的实践方式,也希望能够引起更多更广泛的探讨。

以上,我谈到自己在采用传统TDD方式进行开发时感到有些尴尬,最后不得不放弃这种先写测试再写代码最后重构的方式。不过我还是非常注重单元测试的实践,慢慢发现自己的做法开始转向另一种TDD方式,也就是“可测试性驱动开发”。简单的说,我现在采取的做法是,先开发,再测试,一旦发现产品代码不太容易测试,则将其重构为容易测试的代码。我发现,这种时刻注重可测试性的开发方式,其最终也能够得到质量较高的代码。例如,它和SOLID原则也颇为融洽。上次谈的比较理论,而这次我便通过一个简单功能的开发过程,来表现我的思维方式及常用做法。

任务描述

这个功能是开发ASP.NET MVC项目时的常见任务:构建一个Model Binder。ASP.NET MVC中Model Binder的职责是根据请求的数据来生成Action方法的参数(即构建一个对象)。那么这次,我们将为负责产品搜索的Action方法提供一个SearchCriteria参数作为查询条件:

public class SearchCriteria
{
    public PriceRange Price;
    public string Keywords { get; set; }
    public Color Colors { get; set; }
}

[Flags]
public enum Color
{ 
    Red = 1,
    Black = 1 << 1,
    White = 1 << 2
}

public class PriceRange
{
    public float Min { get; set; }
    public float Max { get; set; }
}

SearchCriteria中包含三个条件,一是复杂类型的Price条件,二是字符串类型的Keywords条件,三是一个Color枚举类型。作为查询条件,它总是需要在URL中表示出来的,而表现方式则是最近在《趣味编程:从字符串中提取信息》中定义的做法。例如,如果是这样的URL:

/keywords-hello%20world--price-100-200--color-black-red

它表示的便是这样的条件:

  • 价格为100到200之间
  • 关键字为“hello world”(注意URL转义)
  • 颜色为黑或红(使用Color.Black | Color.White表示)

而最终,我要使用“可测试性驱动开发”来实现的便是这个方法:

public class SearchCriteriaBinder : IModelBinder
{
    public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        throw new NotImplementedException();
    }
}

那么,我又会怎么做呢?

实现步骤

其实这也是个比较简单的功能,于是我一开始便用最直接的方式进行开发:

public class SearchCriteriaBinder : IModelBinder
{
    public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        var modelName = bindingContext.ModelName;
        var rawValue = bindingContext.ValueProvider[modelName].RawValue;

        var text = HttpUtility.UrlDecode(rawValue.ToString());
        var tokenGroups = this.Tokenize(text);
        ...
    }

    private List<string[]> Tokenize(string text)
    {
        ...
    }
}

这个Model Binder会从Value Provider中得到Model Name(一般Action参数的名称,在这里不是重点)所对应的rawValue,经过了URL Decode之后便得到了text,它是一个带有信息的字符串,也便是《趣味编程》所要解析的对象。如上面的例子,text便是:

keywords-hello world--price-10-20--color-black-red

请注意,原本在URL中表示为%20的字符,已经被URL Decode为一个空格。在得到text之后,我便要将其拆分为一个List<string[]>对象,这便是分割好的结果。拆分字符串的逻辑比较复杂,因此我将其提取到一个独立的Tokenize方法中去。于是我接下来就开始实现Tokenize方法了,写啊写,写完了。但是,我到底写的正不正确?我不知道。我唯一知道的东西是,这个逻辑不简单,我需要测试一下才放心。因此,在继续其他工作之前,我想要为它写一些单元测试。

这就是涉及到一个问题,我们该如何为一个私有方法作单元测试呢?我以前也想在博客上讨论这个问题,但是最终不知为何没有进行。我的看法是,如果设计得当,每个类的职责单一,应该不会出现需要进行单元测试的私有方法。如果一个私有方法需要测试,那么说明它的逻辑相对较为复杂,而且有独立的职责,应该将其提取到外部的类型中。例如在这里,Tokenize方法便值得我这样么做——因为我想要单元测试。于是我提取出一个Tokenizer抽象,以及一个默认的逻辑实现:

internal interface ITokenizer
{
    List<string[]> Tokenize(string text);
}

internal class Tokenizer : ITokenizer
{
    public List<string[]> Tokenize(string text)
    {
        ...
    }
}

我现在便可以对Tokenizer进行充分的单元测试,以确保它的功能满足我的要求。测试完成后,我就对它完全放心了。而此时,我的SearchCriteriaBinder便会直接使用Tokenizer对象,而不是内部的私有方法——当然,是基于抽象来的:

public class SearchCriteriaBinder : IModelBinder
{
    public SearchCriteriaBinder()
        : this(new Tokenizer()) { }

    internal SearchCriteriaBinder(ITokenizer tokenizer)
    {
        this.m_tokenizer = tokenizer;
    }

    private readonly ITokenizer m_tokenizer;

    public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        var modelName = bindingContext.ModelName;
        var rawValue = bindingContext.ValueProvider[modelName].RawValue;

        var text = HttpUtility.UrlDecode(rawValue.ToString());
        var tokenGroups = this.m_tokenizer.Tokenize(text);
        ...
    }
}

原本由私有的Tokenize方法负责的逻辑,现在已经委托给ITokenzier对象了,而这个对象可以在构造SearchCriteriaBinder对象时通过构造函数提供。请注意,提供ITokenizer对象的构造函数访问级别是internal,也就是说,它可以被单元测试代码所访问(通过InternalVisibleToAttribute),但是无法被另一个程序集的使用方调用。也就是说,经过重构的SearchCriteriaBinder,它的可测试性提了,但是对外的表现却没有丝毫变化。

经过简单思考,便可以发现这一简单的改变其实也较为满足SOLID原则中的一部分:

  • 单一职责:SearchCriteriaBinder职责很单一,解析工作交由同样职责单一的Tokenizer进行。
  • 依赖注入:这里使用了构造函数注入的方式,SearchCriteriaBinder对Tokenizer的依赖不是写死在代码里的。
  • 接口分离:SearchCriteriaBinder并不直接访问Tokenizer,而是通过一个抽象(ITokenizer)使用的。

这便是我常用的“可测试性驱动开发”,我一开始只是按照惯例直接实现BindModel方法,然后发现一个需要测试的私有方法,因此为了提高可测试性,我将部分功能提取到独立的Tokenizer对象中去。在实际开发过程中,我也可能是直接在脑子里进行简单的分析,然后直接发现我们的确需要一个Tokenizer方法(这点想象应该不难),于是直接实现ITokenzier接口,Tokenizer类以及单元测试。这样,可能SearchCriteriaBinder从一开始就会变成目前的样子。不过更多的情况,的确是写着写着,为了“可测试性”而进行的重构。为了说明这个“思路”,我接下来还是使用“编写代码——尝试测试而不能——重构”的方式来进行开发。

好,我已经拆分成功了,也就是得到了一个List<string[]>对象。接下来,我要读取其中的数据,将其转化为一个SearchCriteria对象:

public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
{
    ...
    var tokenGroups = this.m_tokenizer.Tokenize(text);

    return this.Build(tokenGroups);
}

private SearchCriteria Build(List<string[]> tokenGroups)
{
    var fieldTokens = tokenGroups.ToDictionary(
        g => g[0].ToLowerInvariant(),
        g => g.Skip(1).ToList());

    var searchCriteria = new SearchCriteria();

    List<string> values;
    if (fieldTokens.TryGetValue("keywords", out values))
    {
        searchCriteria.Keywords = values[0];
    }

    if (fieldTokens.TryGetValue("price", out values))
    {
        searchCriteria.Price = new PriceRange
        {
            Min = float.Parse(values[0]),
            Max = float.Parse(values[1])
        };
    }

    if (fieldTokens.TryGetValue("color", out values))
    {
        ...
    }

    return searchCriteria;
}

在BindModel方法中得到了tokenGroups之后,便交由Build方法进行SearchCriteria对象的构建。首先,我先将List<string[]>对象转化为“字段”和“值”的对应关系,这样我们便可以使用keywords、price等字符串获取数据(也就是一个List<string>对象),并生成SerachCriteria各属性所需要的值了。在这里,我们这一切都放在Build方法中的几个if里进行,但这很显然不是容易单元测试的方法。要知道,这里的代码看上去容易,但事实上每个if里的逻辑其实并不仅仅如此。例如,在输入不合法的情况下是容错,还是抛出异常?如果Min大于Max的情况下,是否直接将其交换再继续处理?因此,其实在每个if之中还会有if,还会有for等复杂的逻辑。对于这样的逻辑,我想要单元测试

于是,我为List<string>到特定对象的转换操作也定义一个抽象:

public interface IConverter
{
    object Convert(List<string> values);
}

public class KeywordConverter : IConverter
{
    public object Convert(List<string> values)
    {
        return values[0];
    }
}

public class PriceRangeConverter : IConverter
{
    public object Convert(List<string> values)
    {
        return new PriceRange
        {
            Min = float.Parse(values[0]),
            Max = float.Parse(values[1])
        };
    }
}

public class ColorConverter : IConverter
{ 
    ...
}

在这里,我为每个字段定义了一种转化器(而在实际开发过程中,我们可能也会为“每种类型”定义一个)。每个转化器对象均可独立的进行单元测试,其中复杂的边界条件,错误判断等等都是测试的目标。待几种转换器测试完毕,我们便可以重构SerachCriteriaBinder的Build方法:

private SearchCriteria Build(List<string[]> tokenGroups)
{
    var fieldTokens = tokenGroups.ToDictionary(
        g => g[0].ToLowerInvariant(),
        g => g.Skip(1).ToList());

    var searchCriteria = new SearchCriteria();

    List<string> values;
    if (fieldTokens.TryGetValue("keywords", out values))
    {
        searchCriteria.Keywords = (string)this.GetConverter("keywords").Convert(values);
    }

    if (fieldTokens.TryGetValue("price", out values))
    {
        searchCriteria.Price = (PriceRange)this.GetConverter("price").Convert(values);
    }

    if (fieldTokens.TryGetValue("color", out values))
    {
        searchCriteria.Colors = (Color)this.GetConverter("color").Convert(values);
    }

    return searchCriteria;
}

private IConverter GetConverter(string field)
{
    // 使用if ... else或是字典
}

又想测试GetConverter方法了,怎么办?那就还是把GetConverter这部分逻辑从外部注入吧——哎,难道还要我写一个IConverterFactory接口和ConverterFactory类吗?也不一定,我们还是用“轻量”些的方法吧:

public class SearchCriteriaBinder : IModelBinder
{
    ...

    public SearchCriteriaBinder()
        : this(new Tokenizer(), GetConverter) { }

    internal SearchCriteriaBinder(ITokenizer tokenizer, Func<string, IConverter> converterGetter)
    {
        this.m_tokenizer = tokenizer;
        this.m_getConverter = converterGetter;
    }

    private readonly ITokenizer m_tokenizer;
    private readonly Func<string, IConverter> m_getConverter;

    internal static IConverter GetConverter(string field)
    {
        // 使用if ... else或是字典
    }

    private SearchCriteria Build(List<string[]> tokenGroups)
    {
        ...

        if (fieldTokens.TryGetValue("keywords", out values))
        {
            searchCriteria.Keywords = (string)this.m_getConverter("keywords").Convert(values);
        }

        ...
    }
}

这一次,我使用了委托对象的方式注入一段逻辑,它其实也是我们可以使用的一种方式。这样,我们便可以为GetConverter方法作独立的单元测试了。不过,我这里使用委托也有点“展示”的意味在里面,在实际开发过程中,可能我还是会使用ConverterFactory,这对我来说更“正规”一些。这种“接口”与“实现”分离的做法,除了能够独立测试之外,还有一个目的就是为了在测试Build方法时不依赖GetConverter的实现,也意味着不依赖PriceRangeConverter,KeywordConverter的实现等等,因为我们在测试Build方法时,可以提供一个针对测试的GetConverter方法逻辑,返回一些IConverter的Mock或Stub对象。这样,Build方法也就非常独立,不依赖外部实现了。

没错,我们想要独立测试Build方法,但是现在SearchCriteriaBinder还不允许我们这么做。那么,继续重构吧:

internal interface ISearchCriteriaBuilder
{
    SearchCriteria Build(List<string[]> tokenGroups);
}

internal class SearchCriteriaBuilder : ISearchCriteriaBuilder
{
    internal SearchCriteriaBuilder() : this(GetConverter) { }

    internal SearchCriteriaBuilder(Func<string, IConverter> converterGetter)
    {
        this.m_getConverter = converterGetter;
    }

    internal static IConverter GetConverter(string field)
    {
        // 使用if ... else或是字典
    }

    private readonly Func<string, IConverter> m_getConverter;

    public SearchCriteria Build(List<string[]> tokenGroups)
    {
        ...
    }
}

把Build的逻辑独立提取成类之后,自然需要让SearchCriteriaBinder使用SearchCriteriaBuilder:

public class SearchCriteriaBinder : IModelBinder
{
    public SearchCriteriaBinder()
        : this(new Tokenizer(), new SearchCriteriaBuilder()) { }

    internal SearchCriteriaBinder(ITokenizer tokenizer, ISearchCriteriaBuilder builder)
    {
        this.m_tokenizer = tokenizer;
        this.m_builder = builder;
    }

    private readonly ITokenizer m_tokenizer;
    private readonly ISearchCriteriaBuilder m_builder;

    public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        var modelName = bindingContext.ModelName;
        var rawValue = bindingContext.ValueProvider[modelName].RawValue;

        var text = HttpUtility.UrlDecode(rawValue.ToString());
        var tokenGroups = this.m_tokenizer.Tokenize(text);

        return this.m_builder.Build(tokenGroups);
    }
}

至此,我们的SearchCriteriaBinder便开发完成了。您可以访问http://gist.github.com/212644浏览最终的结果。

总结

以上便是一个完整的SearchCriteriaBinder的开发过程。可以发现,虽然我们的目标只有SearchCriteriaBinder一个,它也是唯一在外部可以访问到的类型(即public),但是我们这里总共出现了9个接口或是类。整个SearchCriteriaBinder是通过它们的协作完成的。9个看上去很多,但其实它们之间并没有复杂的交互,你会发现每个类本身只是和另外1至2个抽象有联系,它们也没有复杂的依赖关系。确切地说,我只是把它们拆成了一个个独立的小功能而已。

拆成小功能,只是为了进行独立的,细致的单元测试。也就是说,我的每一次重构,每一次拆分,目的都是为提高“可测试性”(我把它们都标红了),因此它是“可测试性驱动开发”。在为某个类进行单元测试的时候,也不会依赖其他类的具体实现,因为所有的类访问的都是抽象,我们只需要为这些抽象创建Mock——其实只是Stub就可以了。例如:

  • 测试SearchCriteriaBinder时,为ITokenizer和ISearchCriteriaBuilder创建Stub。
  • 测试SearchCriteriaBuilder时,为Func<string, IConverter>委托提供实现(也就是个Stub)。

这样,SearchCriteriaBinder的单元测试出错了,那么有问题的一定是SearchCriteriaBinder的实现,而不会是因为Tokenizer实现出错而造成的“连锁反应”。至于其他的类,都只是最简单的“工具类”,没有比它们更容易进行单元测试的东西了。

与传统的TDD相比,我常用的这种“可测试性驱动开发”使用的还是先开发,再测试的做法。在开发的时候,我们使用传统的设计方式,可能设计的只是一套类库/框架对外的表现。例如,我们在开发ASP.NET MVC应用程序时,知道我们需要一个SearchCriteriaBinder来生成Action的参数。于是,这个程序集的职责只是暴露出这个Binder而已。在具体实现这个Binder的过程中,我们也是用非常直接的开发方式,只是会时不时地关注“可测试性”。

“时不时”地关注,这点并不夸张。因为我在实际开发过程中,不会编写大段的逻辑再进行测试,而是写完一段之后(如Tokenize方法)我就会担心“这部分写的到底对不对”。于是,我不会等整个SearchCriteriaBinder实现完成便会提取出Tokenizer,实现并测试。这么做,也可以保证我的开发过程是渐进的,每一步都是走踏实的。使用这种方法,似乎也可以得到TDD的优势:

  • 得到许多测试
  • 模块化
  • 对重构和修改代码进行保护
  • 框架内部设计的文档
  • ……

如果要使用传统的TDD开发SearchCriteriaBinder的话,可能就需要先设计一个输入字符串,然后为它直接设计一个输出。此时,在这个测试中就要考虑许许多多的东西了,例如字符串的拆分,数据的转化,以及转化的各种边界情况等等。事实上,我认为如果不对SearchCriteriaBinder进行分割,是根本无法做到细致的完整的单元测试的。因此,即便是传统的TDD方式,最终也一定会将SearchCriteriaBinder分割成更小的部分。

我认为,使用传统的TDD方式最终得到的结果和“可测试性驱动开发”是很接近的——我是指产品代码。其中的区别可能是使用TDD的方式会有更多更细小的测试,也就是那些被人认为是非常stupid的测试。在开发一些“工具类”的时候,我们很容易想到此类细小的测试,但是大类就不一定了。在面向对象的方式进行开发时,涉及到更多的可能是类之间的交互。这时候“测试驱动”的思维(对我来说)就有些奇怪了。因此,我会选择先进行开发,然后重构成易于测试的形式。

“可测试性驱动开发”和传统的TDD也是不矛盾的,它们完全可以混合使用。例如在开发SearchCriteriaBinder时,我也不会将Tokenize这个私有方法真正实现完毕之后才提取出Tokenizer。因为,我其实很快(甚至是在脑子里“写代码”时)就会意识到Tokenize方法是有独立意义的,是需要单元测试的。因此,我会早早地定义ITokenizer接口,然后在开发Tokenizer这个工具类的时候,便使用传统TDD的方式进行。

这样看来,似乎我们也可以这么说:“可测试性驱动开发”是偏向于“设计”的(毕竟“可测试性”是设计出来的),而传统TDD则更偏向于“实现”。


火龙果软件/UML软件工程组织致力于提高您的软件工程实践能力,我们不断地吸取业界的宝贵经验,向您提供经过数百家企业验证的有效的工程技术实践经验,同时关注最新的理论进展,帮助您“领跑您所在行业的软件世界”。
资源网站: UML软件工程组织