文章出處

概述

  在今天, 前后端分離已經是首選的一個開發模式。這對于后端團隊來說其實是一個好消息,減輕任務并且更專注。在測試方面,就更加依賴于單元測試對于API以及后端業務邏輯的較驗。當然單元測試并非在前后端分離流行之后才有,它很早就存在,只是鮮有人重視且真的能夠用好它。而在前后端分離開發模式下,特別是兩者交付時間差別很大的情況時,后端可能需要更加地依賴于單元測試來保證代碼的正確性。

  本文主要圍繞單元測試展開,從單元測試的基礎概念說起,對比單元測試和集成測試,同時我們還會聊一聊單元測試與測試驅動開發的區別。在我們了解完單元測試的概念之后,我們會探討一下什么樣的單元測試算得上是好的單元測試,它們具備哪些特征,如何使用隔離框架來幫助我們對一些復雜的組件進行測試。最后一個內容也是本文想要闡述的重點: 單元測試是開發人員寫的,那么開發人員在寫自己的代碼的時候,如何提高自己代碼的可測試性? 什么樣的代碼算的上是對單元測試友好的代碼? 帶著這些問題,我們這就來開始我們的單元測試之旅。

目錄

  1. 什么是單元測試?
    1. 單元測試與測試
    2. 單元測試與集成測試
    3. 單元測試與測試驅動開發
    4. 一個單元測試的例子
    5. Mock和Stub的區別
  2. 怎么樣才算好的單元測試?
    1. 測試用例都有哪些?
    2. 自動化——持續集成
  3. 提高代碼的可測試性
    1. 整體架構層面的考慮
    2. 保持類的引用/依賴關系清晰,可注入
    3. 依賴于接口而非實現

什么是單元測試?

  有人可能寫過單元測試,但是卻不知道為什么要寫單元測試,有人知道為什么要寫單元測試,但不確定如何寫才是好的單元測試。但是對于“測試” 我們每個人都輕車熟路, 你看看下面的功能是否似曾相識?

單元測試與測試

  測試種類分為很多種:單元測試、集成測試、系統測試、壓力測試、負載測試、驗收測試等等 ,我們今天不打算也不能進行系統性的介紹。作為開發人員,我們平常所說的“測試”。也就是說你代碼寫完了,老大問你測試通過了嗎?你說過了,然后就可以Check in 代碼了。這里的“測試”,實際上指的是不完整的功能測試。為什么說它不完整,是因為從專業測試的角度來講,還需要定義規范的測試用例,用例寫完之后還要開發和測試人員一起評審等等 。 而我們只是在腦海中預想了一下它應該如何工作的,應該給我什么結果等,然后運行一下,咦,還真是這樣的,那我們的測試就算通過了。 會有多少Bug,就取決于我們這個預想有多細了,往往有時候我們只能想到很少一部份,這時候專業獨立的測試人員就派上用場了。同時精通開發和測試的人是很有優勢的,自己能夠保證寫出來的軟件的質量,這也是現代敏捷開發團隊所追求的,但是這樣的人總是少之又少。

  單元測試是通過把一個應用程序拆分成可測試的足夠小的部分,然后把每一部分與其它所有功能隔離開,單獨對這一部分進行測試。而這個“可測試的足夠小的部分”就稱之為“單元“,在C語言中一個單元可以是一個函數,在C#中單元測試可以是一個類。 如果所有的單元都能夠像我們所預料的正常工作,那么把他們合并起來就能夠保證至少不會出現很嚴重的錯誤。

單元測試與集成測試 

   為什么要把這兩項拿出來對比,是因為這兩項很容易混淆,一不小心你就可能把單元測試寫成集成測試了,這也是為什么單元測試有時候看起來那么糟糕的主要原因。我們上面說單元測試是把每一個單元孤立出來,在測試的時候不能和任何其它的單元有任何聯系,這是單元測試,反過來你一旦在你的測試代碼中引入了另外一個單元,那你就要開始小心,你是不是已經開始寫集成測試了。 當然有時候往往不是引入了其它的一些單元,有可能是一些組件,下面列出了一些單元測試和集成測試的主要特點,希望能夠幫助大家區分單元測試與集成測試。

單元測試

  • 可重復運行的
  • 持續長期有效,并且返回一致的結果
  • 在內存中運行,沒有外部依賴組件(比如說真實的數據庫,真實的文件存儲等)
  • 快速返回結果
  • 一個測試方法只測試一個問題

集成測試

  • 利用真實的外部依賴(采用真實的數據庫,外部的Web Service,文件存儲系統等)
  • 在一個測試里面可能會多個問題(數據庫正常確,配置,系統邏輯等)
  • 可以在運行較長時間之后才返回測試結果

單元測試與測試驅動開發(TDD)

  測試驅動開發其實我們用一個問題就可以解釋清楚,那就是“你什么時候寫單元測試?” 有人選擇在開發的代碼寫完之后再寫,這樣我們的開發過程是: 理解需求-》編寫代碼-》針對代碼結合需求寫單元測試。后來大家發現,往往在寫單元測試的時候發現自己有些需求沒有理解清楚,或者這些需求原來設計的時候就沒有考慮到,所以又重新改原來的代碼。 于是有人就說,為什么我們不干脆反過來? 先寫單元測試,再寫代碼?  所以我們開發的過程就變成了這樣:理解需求-》針對需求寫單元測試 -》 編寫代碼讓單元測試通過。 最開始是叫測試先行(TFD: Test First Development) ,后來就發展成我們熟知的"測試驅動開發"了。

  測試驅動開發最大的好處是,讓開發人員更好的理解需求,甚至是挖掘需求之后再進行開發。 當然,我們不可能一次性把所有的測試代碼都寫出來之后再寫代碼,這是一個重復迭代的過程:

  由于TDD不是我們本篇的主要內容,這里僅僅希望能給大家一個對TDD的淺顯認識的同時了解到TDD與單元測試的聯系。到這里,我們對于單元測試的概念就介紹的差不多了,接下來是代碼時間。:) 我們來上一個真實的例子更形象的了解一下單元測試。

一個單元測試的例子

  那么問題來了,我們用什么來案例來寫了一個單元測試的例子呢?既然這樣,那么我們就用前兩篇我們在領域模型驅動設計中講到的用戶注冊的例子吧。在用戶的領域服務中,UserService提供了一個Register的方法,通過用戶名、郵箱和密碼三個參數來創建一個用戶的對象。 像所有注冊邏輯一樣,郵箱是不能重復的,這是我們現在這個領域服務中比較重要的業務邏輯,所以我們的單元測試必須要覆蓋到。 我們的測試

// UserServiceTests.cs

 1 namespace RepositoryAndEf.Domain.Tests
 2 {
 3     public class UserServiceTests
 4     {
 5         private IRepository<User> _userRepository = new MockRepository<User>();
 6 
 7         [Fact]
 8         public void RegisterUser_ExpectedParameters_Success()
 9         {
10             var userService = new UserService(_userRepository);
11             var registeredUser = userService.Register(
12                 "hellojesseliu@outlook.com",
13                 "Jesse",
14                 "Jesse");
15 
16             var userFromRepository = _userRepository.GetById(registeredUser.Id);
17 
18             userFromRepository.Should().NotBe(null);
19             userFromRepository.Email.Should().Be("hellojesseliu@outlook.com");
20             userFromRepository.Name.Should().Be("Jesse");
21             userFromRepository.Password.Should().Be("Jesse");
22         }
23 
24         [Fact]
25         public void RegisterUser_ExistedEmail_ThrowException()
26         {
27             var userService = new UserService(_userRepository);
28             var registeredUser = userService.Register(
29                 "hellojesseliu@outlook.com",
30                 "Jesse",
31                 "Jesse");
32 
33             var userFromRepository = _userRepository.GetById(registeredUser.Id);
34             userFromRepository.Should().NotBe(null);
35 
36             Action action = () => userService.Register(
37                 "hellojesseliu@outlook.com",
38                 "Jesse_01",
39                 "Jesse");
40             action.ShouldThrow<ArgumentException>();
41         }
42 
43         public void RegisterUser_ExistedName_ThrowException()
44         {
45             var userService = new UserService(_userRepository);
46             var registeredUser = userService.Register(
47                 "hellojesseliu@outlook.com",
48                 "Jesse",
49                 "Jesse");
50 
51             var userFromRepository = _userRepository.GetById(registeredUser.Id);
52             userFromRepository.Should().NotBe(null);
53 
54             Action action = () => userService.Register(
55                 "hellojesseliu_02@outlook.com",
56                 "Jesse",
57                 "Jesse");
58             action.ShouldThrow<ArgumentException>();
59         }
60 
61     }
62 }
View Code

   在這個例子中我們用到了 Fluentassertions、XUnit這兩個開源組件。另外Moq作為一個不錯的單元測試Mock框架也推薦給大家。

  • Fluentassertions:相對于.NET測試工具本身提供的Assert,Fluentassertions提供基于鏈式構建的一些更人性、易懂的方法來幫助寫出更好理解的單元測試代碼 。 上面代碼中我們所用到的ShoudBe、NotBe、以及ShoudThrow等方法即來自于Fluentassertions,還有更多方法可以到官方文檔上查詢。
  • Xunit:這是一個開源的單元測試工具
  • Moq:為了讓單元測試可以完全脫離外部組件,我們需要用到一些Mock對象和Stub對象,而Moq是一個開源的Mock類框架可以幫助我們實現這些功能 。我們上面代碼中用到的MockRepository是我們自己用List封裝的一個IRepository實例,支持增刪改查,相當于我們把數據持久化于內存中。
 1 namespace RepositoryAndEf.Data
 2 {
 3     public class MockRepository<T> : IRepository<T> where T : BaseEntity
 4     {
 5         private List<T> _list = new List<T>();
 6 
 7         public T GetById(Guid id)
 8         {
 9             return _list.FirstOrDefault(e => e.Id == id);
10         }
11 
12         public IEnumerable<T> Get(Expression<Func<T, bool>> predicate)
13         {
14             return _list.Where(predicate.Compile());
15         }
16 
17         public bool Insert(T entity)
18         {
19             if (GetById(entity.Id) != null)
20             {
21                 throw new InvalidCastException("The id has already existed");
22             }
23 
24             _list.Add(entity);
25             return true;
26         }
27 
28         public bool Update(T entity)
29         {
30             var existingEntity = GetById(entity.Id);
31             if (existingEntity == null)
32             {
33                 throw new InvalidCastException("Cannot find the entity.");
34             }
35 
36             existingEntity = entity;
37             return true;
38         }
39 
40         public bool Delete(T entity)
41         {
42             var existingEntity = GetById(entity.Id);
43             if (existingEntity == null)
44             {
45                 throw new InvalidCastException("Cannot find the entity.");
46             }
47 
48             _list.Remove(entity);
49             return true;
50         }
51     }
52 }
MockRepository.cs

   我們也可以用Moq框架在單元測試中臨時初始化一個MockRepository

 1 private readonly IRepository<User> _userRepository;
 2         private List<User> _userList = new List<User>();
 3         public UserServiceTests()
 4         {
 5             var mockRepository = new Mock<IRepository<User>>();
 6 
 7             // 初始化新增方法 
 8             mockRepository.Setup(r => r.Insert(It.IsAny<User>())).Returns((User user) =>
 9             {
10                 if (_userList.Any(u => u.Id == user.Id))
11                 {
12                     throw new InvalidCastException("The id has already existed");
13                 }
14 
15                 _userList.Add(user);
16                 return true;
17             });
18 
19             _userRepository = mockRepository.Object;
20         }
View Code

  在單元測試代碼中臨時初始化Mock repository

  • 更靈活:可以只初始化用到的方法 
  • 更強的控制能力:可以從外部(單元測試代碼內)定義所有的行為 
  • 多態性:與其它單元測試類隔離,可以有不同的行為

Mock和Stub的區別

  因為有很多測試框架把Mock和Stub區別對待,初學者也會對這兩個概念表示含糊不清。簡單的來說,Mock與 Stub最大的區別是:

  Stub主要用來隔離其它的組件讓單元測試可以正常的進行,我們不會對Stub來進行Assert。

       

  Mock則用來和測試代碼進行交互,可以說我們會針對Mock來寫測試代碼,也會對它進行 Assert來驗證我們的代碼。

  在我們上面的代碼中,我們只用到了一個Mock(MockRepository),如果同樣是用戶注冊的業務,有哪些地方是我們可能需要用到Stub的? 試想一下現實的注冊場景,如果用戶注冊成功了, 我們是不是需要給用戶發送注冊成功的郵件通知?這里有一點需要注意的是,注冊用戶相關的代碼屬于我們領域服務的職責,但是注冊成功發送郵件、發送短信、甚至你要干一些系統相關的初始化操作都是屬于應用層的事情。關于這點,大家還可以回顧之前的兩篇關于DDD的文章。如果我們針對應用層的代碼編寫單元測試,那么我們就需要把一些組件比如郵件、日志等用Stub隔離掉,來保證測試代碼的運行。

怎樣才算好的單元測試?

什么是一個好的單元測試?

  • 是自動化的和可重復運行的
  • 很容易實現
  • 持續有用
  • 任何人只要輕松的點一下按鈕就可以運行
  • 運行不會花太長的時間
  • 一直返回同樣的結果(如果你不改變任何代碼或參數)
  • 單元測試是完全隔離的,不應該有任何其它的依賴
  • 當單元測試失敗的時候,應該一眼就看出是因為什么原因導致的這個失敗
  • 一個測試方法只驗證一個case,只用一個Mock,Stub可以是多個
  • 好的命名,最好是可以從方法名看出以下三個要素(所以一般我們采用三段命名法):
    • 測試目標
    • 條件 
    • 應該得到的結果

想知道你寫的單元測試是不是好的單元測試么?

  • 2個星期,或者2個月甚至2年前寫的單元測試還能運行并且得到同樣的結果么?
  • 團隊中的其它人也可以運行你2個月前寫的單元測試么?
  • 可以點擊一下按鈕就運行你所有的單元測試,并返回正確的結果么?
  • 所有的單元測試可以在幾分鐘之內完成么?

    

測試用例都有哪些?

  寫單元測試的代碼可能是開發的好幾倍,這句話是真的!在于你的單元測試用例覆蓋的有多廣,比如說我們上面針對用戶注冊這一個業務場景寫了3個測試用例,其實是遠遠不夠的。

非預期的用例

  不管我們上面那個完全成功注冊的用例,還是另外兩個由于郵箱和名稱重復而沒有注冊成功的用例。這三個用戶都是預期的,如果是非預期的,比如:

  • 如果郵箱地址不是一個正確格式的郵箱?
  • 如果我郵箱不填?用戶名不填?

邊界測試

  • 如果我的郵箱名稱或者用戶名長度超過最大限制?

回歸測試

  修改bug是一件難過的事情,在復雜且耦合度很高的系統下修改bug是一件難過且膽破心驚的事情,那么你感受一下:在復雜且耦合度很高的系統下不斷的修改同一個bug會是一種什么樣的心情。我們后期維護代碼的時候對于新增的改動也需要加上對應的測試代碼來保證單元測試的完整性。

自動化——持續集成

  持續集成里面已經包含了單元測試的自動化。它倡導團隊開發成員必須經常集成他們的工作,甚至每天都可能發生多次集成。而每次的集成都是通過自動化的構建來驗證,包括自動編譯、發布和測試,從而盡快地發現集成錯誤,讓團隊能夠更快的開發內聚的軟件。感興趣的同學可以自行了解,這是一個關于DevOps的話題,就不在本文作過多的表述。光想象一下那種不管誰有代碼check in都引發所有單元測試代碼的自動運行,在單元測試覆蓋的全的情況下基本可以過濾掉很多的潛在bug。 

提高代碼的可測試性

  我們多數遇到的項目之所有很少看到單元測試的代碼大概是因為以下的幾個原因:

  • 領導不重視 ,團隊內沒有這個風氣
  • 項目太緊,根本不給時間(可能也有領導不重視的原因)
  • 開發人員對于單元測試不熟悉 ,不知道怎么樣寫好單測試。(不好的單元測試代碼,寫了可能等于白寫,因為根本沒人去運行它們)
  • 解決方案里面的業務層根本沒有辦法寫單元測試(耦合度太高,重依賴,這是當我排除前面3個困難之后,常常遇到的最后一道坎)

  關于最后一點是需要架構師、或者比較有經驗在開發者在最開始設計系統結構的時候需要考慮到的。如果最開始沒有考慮到怎么辦? 那太好了,因為很多項目最開始都沒有考慮到,所以我們的單元測試代碼總是盛行不起來。(可憐這一層面的架構師也是少之又少,倒是有很多架構師活躍于各大論壇講高并發、各種分布式組件,能挽起袖子去重構/優化代碼結構的人真的少之又少。因為實在太累,而且搞不好還容易出錯,屬于最有挑戰,但其實卻往往不被老板重視的一項苦差事)遇到比較多的問題(包括BAT級別的項目,可能外面的架子、整體架構圖畫出來那是非常的漂亮,但是一旦涉及到業務層面的代碼....后面我就不說了。)

整體架構層面的考慮

  如果我們現在是重新開始搭建一套系統,那我們可以怎樣開始?或者說如果我們有魄力和決心去重構一套系統,我們該往哪些方向去走?—— 從DDD的分層架構說起

    分層: 首先是通過分層把業務與其它基礎組件隔離開,不要讓一些發郵件、記日志、寫文件等這些基礎組件混合了我們的業務,在應用層將領域業務與這些為應用服務的基礎功能組合起來。在之前的一篇文章 《初探領域驅動設計——為復雜業務而生》有具體的介紹。

    

  領域業務層無依賴

  在洋蔥架構中,核心(Core)層是與領域或技術無關的基礎構件塊,它包含了一些通用的構件塊,例如list、case類或Actor等等。核心層不包含任何技術層面的概念,例如REST或數據庫等等。 

  

  如果有依賴,請依賴于接口抽象,而非具體的實現,比如我們例子中的IRepository。這些架構思想其實已經很老很老了,但是我們多數的項目還停留在更更老的三層架構思想上,說好的技術極客們都去哪里了?

保持類的引用/依賴關系清晰,可注入

  不要使用靜態方案

  且不要說一些面向對象的特性沒有辦法使用到,一旦開了這個口子。天知道你的代碼里面會依賴于多少個外部靜態方法,并且完全沒有辦法在測試代碼中將它們mock掉,萬一你在靜態方法里面又有其它依賴,那對于單元測試來說就是一場終結。

  保持一個類所有的外部引用易見

  1.  所有外部引用易見
  2.  外部引用可注入/替換

  

  除了構造函數注入以外,我們還可以采用構造函數注入、字段、以及方法注入的方式,將我們的方法替換掉。這種方式不僅僅是對單元測試友好,更是一種良好的代碼組織方式,是可能提供代碼的易讀性,以及可維護性的。要知道代碼主要是給人閱讀的,只是偶爾讓機器執行一下。如果有跳槽經驗的同學應該都有過那種到了一個公司,有一個很復雜的系統,但是沒有任何的文檔(稍微好一點的可能會有表字典)的感受,唯一了解系統業務的方式是play with the system 然后,看代碼。 對于種無法一眼看到各個類之間的關系的代碼,特別是一個類里面有好幾百個方法、上萬行代碼的時候, 雖然我對于干這種事情已經輕車熟路,但當時的心情難免還是有些激(操)動(蛋)的。

 依賴于接口/抽象,而非實現 

  這點我想也就不需要細述了,在單元測試這個場景里面。我們主要是將業務與非業務相關功能用接口隔離開,那么我們在單元測試中就可以很靈活的用Mock或者Stub來替換。比如:讀寫文件、訪問數據庫、遠程請求等等。

最后

   編寫單元測試雖然簡單,但是考驗的卻是細心和對業務的理解程度。而且往往寫單元測試代碼所花的時間比寫功能代碼還要多,在任務時間進度緊、又不受重視的情況下,自己很少有人會主動愿意去寫。但是,好的單元測試代碼確實在長期能夠體現出它的價值。


文章列表


不含病毒。www.avast.com
arrow
arrow
    全站熱搜
    創作者介紹
    創作者 大師兄 的頭像
    大師兄

    IT工程師數位筆記本

    大師兄 發表在 痞客邦 留言(0) 人氣()