測試驅動開發上的五大錯誤
我曾經寫過很多的糟糕的單元測試程序。很多。但我堅持著寫,現在我已經喜歡上了些單元測試。我編寫單元測試的速度越來越快,當開發完程序,我現在有更多的信心相信它們能按照設計的預期來運行。我不希望我的程序里有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行。我們還看到了整個測試方法變得可讀性更強了!
#p#
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等類,這些類都要模擬或臨時構造出來。當遇到這樣有太多的依賴關系時,這種需要寫出準備代碼的副作用就會顯現出來,正如上面的例子。
請注意,這還只是個很保守的例子。更多的我看到的是一個類里有模擬一、二十個依賴的情況。
下面就是我在測試中提取出來的模擬ProductPresenter的MockProductPresenter類:
- 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實現上的細節處理,測試方法的可讀性是***重要的。
#p#
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);
- }
#p#
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