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

我曾经写过很多的糟糕的单元测试程序。很多。但我坚持着写,现在我已经喜欢上了些单元测试。我编写单元测试的速度越来越快,当开发完程序,我现在有更多的信心相信它们能按照设计的预期来运行。我不希望我的程序里有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等条件写出独立的测试方法。

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

祝测试愉快!

获取文中的代码

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

[英文原文:Top 5 TDD Mistakes ]
分享这篇文章:

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

  1. David says:

    单元测试是一个程序员的习惯问题,好的程序员总是愿意花时间去保证他们的代码质量。

  2. xnnyygn says:

    文章中

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

    说的很对。如果是体力劳动,很多人都不愿意做。于是只能用行政命令,结果导致很多人对测试的反感。所以说怎么实践测试很重要,一般TDD是首选。

  3. hanf says:

    测不测不是问题,问题是怎么测

  4. select says:

    Mock我没怎么用,我也是直接手动来模拟的,我承认我刚开始时不会后,后来就懒的再看了。。

    先写测试再写代码,我一直想这样,但一个想法出来,我也不知道能不能实现,如以就先试着去实现。。。

  5. Yonghang Jiang says:

    “如果不是按照先写测试后写被测试程序的红,绿,重构方法原则,测试编写很可能会变成一种体力劳动。”这句话是作者原创吗?

    • Unknown says:

      “geter, setter 不需要测试” 这里getter都拼错了, 对于一个能犯如此低级错误的人, 如何测试? 如果不测, 明显的一个bug就出在这里了.

  6. u78 对这篇文章的反应是笑死了

发表评论

邮箱地址不会被公开。 必填项已用*标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据