代码改变世界

【翻译】Test-After Development is not Test-Driven Development

2009-04-14 23:14  横刀天笑  阅读(...)  评论(... 编辑 收藏

http://www.asp.net/上连接过去看到这样两篇关于测试驱动开发的两篇文章,看完后觉得有些意思,如是有了翻译过来的冲动。本篇作者用了个小示例比较了测试驱动开发和测试在后的开发的不同之处,还讨论了单纯的单元测试和测试驱动开发中使用的测试。

 

原文连接:http://stephenwalther.com/blog/archive/2009/04/08/test-after-development-is-not-test-driven-development.aspx

以下是翻译内容:

 

最近,我和同事在关于测试驱动开发的正确方式上意见不合。这个意见不合非常重要,因为它会影响ASP.NET MVC框架的设计。

 

根据同事(在这里就称他为Tad吧)的说法,测试先行开发和测试在后开发没啥不同,除了编写单元测试的时间之外。Tad是测试在后开发的实践者和支持者。当实践测试在后开发时,是先编写应用代码,然后编写测试应用代码的单元测试。

 

从某些实践测试驱动开发的人的观点看,这是倒退。我认为,必须在编写任何应用代码之前编写单元测试是测试驱动开发的精华。为什么?

 

首先,也是最重要的,TDD是一种应用设计方法学。如果在编写应用代码之后编写单元测试,那就不是使用单元测试驱动应用的设计。换句话说,测试在后开发忽略了测试驱动开发中的驱动。

 

为了支持测试驱动开发,ASP.NET MVC框架需要支持两个东西:易测性和增量设计[incremental design](Martin Fowler称之为渐进设计[Evolutionary Design])。如果只对测试在后开发感兴趣,你可以忽略第二个需求(它会伤害对真正的测试驱动开发感兴趣的人)。

 

让我们考虑一个具体的场景:构建一个论坛应用

 

使用测试驱动开发构建一个论坛应用

 

下面是我使用测试驱动开发构建一个论坛应用时所遵循的步骤:

 

  1. 编写一个用户故事列表,用以描述论坛应用可以做什么。这些用户故事是非技术性的(这种类型的需求客户也可以编写)。
  2. 选一个用户故事,使用单元测试表达这个用户故事
  3. 编写刚好能通过单元测试的代码。换句话说,以最简单的方式为能通过单元测试。
  4. 考虑重构代码,改进应用的设计。我可以大胆的进行重构,因为单元测试覆盖了我的代码(RefactorMercilessly)。
  5. 在完成应用之前重复步骤2-3(牢记:在编写应用的过程中用户故事可能会发生变化)。

 

嗯,可以从下面这个用户故事列表开始:

1、 可以查看论坛所有帖子

2、 可以创建新帖子

3、 可以回复帖子

可以用类似于下面的单元测试表达第一个用户故事具体的需求:

[TestMethod]   
public void CanListForumPosts()   
{   
    // Arrange   
    var controller = new ForumController();   
  
    // Act   
    var result = (ViewResult)controller.Index();   
  
    // Assert   
    var forumPosts = (ICollection)result.ViewData.Model;   
    CollectionAssert.AllItemsAreInstancesOfType(forumPosts, typeof(ForumPost));   
}  

这个单元测试验证调用Forum控制器类的Index()动作返回的帖子集合。现在,这个单元测试会失败(我甚至都没有编译它),因为还不曾创建ForumController和ForumPost类。

 

遵循测试驱动开发设计方法学的最佳实践,在这个时候,我们只允许编写让单元测试通过的代码。而且,我可以以尽可能最容易和最简单的方式通过测试(我们不允许离开去编写庞大的论坛库,虽然这很有诱惑力)。

 

要让测试通过,我需要创建ForumsController类和ForumPost类。下面是ForumsController类的代码:

using System.Collections.Generic;   
using System.Web.Mvc;   
using Forums.Models;   
  
namespace Forums.Controllers   
{   
    public class ForumController : Controller   
    {   
        //   
        // GET: /Forum/   
  
        public ActionResult Index()   
        {   
            var forumPosts = new List<ForumPost>();   
            return View(forumPosts);   
        }   
  
    }   
} 

注意,Index()方法多简单。Index()方法只是简单的创建一个论坛帖子集合,然后返回它。

 

从好的软件设计的角度看,这个控制器非常糟糕。我混淆了职责。数据访问代码应该放在另外的类中。而且,更糟糕的是这个控制器其实在这里没有做任何有用的事情。

 

但是,从测试驱动开发的角度讲,这是最初创建论坛控制器完全正确的方法。测试驱动开发强制使用递增设计。我只允许编写通过单元测试的代码。

 

测试驱动开发强迫开发人员关注现在需要编写的代码,而不是有可能在未来需要编写的代码。在测试驱动开发背后的两个指导原则是:“Keep It Simple,Stupid,KISS)”和You Ain't Gonna Need It,YAGNI

 

最后,经过一系列编写单元测试和通过测试的代码重复的周期,你会在代码中注意到重复的现象。这时,你可以重构代码,改进代码的设计。你可以大胆地重构代码,因为单元测试覆盖了这些代码。

 

这里的重点是,应用必须是由单元测试驱动的。不能开始开始就遵循设计原则创建应用。反之,要在每个测试和代码周期之后递增的改进应用的设计。

 

使用测试在后开发构建论坛应用

 

测试在后开发的支持者构建应用的过程的方式非常不同。实践测试在后开发的人会先编写应用代码,然后编写单元测试。最重要的是,测试在后开发的支持者在应用开始之前做出所有设计决策。

 

测试驱动开发和测试在后开发最重要的不同点是对递增设计的信仰不同。测试驱动开发的追随者会小步的改进应用的设计。测试在后开发的追随者尝试在最开始实现最佳设计。

 

下面是测试在后开发的支持者创建论坛应用时大概遵循的步骤:

 

  1. 创建用户故事列表
  2. 考虑该应用的最佳设计(创建独立的控制器和仓库代码)。
  3. 根据设计编写应用代码
  4. 为应用代码编写单元测试
  5. 在论坛应用完成之前重复步骤2-4

 

不像实践测试驱动开发,测试在后开发的支持者会从创建独立的论坛控制器和数据库访问类开始。

 

据个例子,Forums控制器也许类似下面的代码:

using System.Web.Mvc;   
using TADApp.Models;   
  
namespace TADApp.Controllers   
{   
    public class ForumsController : Controller   
    {   
        private IForumsRepository _repository;   
  
        public ForumsController()   
            :this(new ForumsRepository()){}   
  
        public ForumsController(IForumsRepository repository)   
        {   
            _repository = repository;   
        }   
  
  
        public ActionResult Index()   
        {   
            var forumPosts = _repository.ListForumPosts();   
            return View(forumPosts);   
        }   
  
    }   
}  

数据库访问的代码如下:

using System;   
using System.Collections.Generic;   
using System.Linq;   
using System.Web;   
  
namespace TADApp.Models   
{   
    public interface IForumsRepository   
    {   
        IEnumerable<ForumPost> ListForumPosts();   
  
    }   
  
    public class ForumsRepository : IForumsRepository   
    {   
  
        private ForumsDBEntities _entities = new ForumsDBEntities();   
 
        #region IForumsRepository Members   
  
        public IEnumerable<ForumPost> ListForumPosts()   
        {   
            return _entities.ForumPostSet.ToList();   
        }  
 
        #endregion   
    }   
}  

 

然后,测试在后开发的支持者会为Forums控制器创建单元测试,如下所示:

[TestMethod]   
public void CanListForumPosts()   
{   
    // Arrange   
    var mockRepository = new Mock<IForumsRepository>();   
    mockRepository.Expect(r => r.ListForumPosts()).Returns(new List<ForumPost>());   
    var controller = new ForumsController(mockRepository.Object);   
  
    // Act   
    var result = (ViewResult)controller.Index();   
  
    // Assert   
    var forumPosts = (ICollection)result.ViewData.Model;   
    CollectionAssert.AllItemsAreInstancesOfType(forumPosts, typeof(ForumPost));   
}  

这个单元测试模拟了论坛数据仓库(通过模拟IForumsRepository接口),并且验证Forums控制器返回的论坛帖子集合。

 

单元测试 vs TDD测试

 

测试驱动开发的支持者和测试在后开发的支持者强烈不一致的地方是单元测试的话题。我不同意Tad的意图,还有编写单元测试的正确方式。

 

当实践测试驱动开发时,我从测试开始,然后只编写为能通过测试的代码。使用测试作为变化的安全网。特别地,因为使用测试作为安全网,所以我可以大胆的重构应用代码,改进应用的设计。

 

当使用测试驱动开发创建论坛应用时,第一个测试验证Forums控制器返回一个论坛帖子列表。我会保持这个测试,即使之后我重构了应用的设计,将数据访问逻辑迁移到独立的仓库类。我需要原始的测试验证在为了达到更好的设计而重构应用时没有破坏原始的应用代码。

 

我的单元测试直接遵循用户故事。然后,我添加单元测试,我几乎从来没有移除过单元测试。我可能会重构单元测试,阻止重复的代码出现在单元测试中。但是,我不会修改单元测试测试的目标。

 

对比一个测试在后开发的支持者,他会经常修改测试。当Tad重写应用的逻辑时,Tad会重写单元测试。Tad的单元测试受他的应用驱动。

 

在最开始,Tad也许会创建独立的Forums控制器类和仓库类。他也许会为Forums控制器和仓库类创建截然不同的单元测试集。当Tad重构他的应用改进应用的设计后,Tad会重写他的单元测试。

 

举个例子,假设Tad和我都决定为论坛应用添加验证机制。如果有人提交了一个标题为空的论坛帖子,我们都想显示一条验证错误的消息。

 

我会采用这种方式:测试forums控制器在我尝试创建一个非法的论坛帖子是否返回ModelState中的验证错误消息。我的单元测试类似如下:

[TestMethod]   
public void ForumPostSubjectIsRequired()   
{   
    // Arrange   
    var controller = new ForumController();   
    var postToCreate = new ForumPost { Subject = string.Empty };   
  
    // Act   
    var result = (ViewResult)controller.Create(postToCreate);   
  
    // Assert   
    var subjectError = result.ViewData.ModelState["Subject"].Errors[0];   
    Assert.AreEqual("Subject is required!", subjectError.ErrorMessage);   
}  

这个测试验证当你尝试创建一个新帖子,但没有提供标题时,在模型状态中是否包含一条验证错误消息。不管最终我如何重构应用(比如,使用一个独立的验证服务层),我都会保留这个单元测试验证应用是否满足用户故事表达的需求。

 

Tad,换句话说,也许永远也不会创建一个测试验证Forums控制器是否返回一条验证错误消息。Tad也许会争辩执行验证不是控制器的职责。控制器的职责是控制应用程序流。

 

Tad也许会为他的验证逻辑编写一个单元测试。但是,他的单元测试的本性会依赖于应用设计的架构。如果Tad使用独立的服务层包含验证逻辑,他会编写单元测试验证这个服务层的行为。如果Tad使用验证器特性执行验证,他必须编写单元测试验证期望的验证器特性。

 

Tad也许会争辩我的单元测试完全不是真正的单元测试。随着时间的推移,应用设计的发展,我的单元测试开始类似于功能(或验收)测试。它们实际上是验证在给定某些输入时,应用的输出。我的单元测试不依赖于应用的设计。

 

我也许会同意Tad,但我主张当实现测试驱动开发时所编写的测试与标准的单元测试有不同的意图。相比单元测试,TDD测试没有必要测试每个独立代码单元。相反,TDD测试用于测试“职责的一些小块,也许是类的一部分或几个类一起”(Martin Fowler)。

 

但不是说TDD测试与验收测试一模一样。验收测试用于端到端的测试应用(包括数据库和UI)。另一方面,TDD测试不是端到端的测试。TDD测试不需要额外的依赖,它被设计成可以快速执行。TDD测试用于测试从用户故事派生的特定需求是否得到满足(Uncle Bob)。

 

从测试驱动的观点看,单元测试的意图是驱动应用的设计。单元测试告诉我们,下一步需要编写的应用代码。比如,当创建一个单元测试时我还不知道将要如何实现验证逻辑。我不会在之前做出设计决策。我的测试告诉我什么可以做,以及什么必须做。单元测试为了我的成功提供了最少和最高准则。

 

我对Tad构建应用的方法主要的异议是他的方法强制过早的设计决策。Tad先做出设计决策,然后创建单元测试。我先创建单元测试,然后创建设计(Jeff Langr)。Tad的方法不允许以渐进的方式设计。

 

结论

 

那么,这有些什么问题?ASP.NET MVC框架被设计为高可测试的。因此,它必须使测试驱动开发和测试在后开发的支持者都高兴。是这样么?

 

本blog的观点是主张ASP.NET MVC框架。支持TDD后,则ASP.NET MVC框架被设计为同时支持可测试性和递增设计。从TDD支持者的观点看,如果一个框架不允许你通过小步设计从A点到达B点,那么将无法很好的设计任何应用。