测试驱动开发上的五大错误

标签: 测试驱动开发 TDD 技术技巧 | 发表时间:2013-02-21 00:01 | 作者:Aqee
出处:http://www.aqee.net

我曾经写过很多的糟糕的单元测试程序。很多。但我坚持着写,现在我已经喜欢上了些单元测试。我编写单元测试的速度越来越快,当开发完程序,我现在有更多的信心相信它们能按照设计的预期来运行。我不希望我的程序里有bug,很多次,单元测试在很多弱智的小bug上挽救了我。如果我能这样并带来好处,我相信所有的人都应该写单元测试!

作为一个自由职业者,我经常有机会能看到各种不同的公司内部是如何做开发工作的,我经常吃惊于如此多的公司仍然没有使用测试驱动开发(TDD)。当我问“为什么”,回答通常是归咎于下面的一个或多个常见的错误做法,这些错误是我在实施驱动测试开发中经常遇到的。这样的错误很容易犯,我也是受害者。我曾合作过的很多公司因为这些错误做法而放弃了测试驱动开发,他们会持有这样一种观点:驱动测试开发“增加了不必要的代码维护量”,或“把时间浪费在写测试上是不值得的”。

人们会很合理的推断出这样的结论:

写了单元测试但没有起到任何作用,那还不如不写。

但根据我的经验,我可以很有信心的说:

单元测试能让我的开发更有效率,让我的代码更有保障。

带着这样的认识,下面让我们看看一些我遇到过/犯过的最常见的在测试驱动开发中的错误做法,以及我从中学到的教训。

1、不使用模拟框架

我在驱动测试开发上学到第一件事情就是应该在独立的环境中进行测试。这意味着我们需要对测试中所需要的外部依赖条件进行模拟,伪造,或者进行短路,让测试的过程不依赖外部条件。

假设我们要测试下面这个类中的 GetByID方法:

01. public class ProductService : IProductService
02. {
03.      private readonly IProductRepository _productRepository;
04.    
05.      public ProductService(IProductRepository productRepository)
06.      {
07.          this ._productRepository = productRepository;
08.      }
09.    
10.      public Product GetByID( string id)
11.      {
12.          Product product =  _productRepository.GetByID(id);
13.    
14.          if (product == null )
15.          {
16.              throw new ProductNotFoundException();
17.          }
18.    
19.          return product;
20.      }
21. }

为了让测试能够进行,我们需要写一个 IProductRepository的临时模拟代码,这样 ProductService.GetByID就能在独立的环境中运行。模拟出的 IProductRepository临时接口应该是下面这样:

01. [TestMethod]
02. public void GetProductWithValidIDReturnsProduct()
03. {
04.      // Arrange
05.      IProductRepository productRepository = new StubProductRepository();
06.      ProductService productService = new ProductService(productRepository);
07.    
08.      // Act
09.      Product product = productService.GetByID( "spr-product" );
10.    
11.      // Assert
12.      Assert.IsNotNull(product);
13. }
14.    
15. public class StubProductRepository : IProductRepository
16. {
17.      public Product GetByID( string id)
18.      {
19.          return new Product()
20.          {
21.              ID = "spr-product" ,
22.              Name = "Nice Product"
23.          };
24.      }
25.    
26.      public IEnumerable<Product> GetProducts()
27.      {
28.          throw new NotImplementedException();
29.      }
30. }

现在让我们用一个无效的产品ID来测试这个方法的报错效果。

01. [TestMethod]
02. public void GetProductWithInValidIDThrowsException()
03. {
04.      // Arrange
05.      IProductRepository productRepository = new StubNullProductRepository();
06.      ProductService productService = new ProductService(productRepository);
07.    
08.      // Act & Assert
09.      Assert.Throws<ProductNotFoundException>(() => productService.GetByID( "invalid-id" ));
10. }
11.    
12. public class StubNullProductRepository : IProductRepository
13. {
14.      public Product GetByID( string id)
15.      {
16.          return null ;
17.      }
18.    
19.      public IEnumerable<Product> GetProducts()
20.      {
21.          throw new NotImplementedException();
22.      }
23. }

在这个例子中,我们为每个测试都做了一个独立的Repository。但我们也可在一个Repository上添加额外的逻辑,例如:

01. public class StubProductRepository : IProductRepository
02. {
03.      public Product GetByID( string id)
04.      {
05.          if (id == "spr-product" )
06.          {
07.              return new Product()
08.              {
09.                  ID = "spr-product" ,
10.                  Name = "Nice Product"
11.              };
12.          }
13.    
14.          return null ;
15.      }
16.    
17.      public IEnumerable<Product> GetProducts()
18.      {
19.          throw new NotImplementedException();
20.      }
21. }

在第一种方法里,我们写了两个不同的 IProductRepository模拟方法,而在第二种方法里,我们的逻辑变得有些复杂。如果我们在这些逻辑中犯了错,那我们的测试就没法得到正确的结果,这又为我们的调试增加了额外的负担,我们需要找到是业务代码出来错还是测试代码不正确。

你也许还会质疑这些模拟代码中的这个没有任何用处的 GetProducts()方法,它是干什么的?因为 IProductRepository接口里有这个方法,我们不得不加入这个方法以让程序能编译通过——尽管在我们的测试中这个方法根本不是我们考虑到对象。

使用这样的测试方法,我们不得不写出大量的临时模拟类,这无疑会让我们在维护时愈加头痛。这种时候,使用一个模拟框架,比如 JustMock,将会节省我们大量的工作。

让我们重新看一下之前的这个测试例子,这次我们将使用一个模拟框架:

01. [TestMethod]
02. public void GetProductWithValidIDReturnsProduct()
03. {
04.      // Arrange
05.      IProductRepository productRepository = Mock.Create<IProductRepository>();
06.      Mock.Arrange(() => productRepository.GetByID( "spr-product" )).Returns( new Product());
07.      ProductService productService = new ProductService(productRepository);
08.    
09.      // Act
10.      Product product = productService.GetByID( "spr-product" );
11.    
12.      // Assert
13.      Assert.IsNotNull(product);
14. }
15.    
16. [TestMethod]
17. public void GetProductWithInValidIDThrowsException()
18. {
19.      // Arrange
20.      IProductRepository productRepository = Mock.Create<IProductRepository>();
21.      ProductService productService = new ProductService(productRepository);
22.    
23.      // Act & Assert
24.      Assert.Throws<ProductNotFoundException>(() => productService.GetByID( "invalid-id" ));
25. }

有没有注意到我们写的代码的减少量?在这个例子中代码量减少49%,更准确的说,使用模拟框架测试时代码是28行,而没有使用时是57行。我们还看到了整个测试方法变得可读性更强了!

2、测试代码组织的太松散

模拟框架让我们在模拟测试中的生成某个依赖类的工作变得非常简单,但有时候太轻易实现也容易产生坏处。为了说明这个观点,请观察下面两个单元测试,看看那一个容易理解。这两个测试程序是测试一个相同的功能:

Test #1

01. TestMethod]
02. public void InitializeWithValidProductIDReturnsView()
03. {
04.      // Arrange
05.      IProductView productView = Mock.Create<IProductView>();
06.      Mock.Arrange(() => productView.ProductID).Returns( "spr-product" );
07.    
08.      IProductService productService = Mock.Create<IProductService>();
09.      Mock.Arrange(() => productService.GetByID( "spr-product" )).Returns( new Product()).OccursOnce();
10.    
11.      INavigationService navigationService = Mock.Create<INavigationService>();
12.      Mock.Arrange(() => navigationService.GoTo( "/not-found" ));
13.    
14.      IBasketService basketService = Mock.Create<IBasketService>();
15.      Mock.Arrange(() => basketService.ProductExists( "spr-product" )).Returns( true );
16.        
17.      var productPresenter = new ProductPresenter(
18.                                              productView,
19.                                              navigationService,
20.                                              productService, 
21.                                              basketService);
22.    
23.      // Act
24.      productPresenter.Initialize();
25.    
26.      // Assert
27.      Assert.IsNotNull(productView.Product);
28.      Assert.IsTrue(productView.IsInBasket);
29. }

Test #2

01. [TestMethod]
02. public void InitializeWithValidProductIDReturnsView()
03. {
04.      // Arrange   
05.      var view = Mock.Create<IProductView>();
06.      Mock.Arrange(() => view.ProductID).Returns( "spr-product" );
07.    
08.      var mock = new MockProductPresenter(view);
09.    
10.      // Act
11.      mock.Presenter.Initialize();
12.    
13.      // Assert
14.      Assert.IsNotNull(mock.Presenter.View.Product);
15.      Assert.IsTrue(mock.Presenter.View.IsInBasket);
16. }

我相信Test #2是更容易理解的,不是吗?而Test #1的可读性不那么强的原因就是有太多的创建测试的代码。在Test #2中,我把复杂的构建测试的逻辑提取到了 ProductPresenter类里,从而使测试代码可读性更强。

为了把这个概念说的更清楚,让我们来看看测试中引用的方法:

01. public void Initialize()
02. {
03.      string productID = View.ProductID;
04.      Product product = _productService.GetByID(productID);
05.    
06.      if (product != null )
07.      {
08.          View.Product = product;
09.          View.IsInBasket = _basketService.ProductExists(productID);
10.      }
11.      else
12.      {
13.         NavigationService.GoTo( "/not-found" );
14.      }
15. }

这个方法依赖于 View, ProductService, BasketService and NavigationService等类,这些类都要模拟或临时构造出来。当遇到这样有太多的依赖关系时,这种需要写出准备代码的副作用就会显现出来,正如上面的例子。

请注意,这还只是个很保守的例子。更多的我看到的是一个类里有模拟一、二十个依赖的情况。

下面就是我在测试中提取出来的模拟 ProductPresenterMockProductPresenter类:

01. public class MockProductPresenter
02. {
03.      public IBasketService BasketService { get ; set ; }
04.      public IProductService ProductService { get ; set ; }
05.      public ProductPresenter Presenter { get ; private set ; }
06.    
07.      public MockProductPresenter(IProductView view)
08.      {
09.          var productService = Mock.Create<IProductService>();
10.          var navigationService = Mock.Create<INavigationService>();
11.          var basketService = Mock.Create<IBasketService>();
12.    
13.          // Setup for private methods
14.          Mock.Arrange(() => productService.GetByID( "spr-product" )).Returns( new Product());
15.          Mock.Arrange(() => basketService.ProductExists( "spr-product" )).Returns( true );
16.          Mock.Arrange(() => navigationService.GoTo( "/not-found" )).OccursOnce();
17.    
18.          Presenter = new ProductPresenter(
19.                                     view,
20.                                          navigationService,
21.                                          productService,
22.                                          basketService);
23.      }
24. }

因为 View.ProductID的属性值决定着这个方法的逻辑走向,我们向 MockProductPresenter类的构造器里传入了一个模拟的 View实例。这种做法保证了当产品ID改变时自动判断需要模拟的依赖。

我们也可以用这种方法处理测试过程中的细节动作,就像我们在第二个单元测试里的 Initialize方法里处理product==null的情况:

01. [TestMethod]
02. public void InitializeWithInvalidProductIDRedirectsToNotFound()
03. {
04.      // Arrange
05.      var view = Mock.Create<IProductView>();
06.      Mock.Arrange(() => view.ProductID).Returns( "invalid-product" );
07.    
08.      var mock = new MockProductPresenter(view);
09.    
10.      // Act
11.      mock.Presenter.Initialize();
12.    
13.      // Assert
14.      Mock.Assert(mock.Presenter.NavigationService);
15. }

这隐藏了一些 ProductPresenter实现上的细节处理, 测试方法的可读性是第一重要的。

3、一次测试太多的项目

看看下面的单元测试,请在不使用“和”这个词的情况下描述它:

01. [TestMethod]
02. public void ProductPriceTests()
03. {
04.      // Arrange
05.      var product = new Product()
06.      {
07.          BasePrice = 10m
08.      };
09.    
10.      // Act
11.      decimal basePrice = product.CalculatePrice(CalculationRules.None);
12.      decimal discountPrice = product.CalculatePrice(CalculationRules.Discounted);
13.      decimal standardPrice = product.CalculatePrice(CalculationRules.Standard);
14.    
15.      // Assert
16.      Assert.AreEqual(10m, basePrice);
17.      Assert.AreEqual(11m, discountPrice);
18.      Assert.AreEqual(12m, standardPrice);
19. }

我只能这样描述这个方法:

“测试中计算基价,打折价 标准价是都能否返回正确的值。”

这是一个简单的方法来判断你是否一次测试了过多的内容。上面这个测试会有三种情况导致它失败。如果测试失败,我们需要去找到那个/哪些出了错。

理想情况下,每一个方法都应该有它自己的测试,例如:

01. [TestMethod]
02. public void CalculateDiscountedPriceReturnsAmountOf11()
03. {
04.      // Arrange
05.      var product = new Product()
06.      {
07.          BasePrice = 10m
08.      };
09.    
10.      // Act
11.      decimal discountPrice = product.CalculatePrice(CalculationRules.Discounted);
12.    
13.      // Assert
14.      Assert.AreEqual(11m, discountPrice);
15. }
16.    
17. [TestMethod]
18. public void CalculateStandardPriceReturnsAmountOf12()
19. {
20.      // Arrange
21.      var product = new Product()
22.      {
23.          BasePrice = 10m
24.      };
25.    
26.      // Act
27.      decimal standardPrice = product.CalculatePrice(CalculationRules.Standard);
28.    
29.      // Assert
30.      Assert.AreEqual(12m, standardPrice);
31. }
32.    
33. [TestMethod]
34. public void NoDiscountRuleReturnsBasePrice()
35. {
36.      // Arrange
37.      var product = new Product()
38.      {
39.          BasePrice = 10m
40.      };
41.    
42.      // Act
43.      decimal basePrice = product.CalculatePrice(CalculationRules.None);
44.    
45.      // Assert
46.      Assert.AreEqual(10m, basePrice);
47. }

注意这些非常具有描述性的测试名称。如果一个项目里有500个测试,其中一个失败了,你能根据名称就能知道哪个测试应该为此承担责任。

这样我们可能会有更多的方法,但换来的好处是清晰。我在 《代码大全(第2版)》里看到了这句经验之谈:

为方法里的每个IF,And,Or,Case,For,While等条件写出独立的测试方法。

驱动测试开发纯粹主义者可能会说每个测试里只应该有一个断言。我想这个原则有时候可以灵活处理,就像下面测试一个对象的属性值时:

01. public Product Map(ProductDto productDto)
02. {
03.      var product = new Product()
04.     
05.          ID = productDto.ID,
06.          Name = productDto.ProductName,
07.          BasePrice = productDto.Price
08.      };
09.    
10.      return product;
11. }

我不认为为每个属性写一个独立的测试方法进行断言是有必要的。下面是我如何写这个测试方法的:

01. [TestMethod]
02. public void ProductMapperMapsToExpectedProperties()
03. {
04.      // Arrange
05.      var mapper = new ProductMapper();
06.      var productDto = new ProductDto()
07.      {
08.          ID = "sp-001" ,
09.          Price = 10m,
10.          ProductName = "Super Product"
11.      };
12.    
13.      // Act
14.      Product product = mapper.Map(productDto);
15.    
16.      // Assert
17.      Assert.AreEqual(10m, product.BasePrice);
18.      Assert.AreEqual( "sp-001" , product.ID);
19.      Assert.AreEqual( "Super Product" , product.Name);
20. }

4、先写程序后写测试

我坚持认为, 驱动测试开发的意义远高于测试本身。正确的实施驱动测试开发能巨大的提高开发效率,这是一种良性循环。我看到很多开发人员在开发完某个功能后才去写测试方法,把这当成一种在提交代码前需要完成的行政命令来执行。事实上,补写测试代码只是驱动测试开发的一个内容。

如果不是按照先写测试后写被测试程序的 红,绿,重构方法原则,测试编写很可能会变成一种体力劳动。

如果想培养你的单元测试习惯,你可以看一些关于TDD的材料,比如 The String Calculator Code Kata

5、测试的过细

请检查下面的这个方法:

1. public Product GetByID( string id)
2. {
3.      return _productRepository.GetByID(id);
4. }

这个方法真的需要测试吗?不,我也认为不需要。

驱动测试纯粹主义者可能会坚持认为所有的代码都应该被测试覆盖,而且有这样的自动化工具能扫描并报告程序的某部分内容没有被测试覆盖,然而,我们要当心,不要落入这种给自己制造工作量的陷阱。

很多我交谈过的反对驱动测试开发的人都会引用这点来作为不写任何测试代码的主要理由。我对他们的回复是:只测试你需要测试的代码。我的观点是,构造器,geter,setter等方法没必要特意的测试。让我们来加深记忆一下我前面提到的经验论:

为方法里的每个IF,And,Or,Case,For,While等条件写出独立的测试方法。

如果一个方法里没有任何一个上面提到的条件语句,那它真的需要测试吗?

祝测试愉快!

获取文中的代码

文中例子的代码你可以从 这里找到。


本文由 外刊IT评论网( www.aqee.net)原创发表,文章地址: 测试驱动开发上的五大错误

相关 [测试 开发 错误] 推荐:

测试驱动开发上的五大错误

- - 外刊IT评论
我曾经写过很多的糟糕的单元测试程序. 但我坚持着写,现在我已经喜欢上了些单元测试. 我编写单元测试的速度越来越快,当开发完程序,我现在有更多的信心相信它们能按照设计的预期来运行. 我不希望我的程序里有bug,很多次,单元测试在很多弱智的小bug上挽救了我. 如果我能这样并带来好处,我相信所有的人都应该写单元测试.

单元测试里的 5 个错误

- - ITeye博客
当我第一次听说可以使用框架比如JUnit来进行单元测试的时候,我惊叹这真是一个简单而强大的概念. 它取代了随机测试,使你可以保存你的测试代码,并按照需要随时运行它们. 按照我的理解,关于单元测试并没有多少产生误解的可能. 但是过去的几年中,我确实见过几种或多或少不太正确的单元测试使用方式. 如果跟协作逻辑代码分离开来,那么算法逻辑是最容易测试的.

Java开发者易犯错误Top10

- - CSDN博客编程语言推荐文章
摘要:在Java中,有些事物如果不了解的话,很容易就会用错,如数组转换为数组列表、元素删除、Hashtable和HashMap、ArrayList和LinkedList、Super和Sub构造函数等,如果这些对你来说是陌生的,你可以在本文中了解它们. 本文总结了Java开发者经常会犯的前十种错误列表.

Android NDK开发Crash错误定位

- - 极客521 | 极客521
在Android开发中,程序Crash分三种情况:未捕获的异常、ANR(Application Not Responding)和闪退(NDK引发错误). 其中 未捕获的异常根据logcat打印的堆栈信息很容易定位错误. ANR错误也好查,Android规定,应用与用户进行交互时,如果5秒内没有响应用户的操作,则会引发ANR错误,并弹出一个系统提示框,让用户选择继续等待或立即关闭程序.

软件开发团队主管易犯的十个错误

- Frank Cai - 36氪
本文是Roy Osherove在Skills Matter的一次发言,他介绍了团队领导经常会犯的十个错误,并提出了一些解决方案. Roy首先提出几个团队领袖可能遇到的一些问题:. 我如何说服的我团队做某件事情. 我该拿团队里的那个专门搞事的家伙怎么办?. 我们为什么无法远离无谓的争吵呢?. 他说这些问题其实缠绕他多年,接下来他也逐一做出解答.

开发移动应用的7个致命错误

- - HTML5研究小组
“幸福的家庭总是相似的,不幸的家庭各有各的不幸”,这个准则同样适用于移动应用开发者,最好的移动应用一般具备以下几个特点:美观,简单,实用,耐看. 而对于不好的应用,有些常见的缺点是可以避免的,下面我们列举出开发移动应用时 7 个致命错误:. 以 Bump 为例,这个应用是用来分享手机间的数据的. 刚开始这个应用支持分享音乐,人气应用,联系信息,图片和其他内容,用户不知道它到底能干什么,后来开发者砍掉了其他功能,只剩下联系信息和图片分享,它才取得成功.

案例:新产品开发模式的9大错误

- - 互联网分析
下面这个案例虽然发生时间已经有些久远,但其中蕴含的教训却是永恒的. 在20世纪末互联网经济泡沫高峰时期,Webvan(美国一家网上杂货零售 商,曾经一度非常著名,2001年宣布破产. ——译者注)一举成为最令人兴奋的新型初创企业,该公司曾雄心勃勃地宣称要让其产品深入每一个美国家庭. 在成 功筹集到史上最大一笔投资(超过8亿美元)之后,这家公司提出了耗资4.5亿美元的具有革命性意义的网上杂货零售业务,号称可实现“订购当日上门交货服 务”.

前端开发中最常见的12个HTML标签错误

- - ITeye资讯频道
开发者在写HTML代码的时候一定要仔细,并熟练掌握HTML规则,因为一不留神则可能出现一些微小的错误,但有可能会导致严重的后果. 本文列举了一些在HTML中常见的错误,并且给出了如何避免错误的方法. 相信这些方法会对前端开发者有一些帮助. 正确使用HTML tag的结束标记非常重要,HTML tag的结束标记的顺序要和开始标记一致,而新手往往会忽视这点.

创业开发团队常犯的9大类错误

- - 创业邦
  如今的创业公司面临的最大问题就是服务交付的速度,创业公司的开发者也与创业团队的其他成员一样,整天忙于救火,因此往往搞错重点和优先级,并最终为公司带来大麻烦.   青年创业家协会Young Entrepreneur Council近日就创业开发团队常犯的错误进行了一次调查,发现创业团队的开发者最常犯的错误可以归结为以下九大类:.