測試驅動開發上的五大錯誤

aqee發表於2013-02-21

  我曾經寫過很多的糟糕的單元測試程式。很多。但我堅持著寫,現在我已經喜歡上了些單元測試。我編寫單元測試的速度越來越快,當開發完程式,我現在有更多的信心相信它們能按照設計的預期來執行。我不希望我的程式裡有bug,很多次,單元測試在很多弱智的小bug上挽救了我。如果我能這樣並帶來好處,我相信所有的人都應該寫單元測試!

  作為一個自由職業者,我經常有機會能看到各種不同的公司內部是如何做開發工作的,我經常吃驚於如此多的公司仍然沒有使用測試驅動開發(TDD)。當我問“為什麼”,回答通常是歸咎於下面的一個或多個常見的錯誤做法,這些錯誤是我在實施驅動測試開發中經常遇到的。這樣的錯誤很容易犯,我也是受害者。我曾合作過的很多公司因為這些錯誤做法而放棄了測試驅動開發,他們會持有這樣一種觀點:驅動測試開發“增加了不必要的程式碼維護量”,或“把時間浪費在寫測試上是不值得的”。

  人們會很合理的推斷出這樣的結論:

寫了單元測試但沒有起到任何作用,那還不如不寫。

  但根據我的經驗,我可以很有信心的說:

單元測試能讓我的開發更有效率,讓我的程式碼更有保障。

  帶著這樣的認識,下面讓我們看看一些我遇到過/犯過的最常見的在測試驅動開發中的錯誤做法,以及我從中學到的教訓。

  1、不使用模擬框架

  我在驅動測試開發上學到第一件事情就是應該在獨立的環境中進行測試。這意味著我們需要對測試中所需要的外部依賴條件進行模擬,偽造,或者進行短路,讓測試的過程不依賴外部條件。

  假設我們要測試下面這個類中的GetByID方法:

public class ProductService : IProductService
{
    private readonly IProductRepository _productRepository;
   
    public ProductService(IProductRepository productRepository)
    {
        this._productRepository = productRepository;
    }
   
    public Product GetByID(string id)
    {
        Product product =  _productRepository.GetByID(id);
   
        if (product == null)
        {
            throw new ProductNotFoundException();
        }
   
        return product;
    }
}

  為了讓測試能夠進行,我們需要寫一個IProductRepository的臨時模擬程式碼,這樣ProductService.GetByID就能在獨立的環境中執行。模擬出的IProductRepository臨時介面應該是下面這樣:

[TestMethod]
public void GetProductWithValidIDReturnsProduct()
{
    // Arrange
    IProductRepository productRepository = new StubProductRepository();
    ProductService productService = new ProductService(productRepository);
   
    // Act
    Product product = productService.GetByID("spr-product");
   
    // Assert
    Assert.IsNotNull(product);
}
   
public class StubProductRepository : IProductRepository
{
    public Product GetByID(string id)
    {
        return new Product()
        {
            ID = "spr-product",
            Name = "Nice Product"
        };
    }
   
    public IEnumerable<Product> GetProducts()
    {
        throw new NotImplementedException();
    }
}

  現在讓我們用一個無效的產品ID來測試這個方法的報錯效果。

[TestMethod]
public void GetProductWithInValidIDThrowsException()
{
    // Arrange
    IProductRepository productRepository = new StubNullProductRepository();
    ProductService productService = new ProductService(productRepository);
   
    // Act & Assert
    Assert.Throws<ProductNotFoundException>(() => productService.GetByID("invalid-id"));
}
   
public class StubNullProductRepository : IProductRepository
{
    public Product GetByID(string id)
    {
        return null;
    }
   
    public IEnumerable<Product> GetProducts()
    {
        throw new NotImplementedException();
    }
}

  在這個例子中,我們為每個測試都做了一個獨立的Repository。但我們也可在一個Repository上新增額外的邏輯,例如:

public class StubProductRepository : IProductRepository
{
    public Product GetByID(string id)
    {
        if (id == "spr-product")
        {
            return new Product()
            {
                ID = "spr-product",
                Name = "Nice Product"
            };
        }
   
        return null;
    }
   
    public IEnumerable<Product> GetProducts()
    {
        throw new NotImplementedException();
    }
}

  在第一種方法裡,我們寫了兩個不同的IProductRepository模擬方法,而在第二種方法裡,我們的邏輯變得有些複雜。如果我們在這些邏輯中犯了錯,那我們的測試就沒法得到正確的結果,這又為我們的除錯增加了額外的負擔,我們需要找到是業務程式碼出來錯還是測試程式碼不正確。

  你也許還會質疑這些模擬程式碼中的這個沒有任何用處的GetProducts()方法,它是幹什麼的?因為IProductRepository介面裡有這個方法,我們不得不加入這個方法以讓程式能編譯通過——儘管在我們的測試中這個方法根本不是我們考慮到物件。

  使用這樣的測試方法,我們不得不寫出大量的臨時模擬類,這無疑會讓我們在維護時愈加頭痛。這種時候,使用一個模擬框架,比如JustMock,將會節省我們大量的工作。

  讓我們重新看一下之前的這個測試例子,這次我們將使用一個模擬框架:

[TestMethod]
public void GetProductWithValidIDReturnsProduct()
{
    // Arrange
    IProductRepository productRepository = Mock.Create<IProductRepository>();
    Mock.Arrange(() => productRepository.GetByID("spr-product")).Returns(new Product());
    ProductService productService = new ProductService(productRepository);
   
    // Act
    Product product = productService.GetByID("spr-product");
   
    // Assert
    Assert.IsNotNull(product);
}
   
[TestMethod]
public void GetProductWithInValidIDThrowsException()
{
    // Arrange
    IProductRepository productRepository = Mock.Create<IProductRepository>();
    ProductService productService = new ProductService(productRepository);
   
    // Act & Assert
    Assert.Throws<ProductNotFoundException>(() => productService.GetByID("invalid-id"));
}

有沒有注意到我們寫的程式碼的減少量?在這個例子中程式碼量減少49%,更準確的說,使用模擬框架測試時程式碼是28行,而沒有使用時是57行。我們還看到了整個測試方法變得可讀性更強了!

  2、測試程式碼組織的太鬆散

  模擬框架讓我們在模擬測試中的生成某個依賴類的工作變得非常簡單,但有時候太輕易實現也容易產生壞處。為了說明這個觀點,請觀察下面兩個單元測試,看看那一個容易理解。這兩個測試程式是測試一個相同的功能:

  Test #1

TestMethod]
public void InitializeWithValidProductIDReturnsView()
{
    // Arrange
    IProductView productView = Mock.Create<IProductView>();
    Mock.Arrange(() => productView.ProductID).Returns("spr-product");
   
    IProductService productService = Mock.Create<IProductService>();
    Mock.Arrange(() => productService.GetByID("spr-product")).Returns(new Product()).OccursOnce();
   
    INavigationService navigationService = Mock.Create<INavigationService>();
    Mock.Arrange(() => navigationService.GoTo("/not-found"));
   
    IBasketService basketService = Mock.Create<IBasketService>();
    Mock.Arrange(() => basketService.ProductExists("spr-product")).Returns(true);
       
    var productPresenter = new ProductPresenter(
                                            productView,
                                            navigationService,
                                            productService, 
                                            basketService);
   
    // Act
    productPresenter.Initialize();
   
    // Assert
    Assert.IsNotNull(productView.Product);
    Assert.IsTrue(productView.IsInBasket);
}

  Test #2

[TestMethod]
public void InitializeWithValidProductIDReturnsView()
{
    // Arrange   
    var view = Mock.Create<IProductView>();
    Mock.Arrange(() => view.ProductID).Returns("spr-product");
   
    var mock = new MockProductPresenter(view);
   
    // Act
    mock.Presenter.Initialize();
   
    // Assert
    Assert.IsNotNull(mock.Presenter.View.Product);
    Assert.IsTrue(mock.Presenter.View.IsInBasket);
}

  我相信Test #2是更容易理解的,不是嗎?而Test #1的可讀性不那麼強的原因就是有太多的建立測試的程式碼。在Test #2中,我把複雜的構建測試的邏輯提取到了ProductPresenter類裡,從而使測試程式碼可讀性更強。

  為了把這個概念說的更清楚,讓我們來看看測試中引用的方法:

public void Initialize()
{
    string productID = View.ProductID;
    Product product = _productService.GetByID(productID);
   
    if (product != null)
    {
        View.Product = product;
        View.IsInBasket = _basketService.ProductExists(productID);
    }
    else
    {
       NavigationService.GoTo("/not-found");
    }
}

  這個方法依賴於View, ProductService, BasketService and NavigationService等類,這些類都要模擬或臨時構造出來。當遇到這樣有太多的依賴關係時,這種需要寫出準備程式碼的副作用就會顯現出來,正如上面的例子。

  請注意,這還只是個很保守的例子。更多的我看到的是一個類裡有模擬一、二十個依賴的情況。

  下面就是我在測試中提取出來的模擬ProductPresenterMockProductPresenter類:

public class MockProductPresenter
{
    public IBasketService BasketService { get; set; }
    public IProductService ProductService { get; set; }
    public ProductPresenter Presenter { get; private set; }
   
    public MockProductPresenter(IProductView view)
    {
        var productService = Mock.Create<IProductService>();
        var navigationService = Mock.Create<INavigationService>();
        var basketService = Mock.Create<IBasketService>();
   
        // Setup for private methods
        Mock.Arrange(() => productService.GetByID("spr-product")).Returns(new Product());
        Mock.Arrange(() => basketService.ProductExists("spr-product")).Returns(true);
        Mock.Arrange(() => navigationService.GoTo("/not-found")).OccursOnce();
   
        Presenter = new ProductPresenter(
                                   view,
                                        navigationService,
                                        productService,
                                        basketService);
    }
}

  因為View.ProductID的屬性值決定著這個方法的邏輯走向,我們向MockProductPresenter類的構造器裡傳入了一個模擬的View例項。這種做法保證了當產品ID改變時自動判斷需要模擬的依賴。

  我們也可以用這種方法處理測試過程中的細節動作,就像我們在第二個單元測試裡的Initialize方法裡處理product==null的情況:

[TestMethod]
public void InitializeWithInvalidProductIDRedirectsToNotFound()
{
    // Arrange
    var view = Mock.Create<IProductView>();
    Mock.Arrange(() => view.ProductID).Returns("invalid-product");
   
    var mock = new MockProductPresenter(view);
   
    // Act
    mock.Presenter.Initialize();
   
    // Assert
    Mock.Assert(mock.Presenter.NavigationService);
}

  這隱藏了一些ProductPresenter實現上的細節處理,測試方法的可讀性是第一重要的

  3、一次測試太多的專案

  看看下面的單元測試,請在不使用“和”這個詞的情況下描述它:

[TestMethod]
public void ProductPriceTests()
{
    // Arrange
    var product = new Product()
    {
        BasePrice = 10m
    };
   
    // Act
    decimal basePrice = product.CalculatePrice(CalculationRules.None);
    decimal discountPrice = product.CalculatePrice(CalculationRules.Discounted);
    decimal standardPrice = product.CalculatePrice(CalculationRules.Standard);
   
    // Assert
    Assert.AreEqual(10m, basePrice);
    Assert.AreEqual(11m, discountPrice);
    Assert.AreEqual(12m, standardPrice);
}

  我只能這樣描述這個方法:

  “測試中計算基價,打折價和標準價是都能否返回正確的值。”

  這是一個簡單的方法來判斷你是否一次測試了過多的內容。上面這個測試會有三種情況導致它失敗。如果測試失敗,我們需要去找到那個/哪些出了錯。

  理想情況下,每一個方法都應該有它自己的測試,例如:

[TestMethod]
public void CalculateDiscountedPriceReturnsAmountOf11()
{
    // Arrange
    var product = new Product()
    {
        BasePrice = 10m
    };
   
    // Act
    decimal discountPrice = product.CalculatePrice(CalculationRules.Discounted);
   
    // Assert
    Assert.AreEqual(11m, discountPrice);
}
   
[TestMethod]
public void CalculateStandardPriceReturnsAmountOf12()
{
    // Arrange
    var product = new Product()
    {
        BasePrice = 10m
    };
   
    // Act
    decimal standardPrice = product.CalculatePrice(CalculationRules.Standard);
   
    // Assert
    Assert.AreEqual(12m, standardPrice);
}
   
[TestMethod]
public void NoDiscountRuleReturnsBasePrice()
{
    // Arrange
    var product = new Product()
    {
        BasePrice = 10m
    };
   
    // Act
    decimal basePrice = product.CalculatePrice(CalculationRules.None);
   
    // Assert
    Assert.AreEqual(10m, basePrice);
}

  注意這些非常具有描述性的測試名稱。如果一個專案裡有500個測試,其中一個失敗了,你能根據名稱就能知道哪個測試應該為此承擔責任。

  這樣我們可能會有更多的方法,但換來的好處是清晰。我在程式碼大全(第2版) 裡看到了這句經驗之談:

為方法裡的每個IF,And,Or,Case,For,While等條件寫出獨立的測試方法。

驅動測試開發純粹主義者可能會說每個測試裡只應該有一個斷言。我想這個原則有時候可以靈活處理,就像下面測試一個物件的屬性值時:

public Product Map(ProductDto productDto)
{
    var product = new Product()
    { 
        ID = productDto.ID,
        Name = productDto.ProductName,
        BasePrice = productDto.Price
    };
   
    return product;
}

  我不認為為每個屬性寫一個獨立的測試方法進行斷言是有必要的。下面是我如何寫這個測試方法的:

[TestMethod]
public void ProductMapperMapsToExpectedProperties()
{
    // Arrange
    var mapper = new ProductMapper();
    var productDto = new ProductDto()
    {
        ID = "sp-001",
        Price = 10m,
        ProductName = "Super Product"
    };
   
    // Act
    Product product = mapper.Map(productDto);
   
    // Assert
    Assert.AreEqual(10m, product.BasePrice);
    Assert.AreEqual("sp-001", product.ID);
    Assert.AreEqual("Super Product", product.Name);
}

  4、先寫程式後寫測試

  我堅持認為,驅動測試開發的意義遠高於測試本身。正確的實施驅動測試開發能巨大的提高開發效率,這是一種良性迴圈。我看到很多開發人員在開發完某個功能後才去寫測試方法,把這當成一種在提交程式碼前需要完成的行政命令來執行。事實上,補寫測試程式碼只是驅動測試開發的一個內容。

如果不是按照先寫測試後寫被測試程式的紅,綠,重構方法原則,測試編寫很可能會變成一種體力勞動。

  如果想培養你的單元測試習慣,你可以看一些關於TDD的材料,比如The String Calculator Code Kata

  5、測試的過細

  請檢查下面的這個方法:

public Product GetByID(string id)
{
    return _productRepository.GetByID(id);
}

  這個方法真的需要測試嗎?不,我也認為不需要。

  驅動測試純粹主義者可能會堅持認為所有的程式碼都應該被測試覆蓋,而且有這樣的自動化工具能掃描並報告程式的某部分內容沒有被測試覆蓋,然而,我們要當心,不要落入這種給自己製造工作量的陷阱。

  很多我交談過的反對驅動測試開發的人都會引用這點來作為不寫任何測試程式碼的主要理由。我對他們的回覆是:只測試你需要測試的程式碼。我的觀點是,構造器,geter,setter等方法沒必要特意的測試。讓我們來加深記憶一下我前面提到的經驗論:

為方法裡的每個IF,And,Or,Case,For,While等條件寫出獨立的測試方法。

  如果一個方法裡沒有任何一個上面提到的條件語句,那它真的需要測試嗎?

  祝測試愉快!

  獲取文中的程式碼

  文中例子的程式碼你可以從這裡找到。

  英文原文:Top 5 TDD Mistakes

相關文章