技术开发 频道

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

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

  internal interface ISearchCriteriaBuilder

  {

  SearchCriteria Build(List tokenGroups);

  }

  internal class SearchCriteriaBuilder : ISearchCriteriaBuilder

  {

  internal SearchCriteriaBuilder() : this(GetConverter) { }

  internal SearchCriteriaBuilder(Func converterGetter)

  {

  this.m_getConverter = converterGetter;

  }

  internal static IConverter GetConverter(string field)

  {

  // 使用if ... else或是字典

  }

  private readonly Func m_getConverter;

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

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

  * 测试SearchCriteriaBinder时,为ITokenizer和ISearchCriteriaBuilder创建Stub。

  * 测试SearchCriteriaBuilder时,为Func委托提供实现(也就是个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则更偏向于“实现”。
 

0
相关文章