測(cè)試驅(qū)動(dòng)開發(fā)上的五大錯(cuò)誤
我曾經(jīng)寫過很多的糟糕的單元測(cè)試程序。很多。但我堅(jiān)持著寫,現(xiàn)在我已經(jīng)喜歡上了些單元測(cè)試。我編寫單元測(cè)試的速度越來越快,當(dāng)開發(fā)完程序,我現(xiàn)在有更多的信心相信它們能按照設(shè)計(jì)的預(yù)期來運(yùn)行。我不希望我的程序里有bug,很多次,單元測(cè)試在很多***的小bug上挽救了我。如果我能這樣并帶來好處,我相信所有的人都應(yīng)該寫單元測(cè)試!
作為一個(gè)自由職業(yè)者,我經(jīng)常有機(jī)會(huì)能看到各種不同的公司內(nèi)部是如何做開發(fā)工作的,我經(jīng)常吃驚于如此多的公司仍然沒有使用測(cè)試驅(qū)動(dòng)開發(fā)(TDD)。當(dāng)我問“為什么”,回答通常是歸咎于下面的一個(gè)或多個(gè)常見的錯(cuò)誤做法,這些錯(cuò)誤是我在實(shí)施驅(qū)動(dòng)測(cè)試開發(fā)中經(jīng)常遇到的。這樣的錯(cuò)誤很容易犯,我也是受害者。我曾合作過的很多公司因?yàn)檫@些錯(cuò)誤做法而放棄了測(cè)試驅(qū)動(dòng)開發(fā),他們會(huì)持有這樣一種觀點(diǎn):驅(qū)動(dòng)測(cè)試開發(fā)“增加了不必要的代碼維護(hù)量”,或“把時(shí)間浪費(fèi)在寫測(cè)試上是不值得的”。
人們會(huì)很合理的推斷出這樣的結(jié)論:
寫了單元測(cè)試但沒有起到任何作用,那還不如不寫。
但根據(jù)我的經(jīng)驗(yàn),我可以很有信心的說:
單元測(cè)試能讓我的開發(fā)更有效率,讓我的代碼更有保障。
帶著這樣的認(rèn)識(shí),下面讓我們看看一些我遇到過/犯過的最常見的在測(cè)試驅(qū)動(dòng)開發(fā)中的錯(cuò)誤做法,以及我從中學(xué)到的教訓(xùn)。
1、不使用模擬框架
我在驅(qū)動(dòng)測(cè)試開發(fā)上學(xué)到***件事情就是應(yīng)該在獨(dú)立的環(huán)境中進(jìn)行測(cè)試。這意味著我們需要對(duì)測(cè)試中所需要的外部依賴條件進(jìn)行模擬,偽造,或者進(jìn)行短路,讓測(cè)試的過程不依賴外部條件。
假設(shè)我們要測(cè)試下面這個(gè)類中的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;
- }
- }
為了讓測(cè)試能夠進(jìn)行,我們需要寫一個(gè)IProductRepository的臨時(shí)模擬代碼,這樣ProductService.GetByID就能在獨(dú)立的環(huán)境中運(yùn)行。模擬出的IProductRepository臨時(shí)接口應(yīng)該是下面這樣:
- [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();
- }
- }
現(xiàn)在讓我們用一個(gè)無效的產(chǎn)品ID來測(cè)試這個(gè)方法的報(bào)錯(cuò)效果。
- [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();
- }
- }
在這個(gè)例子中,我們?yōu)槊總€(gè)測(cè)試都做了一個(gè)獨(dú)立的Repository。但我們也可在一個(gè)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();
- }
- }
在***種方法里,我們寫了兩個(gè)不同的IProductRepository模擬方法,而在第二種方法里,我們的邏輯變得有些復(fù)雜。如果我們?cè)谶@些邏輯中犯了錯(cuò),那我們的測(cè)試就沒法得到正確的結(jié)果,這又為我們的調(diào)試增加了額外的負(fù)擔(dān),我們需要找到是業(yè)務(wù)代碼出來錯(cuò)還是測(cè)試代碼不正確。
你也許還會(huì)質(zhì)疑這些模擬代碼中的這個(gè)沒有任何用處的 GetProducts()方法,它是干什么的?因?yàn)镮ProductRepository接口里有這個(gè)方法,我們不得不加入這個(gè)方法以讓程序能編譯通過——盡管在我們的測(cè)試中這個(gè)方法根本不是我們考慮到對(duì)象。
使用這樣的測(cè)試方法,我們不得不寫出大量的臨時(shí)模擬類,這無疑會(huì)讓我們?cè)诰S護(hù)時(shí)愈加頭痛。這種時(shí)候,使用一個(gè)模擬框架,比如JustMock,將會(huì)節(jié)省我們大量的工作。
讓我們重新看一下之前的這個(gè)測(cè)試?yán)?,這次我們將使用一個(gè)模擬框架:
- [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"));
- }
有沒有注意到我們寫的代碼的減少量?在這個(gè)例子中代碼量減少49%,更準(zhǔn)確的說,使用模擬框架測(cè)試時(shí)代碼是28行,而沒有使用時(shí)是57行。我們還看到了整個(gè)測(cè)試方法變得可讀性更強(qiáng)了!
#p#
2、測(cè)試代碼組織的太松散
模擬框架讓我們?cè)谀M測(cè)試中的生成某個(gè)依賴類的工作變得非常簡(jiǎn)單,但有時(shí)候太輕易實(shí)現(xiàn)也容易產(chǎn)生壞處。為了說明這個(gè)觀點(diǎn),請(qǐng)觀察下面兩個(gè)單元測(cè)試,看看那一個(gè)容易理解。這兩個(gè)測(cè)試程序是測(cè)試一個(gè)相同的功能:
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的可讀性不那么強(qiáng)的原因就是有太多的創(chuàng)建測(cè)試的代碼。在Test #2中,我把復(fù)雜的構(gòu)建測(cè)試的邏輯提取到了ProductPresenter類里,從而使測(cè)試代碼可讀性更強(qiáng)。
為了把這個(gè)概念說的更清楚,讓我們來看看測(cè)試中引用的方法:
- 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");
- }
- }
這個(gè)方法依賴于View, ProductService, BasketService and NavigationService等類,這些類都要模擬或臨時(shí)構(gòu)造出來。當(dāng)遇到這樣有太多的依賴關(guān)系時(shí),這種需要寫出準(zhǔn)備代碼的副作用就會(huì)顯現(xiàn)出來,正如上面的例子。
請(qǐng)注意,這還只是個(gè)很保守的例子。更多的我看到的是一個(gè)類里有模擬一、二十個(gè)依賴的情況。
下面就是我在測(cè)試中提取出來的模擬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);
- }
- }
因?yàn)閂iew.ProductID的屬性值決定著這個(gè)方法的邏輯走向,我們向MockProductPresenter類的構(gòu)造器里傳入了一個(gè)模擬的View實(shí)例。這種做法保證了當(dāng)產(chǎn)品ID改變時(shí)自動(dòng)判斷需要模擬的依賴。
我們也可以用這種方法處理測(cè)試過程中的細(xì)節(jié)動(dòng)作,就像我們?cè)诘诙€(gè)單元測(cè)試?yán)锏腎nitialize方法里處理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實(shí)現(xiàn)上的細(xì)節(jié)處理,測(cè)試方法的可讀性是***重要的。
#p#
3、一次測(cè)試太多的項(xiàng)目
看看下面的單元測(cè)試,請(qǐng)?jiān)诓皇褂?ldquo;和”這個(gè)詞的情況下描述它:
- [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);
- }
我只能這樣描述這個(gè)方法:
“測(cè)試中計(jì)算基價(jià),打折價(jià)和標(biāo)準(zhǔn)價(jià)是都能否返回正確的值。”
這是一個(gè)簡(jiǎn)單的方法來判斷你是否一次測(cè)試了過多的內(nèi)容。上面這個(gè)測(cè)試會(huì)有三種情況導(dǎo)致它失敗。如果測(cè)試失敗,我們需要去找到那個(gè)/哪些出了錯(cuò)。
理想情況下,每一個(gè)方法都應(yīng)該有它自己的測(cè)試,例如:
- [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);
- }
注意這些非常具有描述性的測(cè)試名稱。如果一個(gè)項(xiàng)目里有500個(gè)測(cè)試,其中一個(gè)失敗了,你能根據(jù)名稱就能知道哪個(gè)測(cè)試應(yīng)該為此承擔(dān)責(zé)任。
這樣我們可能會(huì)有更多的方法,但換來的好處是清晰。我在《代碼大全(第2版)》里看到了這句經(jīng)驗(yàn)之談:
為方法里的每個(gè)IF,And,Or,Case,F(xiàn)or,While等條件寫出獨(dú)立的測(cè)試方法。
驅(qū)動(dòng)測(cè)試開發(fā)純粹主義者可能會(huì)說每個(gè)測(cè)試?yán)镏粦?yīng)該有一個(gè)斷言。我想這個(gè)原則有時(shí)候可以靈活處理,就像下面測(cè)試一個(gè)對(duì)象的屬性值時(shí):
- public Product Map(ProductDto productDto)
- {
- var product = new Product()
- {
- ID = productDto.ID,
- Name = productDto.ProductName,
- BasePrice = productDto.Price
- };
- return product;
- }
我不認(rèn)為為每個(gè)屬性寫一個(gè)獨(dú)立的測(cè)試方法進(jìn)行斷言是有必要的。下面是我如何寫這個(gè)測(cè)試方法的:
- [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、先寫程序后寫測(cè)試
我堅(jiān)持認(rèn)為,驅(qū)動(dòng)測(cè)試開發(fā)的意義遠(yuǎn)高于測(cè)試本身。正確的實(shí)施驅(qū)動(dòng)測(cè)試開發(fā)能巨大的提高開發(fā)效率,這是一種良性循環(huán)。我看到很多開發(fā)人員在開發(fā)完某個(gè)功能后才去寫測(cè)試方法,把這當(dāng)成一種在提交代碼前需要完成的行政命令來執(zhí)行。事實(shí)上,補(bǔ)寫測(cè)試代碼只是驅(qū)動(dòng)測(cè)試開發(fā)的一個(gè)內(nèi)容。
如果不是按照先寫測(cè)試后寫被測(cè)試程序的紅,綠,重構(gòu)方法原則,測(cè)試編寫很可能會(huì)變成一種體力勞動(dòng)。
如果想培養(yǎng)你的單元測(cè)試習(xí)慣,你可以看一些關(guān)于TDD的材料,比如The String Calculator Code Kata。
5、測(cè)試的過細(xì)
請(qǐng)檢查下面的這個(gè)方法:
- public Product GetByID(string id)
- {
- return _productRepository.GetByID(id);
- }
這個(gè)方法真的需要測(cè)試嗎?不,我也認(rèn)為不需要。
驅(qū)動(dòng)測(cè)試純粹主義者可能會(huì)堅(jiān)持認(rèn)為所有的代碼都應(yīng)該被測(cè)試覆蓋,而且有這樣的自動(dòng)化工具能掃描并報(bào)告程序的某部分內(nèi)容沒有被測(cè)試覆蓋,然而,我們要當(dāng)心,不要落入這種給自己制造工作量的陷阱。
很多我交談過的反對(duì)驅(qū)動(dòng)測(cè)試開發(fā)的人都會(huì)引用這點(diǎn)來作為不寫任何測(cè)試代碼的主要理由。我對(duì)他們的回復(fù)是:只測(cè)試你需要測(cè)試的代碼。我的觀點(diǎn)是,構(gòu)造器,geter,setter等方法沒必要特意的測(cè)試。讓我們來加深記憶一下我前面提到的經(jīng)驗(yàn)論:
為方法里的每個(gè)IF,And,Or,Case,F(xiàn)or,While等條件寫出獨(dú)立的測(cè)試方法。
如果一個(gè)方法里沒有任何一個(gè)上面提到的條件語句,那它真的需要測(cè)試嗎?
祝測(cè)試愉快!
獲取文中的代碼
文中例子的代碼你可以從這里找到。
英文原文:Top 5 TDD Mistakes