1、前言
最近團隊要嘗試TDD(測試驅動開發)的實踐,不少人習慣了先代碼後測試的流程,對於TDD總心存恐懼,認爲沒有代碼的狀況下寫測試代碼時被架空了,無法寫下來,其實,根據我的實踐經驗,TDD並不可怕,還很可愛,只要你真正去實踐了幾十個測試用例以後,你會愛上這種形式方式的。微軟對於TDD的開發方式是大力支持和推薦的,新發布的VS2012的團隊模板就是根據。新的Visual Studio 2012給咱們帶來了Fakes框架,這是一個針對代碼測試時對測試的外界依賴(如數據庫,文件等)進行模擬的Mock框架,用上了以後,我當即從Moq的陣營中叛變了^_^。截止到寫此文的時間,網上尚未一篇關於Fakes框架的文章(除了「VS11將擁有更好的單元測試工具和Fakes框架」這篇介紹性的以外),就讓咱們來慢慢摸索着用吧。廢話少說,下面咱們就來一步一步的使用Visual Studio 2012的Fakes框架來實戰一把TDD。數據庫
2、需求說明
咱們要作的是一個普通的用戶註冊中「檢查用戶名是否存在」的功能,需求以下:數據結構
- 用戶名不能重複
- 可設置是否啓用郵件激活,若是不啓用郵件激活,則直接在「正式用戶信息表」中檢查,反之則還要進入「未激活用戶信息表」中進行查詢
3、項目結構
先分解一下項目的結構,仍是傳統的三層結構,從底層到上層:框架
- Liuliu.Components.Tools:通用工具組件
- Liuliu.Components.Data:通用數據訪問組件,目前只定義了一個數據訪問接口的通用基接口IRepository
- Liuliu.Demo.Core.Models:數據實體類,分兩個模塊,帳戶模塊(Account)與通用模塊(Common)
- Liuliu.Demo.Core:業務核心層,裏面包含Business與DataAccess兩個子層,DataAccess實現實體類的數據訪問,Business層實現模塊的業務邏輯,由於測試的過程當中數據訪問層的數據庫實現會用Fakes框架來模擬,因此數據訪問層只提供了接口,不提供實現,Business只調用了DataAccess的接口。咱們要作的工做就是用Fakes框架來模擬數據訪問層,用TDD的方式來編寫Business中的業務實現
- Liuliu.Demo.Core.Business.UnitTest:單元測試項目,存放着測試Business實現的測試用例。
- Liuliu.Demo.Consoles:用戶操做控制檯,功能實現後進行用戶操做的UI項目
其餘的項目與測試無關,略過。函數
4、開發準備
(一) 應用代碼準備
Entity:實體類的通用數據結構工具
-
-
-
- public abstract class Entity
- {
-
-
-
- public int Id { get; set; }
-
-
-
-
- public bool IsDelete { get; set; }
-
-
-
-
- public DateTime AddDate { get; set; }
- }
-
IRepository:通用數據訪問接口,簡單起見,只寫了幾個增刪改查的接口單元測試
-
-
-
-
- public interface IRepository<TEntity> where TEntity : Entity
- {
- #region 公用方法
-
-
-
-
-
-
-
- int Insert(TEntity entity, bool isSave = true);
-
-
-
-
-
-
-
- int Delete(TEntity entity, bool isSave = true);
-
-
-
-
-
-
-
- int Update(TEntity entity, bool isSave = true);
-
-
-
-
-
- int Commit();
-
-
-
-
-
-
- TEntity GetById(object id);
-
-
-
-
-
-
-
- TEntity GetByName(string name);
-
- #endregion
- }
Member:實體類——用戶信息測試
-
-
-
- public class Member : Entity
- {
- public string UserName { get; set; }
-
- public string Password { get; set; }
-
- public string Email { get; set; }
- }
MemberInactive:實體類——未激活用戶信息優化
-
-
-
- public class MemberInactive : Entity
- {
- public string UserName { get; set; }
-
- public string Password { get; set; }
-
- public string Email { get; set; }
- }
ConfigInfo:實體類——系統配置信息spa
-
-
-
- public class ConfigInfo : Entity
- {
- public ConfigInfo()
- {
- RegisterConfig = new RegisterConfig();
- }
-
- public RegisterConfig RegisterConfig { get; set; }
- }
-
-
- public class RegisterConfig
- {
-
-
-
- public bool NeedActive { get; set; }
-
-
-
-
- public int ActiveTimeout { get; set; }
-
-
-
-
- public bool EmailRepeat { get; set; }
- }
-
IMemberDao:數據訪問接口——用戶信息,僅添加IRepository不知足的接口翻譯
-
-
-
- public interface IMemberDao : IRepository<Member>
- {
-
-
-
-
-
- IEnumerable<Member> GetByEmail(string email);
- }
IMemberInactiveDao:數據訪問接口——未激活用戶信息,僅添加IRepository不知足的接口
-
-
-
- public interface IMemberInactiveDao : IRepository<MemberInactive>
- {
-
-
-
-
-
- IEnumerable<MemberInactive> GetByEmail(string email);
- }
IConfigInfoDao:數據訪問接口——系統配置,無額外需求的接口,因此爲空接口
-
-
-
- public interface IConfigInfoDao : IRepository<ConfigInfo>
- { }
IAccountContract:帳戶模塊業務契約——定義了三個操做,用做註冊前的數據檢查和註冊提交
-
-
-
- public interface IAccountContract
- {
-
-
-
-
-
-
- bool UserNameExistsCheck(string userName, string configName);
-
-
-
-
-
-
-
- bool EmailExistsCheck(string email, string configName);
-
-
-
-
-
-
-
- RegisterResults Register(Member model, string configName);
- }
以上代碼原本想收起來的,但測試時代碼展開老失效,因此辛苦你們劃了那麼長的鼠標來看下面的正題了\(^o^)/
(二) 測試類準備
- 添加測試項目的引用
- 添加要模擬實現接口的Fakes程序集,要模擬的接口在Liuliu.Demo.Core程序集中,因此在該程序集上點右鍵,選擇「添加Fakes程序集」菜單項
- 添加好了以後,Fakes框架會在測試項目中添加一個Fakes文件夾和一個配置文件,並自動生成引用一個 模擬程序集.Fakes 的程序集和Fakes框架的運行環境Microsoft.QualityTools.Testing.Fakes
- 打開對象查看器,可看到生成的Fakes程序集的內容,全部的接口都生成了一個對應的模擬類
- 經過ILSpy對Fakes程序集進行反向,能夠看到生成的模擬類以下所示,StubIMemberDao實現了接口IMemberDao,而接口中的公共成員都生成了「方法名+參數類型名」的委託模擬,用以接收外部給模擬方法的執行結果賦值,這樣每一個方法的返回值均可以被控制
- 另外生成的Fakes文件夾中的配置文件Liuliu.Demo.Core.fakes內容以下所示
1 <Fakes xmlns="http://schemas.microsoft.com/fakes/2011/">
2 <Assembly Name="Liuliu.Demo.Core"/>
3 </Fakes>
這個配置默認會把測試程序集中的全部接口、類都生成模擬類,固然也能夠配置生成指定的類型的模擬,相關知識這裏就不講了,請參閱官方文檔:Microsoft Fakes 中的代碼生成、編譯和命名約定
- 須要特別說明的是,每次生成,Fakes程序集都會從新生成,因此測試類有更改後想刷新Fakes程序集,只須要把原來的程序集刪除再進行生成,或者在測試項目能編譯的時候從新編譯測試項目便可。
(三) TDD正式開始
- 給測試項目添加一個單元測試類文件,添加新項 -> Visual C#項 -> 測試 -> 單元測試,命名爲AccountServiceTest.cs,推薦命名方式爲「測試類名+Test」的方式
- 添加一個測試方法,關於測試方法的命名,各人有各人的方案,這裏推薦一種方案:「測試方法名_執行結果_獲得此結果的條件/緣由」,而且測試方法是可使用中文的,好比「UserNameExistsCheck_用戶名已存在_用戶名在用戶信息表中已存在記錄」,這種方式好不少好處,特別是團隊成員英文水平不太好的時候,若是翻譯成英文的方式,頗有可能會不知所云,而且中文與需求文檔一一對應,很是明瞭,如下的測試用例中都會運用這種方式,若是不適應請在腦中自行翻譯\(^o^)/,創建測試方法以下:
- [TestMethod]
- public void UserNameExistsCheck_用戶名不存在()
- {
- var userName = "柳柳英俠";
- var configName = "configName";
- var accountService = new AccountService();
- Assert.IsFalse(accountService.UserNameExistsCheck(userName, configName));
- }
固然,此時運行測試是編譯不過的,由於AccountService類根本尚未建立。在Liuliu.Demo.Core.Business.Impl文件夾下添加AccountService類,並實現IAccountContract接口
-
-
-
- public class AccountService : IAccountContract
- {
-
-
-
-
-
-
- public bool UserNameExistsCheck(string userName, string configName)
- {
- throw new NotImplementedException();
- }
-
-
-
-
-
-
-
- public bool EmailExistsCheck(string email, string configName)
- {
- throw new NotImplementedException();
- }
-
-
-
-
-
-
-
- public RegisterResults Register(Member model, string configName)
- {
- throw new NotImplementedException();
- }
- }
再次運行測試,是通不過,TDD的基本作法就是讓測試儘快經過,因此修改方法UserNameExistsCheck爲以下:
-
-
-
-
-
-
- public bool UserNameExistsCheck(string userName, string configName)
- {
- return false;
- }
再次運行測試用例,紅叉終於變成綠勾了,我敢打賭,若是你真正實踐TDD的話,綠色將是你必定會喜歡的顏色
參數的字符串,值的有效性必定要檢查的,因此添加如下兩個測試用例,經過ExpectedException特性可能肯定拋出異常的類型
-
運行測試,結果以下,緣由爲尚未寫異常代碼,指望的異常沒有引起。└(^o^)┘日常咱們很怕出異常,如今要去指望出異常
異常代碼編寫很簡單,修改成以下便可經過:
- public bool UserNameExistsCheck(string userName, string configName)
- {
- if (string.IsNullOrEmpty(userName))
- {
- throw new ArgumentNullException("userName");
- }
- if (string.IsNullOrEmpty(configName))
- {
- throw new ArgumentNullException("configName");
- }
- return false;
- }
給AccountService類添加以下屬性,以便在接下來的操做中能模擬調用數據訪問層的操做
- #region 屬性
-
-
-
-
- public IMemberDao MemberDao { get; set; }
-
-
-
-
- public IMemberInactiveDao MemberInactiveDao { get; set; }
-
-
-
-
- public IConfigInfoDao ConfigInfoDao { get; set; }
-
- #endregion
接下來該進行用戶名存在的判斷了,即爲在用戶信息數據庫中(MemberDao)存在相同用戶名的用戶信息,在這裏的查詢實際並非到數據庫中查詢,而是經過Fakes框架生成的模擬類模擬出一個查詢過程與得到查詢結果。添加的測試用例以下:
- [TestMethod]
- public void UserNameExistsCheck_用戶名存在_該用戶名在用戶數據庫中已存在記錄()
- {
- var userName = "柳柳英俠";
- var configName = "configName";
- var accountService = new AccountService();
- var memberDao = new StubIMemberDao();
- memberDao.GetByNameString = str => new Member();
- accountService.MemberDao = memberDao;
- Assert.IsTrue(accountService.UserNameExistsCheck(userName, configName));
- }
StubIMemberDao類即爲Fakes框架由IMemberDao接口生成的一個模擬類,第7行實例化了一個該類的對象, 這個對象有一個委託類型的字段GetByNameString開放出來,咱們就能夠經過這個字段給接口的GetByName方法賦一個執行結果,即第8行的操做。再把這個對象賦給AccountService類中的IMemberDao類型的屬性(第9行),即至關於給AccountService類添加了一個操做用戶信息數據層的實現。
修改UserNameExistsCheck方法使測試經過
- public bool UserNameExistsCheck(string userName, string configName)
- {
- if (string.IsNullOrEmpty(userName))
- {
- throw new ArgumentNullException("userName");
- }
- if (string.IsNullOrEmpty(configName))
- {
- throw new ArgumentNullException("configName");
- }
- var member = MemberDao.GetByName(userName);
- if (member != null)
- {
- return true;
- }
- return false;
- }
運行測試,上面這個測試經過了,但第一個測試卻失敗了。
這不合乎TDD的要求了,TDD要求後面添加的功能不能影響原來的功能。看代碼實現是沒有問題的,看來問題是出在測試用例上。
當咱們走到「UserNameExistsCheck_用戶名存在_該用戶名在用戶數據庫中已存在記錄」這個測試用例的時候,添加了一些屬性,而這些屬性在第一個測試用例「UserNameExistsCheck_用戶名不存在」並無進行初始化,因此報了一個NullReferenceException異常。
接下來咱們來優化測試類的結構來解決這些問題:
a. 每一個測試用例的先決條件都要從0開始初始化,太麻煩
b. 測試環境沒有初始化,新增條件會影響到舊的測試用例的運行
-
根據以上提出的問題,給出下面的解決方案
a. 進行公共環境的初始化,即讓全部測試用例在相同的環境下運行
b. 全部的模擬環境都初始化爲「正確的」,結合現有場景,即認爲:數據訪問層的全部操做是可用的,而且能提供運行結果的,即查詢能查到數據,增刪改能操做成功。
c. 當須要不正確的環境時再單獨進行覆蓋設置(即從新給模擬方法的執行結果賦值)
根據以上方案對測試類初始化爲以下:給測試類添加字段和每一個方法運行前都運行的公共方法
- #region 字段
-
- private readonly AccountService _accountService = new AccountService();
- private readonly StubIMemberDao _memberDao = new StubIMemberDao();
- private readonly StubIMemberInactiveDao _memberInactiveDao = new StubIMemberInactiveDao();
- private readonly StubIConfigInfoDao _configInfoDao = new StubIConfigInfoDao();
-
- private int _num = 1;
- private Member _member = new Member();
- private readonly List<Member> _memberList = new List<Member>();
- private MemberInactive _memberInactive = new MemberInactive();
- private readonly List<MemberInactive> _memberInactiveList = new List<MemberInactive>();
- private ConfigInfo _configInfo = new ConfigInfo();
-
- #endregion
-
- [TestInitialize()]
- public void MyTestInitialize()
- {
- _memberDao.Commit = () => _num;
- _memberDao.DeleteMemberBoolean = (@member, @bool) => _num;
- _memberDao.GetByEmailString = @string => _memberList;
- _memberDao.GetByIdObject = @id => _member;
- _memberDao.GetByNameString = @string => _member;
- _memberDao.InsertMemberBoolean = (@member, @bool) => _num;
- _accountService.MemberDao = _memberDao;
-
- _memberInactiveDao.Commit = () => _num;
- _memberInactiveDao.DeleteMemberInactiveBoolean = (@memberInactive, @bool) => _num;
- _memberInactiveDao.GetByEmailString = @string => _memberInactiveList;
- _memberInactiveDao.GetByIdObject = @id => _memberInactive;
- _memberInactiveDao.GetByNameString = @string => _memberInactive;
- _memberInactiveDao.InsertMemberInactiveBoolean = (@memberInactive, @bool) => _num;
- _accountService.MemberInactiveDao = _memberInactiveDao;
-
- _configInfoDao.Commit = () => _num;
- _configInfoDao.DeleteConfigInfoBoolean = (@configInfo, @bool) => _num;
- _configInfoDao.GetByIdObject = @id => _configInfo;
- _configInfoDao.GetByNameString = @string => _configInfo;
- _configInfoDao.InsertConfigInfoBoolean = (@configInfo, @bool) => _num;
- _accountService.ConfigInfoDao = _configInfoDao;
-
- }
有了初始化之後,原來的測試用例就能夠如此的簡單,只須要初始化不成立的條件便可
- #region UserNameExistsCheck
- [TestMethod]
- public void UserNameExistsCheck_用戶名不存在()
- {
- var userName = "柳柳英俠";
- var configName = "configName";
- _member = null;
- Assert.IsFalse(_accountService.UserNameExistsCheck(userName, configName));
- }
-
- [TestMethod]
- [ExpectedException(typeof(ArgumentNullException))]
- public void UserNameExistsCheck_引起ArgumentNullException異常_參數userName爲空()
- {
- string userName = null;
- var configName = "configName";
- _accountService.UserNameExistsCheck(userName, configName);
- }
-
- [TestMethod]
- [ExpectedException(typeof(ArgumentNullException))]
- public void UserNameExistsCheck_引起ArgumentNullException異常_參數configName爲空()
- {
- var userName = "柳柳英俠";
- string configName = null;
- _accountService.UserNameExistsCheck(userName, configName);
- }
-
- [TestMethod]
- public void UserNameExistsCheck_用戶名存在_該用戶名在用戶數據庫中已存在記錄()
- {
- var userName = "柳柳英俠";
- var configName = "configName";
- Assert.IsTrue(_accountService.UserNameExistsCheck(userName, configName));
- }
-
- #endregion
[TestMethod]
- [ExpectedException(typeof(ArgumentNullException))]
- public void UserNameExistsCheck_引起ArgumentNullException異常_參數userName爲空()
- {
- string userName = null;
- var configName = "configName";
- var accountService = new AccountService();
- accountService.UserNameExistsCheck(userName, configName);
- }
-
- [TestMethod]
- [ExpectedException(typeof(ArgumentNullException))]
- public void UserNameExistsCheck_引起ArgumentNullException異常_參數configName爲空()
- {
- var userName = "柳柳英俠";
- string configName = null;
- var accountService = new AccountService();
- accountService.UserNameExistsCheck(userName, configName);
- }
-
全部條件都初始化好了,繼續研究需求,就能夠把測試用例的全部狀況都寫出來
- [TestMethod]
- [ExpectedException(typeof(NullReferenceException))]
- public void UserNameExistsCheck_引起NullReferenceException異常_系統配置信息沒法找到()
- {
- var userName = "柳柳英俠";
- var configName = "configName";
- _member = null;
- _configInfo = null;
- _accountService.UserNameExistsCheck(userName, configName);
- }
-
- [TestMethod]
- public void UserNameExistsCheck_用戶不存在_用戶在用戶數據庫中不存在_and_註冊不須要激活()
- {
- var userName = "柳柳英俠";
- var configName = "configName";
- _member = null;
- _configInfo.RegisterConfig.NeedActive = false;
- Assert.IsFalse(_accountService.UserNameExistsCheck(userName, configName));
- }
-
- [TestMethod]
- public void UserNameExistsCheck_用戶不存在_用戶在用戶數據庫中不存在_and_註冊須要激活_and_用戶名在未激活用戶數據庫中不存在()
- {
- var userName = "柳柳英俠";
- var configName = "configName";
- _member = null;
- _configInfo.RegisterConfig.NeedActive = true;
- _memberInactive = null;
- Assert.IsFalse(_accountService.UserNameExistsCheck(userName, configName));
- }
編寫代碼讓測試經過
- public bool UserNameExistsCheck(string userName, string configName)
- {
- if (string.IsNullOrEmpty(userName))
- {
- throw new ArgumentNullException("userName");
- }
- if (string.IsNullOrEmpty(configName))
- {
- throw new ArgumentNullException("configName");
- }
- var member = MemberDao.GetByName(userName);
- if (member != null)
- {
- return true;
- }
- var configInfo = ConfigInfoDao.GetByName(configName);
- if (configInfo == null)
- {
- throw new NullReferenceException("系統配置信息爲空。");
- }
- if (!configInfo.RegisterConfig.NeedActive)
- {
- return false;
- }
- var memberInactive = MemberInactiveDao.GetByName(userName);
- if (memberInactive != null)
- {
- return true;
- }
- return false;
- }
5、總結
看起來文章寫得挺長了,其實內容並無多少,篇幅都被代碼拉開了。咱們來總結一下使用Fakes框架進行TDD開發的步驟:
- 創建底層接口
- 建立測試接口的Fakes程序集
- 建立環境徹底初始化的測試類(這點比較麻煩,能夠配合T4模板進行生成)
- 分析需求寫測試用例
- 編寫代碼讓測試用例經過
- 重構代碼,並保證重構的代碼仍然能讓測試用例經過
另外有幾點經驗之談:
- 測試用例的方法名徹底能夠包含中文,清晰明瞭
- 因爲測試類的環境已徹底初始化,能夠根據需求把全部的測試用例一次寫出來,不肯定的能夠留爲空方法,也不會影響測試經過
- 當你習慣了TDD以後,你會離不開它的└(^o^)┘
本篇只對底層的接口進行了模擬,在下篇將對測試類中的私有方法,靜態方法等進行模擬,敬請期待^_^o~ 努力!
6、源碼下載
LiuliuTDDFakesDemo01.rar
7、參考資料
1.Microsoft Fakes 中的代碼生成、編譯和命名約定:
http://msdn.microsoft.com/zh-cn/library/hh708916
2.使用存根隔離對單元測試方法中虛擬函數的調用
http://msdn.microsoft.com/zh-cn/library/hh549174
3.使用填充碼隔離對單元測試方法中非虛擬函數的調用
http://msdn.microsoft.com/zh-cn/library/hh549176