使用IdleTest進行TDD單元測試驅動開發演練(1)

dong.net發表於2013-11-01

【前言】

開發工具:Visual Studio 2012

測試庫:Visual Studio 2012自帶的MSTest

DI框架:Unity 

資料持久層:Entity Framework

前端UI:ASP.NET MVC 4.0

需求:我這裡假設只滿足兩個功能,一個使用者註冊,另一個則是登陸的功能,藉助於一些DDD思想,我將從領域層(或者常說的BLL)開始開發,當然每一層都是採用TDD,按我喜歡的做法就是“介面先行,測試驅動”,不廢話,直奔主題吧。

有關VS2012的單元測試請參見《VS2012 Unit Test 個人學習彙總(含目錄)

有關測試中使用的IdleTest庫請參見http://idletest.codeplex.com/

 

一、首先來建立解決方案與專案的結構。

 

1. 建立空白解決方案“IdleTest.TDDEntityFramework”,新建解決方案資料夾“Interfaces”,並在資料夾內建立兩個專案 “IdleTest.TDDEntityFramework.IRepositories” 和 “IdleTest.TDDEntityFramework.IServices”。

2. 直接在解決方案下建立類庫專案 “IdleTest.TDDEntityFramework.Services”、“IdleTest.TDDEntityFramework.Models” 和 “IdleTest.TDDEntityFramework.Repositories”

3. 在解決方案下建立MVC4專案"IdleTest.TDDEntityFramework.MvcUI"作為最終的UI,我這裡選擇空模板,解決方案初始結構初始結構圖如下

4. 把所有類庫專案中自動生成的“Class1.cs”檔案刪除。

5. 使用Visio畫出解決方案中各專案的關係(如下圖),這圖畫的是專案關係,實際上這些專案內的類也都遵循這樣的關係。例如本專案只有一個Model,即UserModel,那麼“IdleTest.TDDEntityFramework.IRepositories”下就相應將類命名為“IUserRepository”,“IdleTest.TDDEntityFramework.IServices”對應“IUserService”,以此類推,非介面則去掉字首“I”。這是我個人的一些習慣,每個人可能命名方式可能不太一樣,這很正常,但是如果是超過一個人來共同開發,則應將規範統一,俗話說“約定優於配置”嘛。

6. 這裡只是自己演練TDD的Demo而已,將不使用“UnitOfWork”,其他也可能會缺少不少功能,因為不低不在於Entity Framework或MVC等等,而關注的只是單元測試驅動開發罷了。

 

二、測試前的編碼以及其他方面的準備

 

7. 在“IdleTest.TDDEntityFramework.Models”下新增類“UserModel”。

    public class UserModel
    {
        public string LoginName { get; set; }

        public string Password { get; set; }

        public int Age { get; set; }
    }
UserModel

8. 分別在專案“IdleTest.TDDEntityFramework.IRepositories”和“IdleTest.TDDEntityFramework.IServices”下新增引用“IdleTest.TDDEntityFramework.Models”,並分別新增接口“IUserRepository”、“IRepository”和“IUserService”。 

    public interface IUserRepository : IRepository<UserModel, string>
    {
    }
    public interface IRepository<TEntity, TKey> where TEntity : class
    {
        IEnumerable<TEntity> Get(
            Expression<Func<TEntity, bool>> filter = null,
            Func<IQueryable<TEntity>, IOrderedQueryable<TEntity>> orderBy = null,
            string includeProperties = "");

        TEntity GetSingle(TKey id);

        void Insert(TEntity entity);

        void Update(TEntity entityToUpdate);

        void Delete(TKey id);

        void Delete(TEntity entityToDelete);
    }
IRepository
    public interface IUserService
    {
        bool Login(UserModel model);

        bool Register(UserModel model);

        UserModel GetModel(string loginName);
    }
IUserService

  那麼藉助DDD的一些思想,這裡的IUserService體現著功能需求,Service這層的程式碼完全由業務需求確定,因而IUserService只編寫了三個方法。而Repository這層則不去關心業務,只是常規性的公開且提供一些方法出來,這在很多專案中幾乎都是確定,孤兒IRepository也就自然而然具有了增刪改查的功能了。

9. 開始涉及單元測試,建立解決方案資料夾“Tests”,並在該資料夾下建立單元測試專案“IdleTest.TDDEntityFramework.ServiceTest”,新增引
用“IdleTest.TDDEntityFramework.IRepositories”、“IdleTest.TDDEntityFramework.IServices”、“IdleTest.TDDEntityFramework.Services”、“IdleTest.TDDEntityFramework.Models”,緊接著對“IdleTest.TDDEntityFramework.IRepositories”新增“Fakes程式集”(有關Fakes可參照VS2012 Unit Test——Microsoft Fakes入門)。

10. 在解決方案物理路徑下建立資料夾“libs”,並將“IdleTest”中相關dll拷貝進去。接著在專案“IdleTest.TDDEntityFramework.ServiceTest”新增引用,在“引用管理器”中單擊“瀏覽”按鈕,找到剛剛建立的“libs”資料夾,並新增下圖所示引用。有關IdleTest可參照從http://idletest.codeplex.com下載編譯。

 

 

三、編寫單元測試,邊測試邊修改程式碼

 

11. 我將在剛新增的測試專案中編寫一個針對“IUserService”的測試基類“BaseUserServiceTest”(關於對介面的測試可以參照VS2012 Unit Test —— 我對介面進行單元測試使用的技巧)。

using IdleTest;
using IdleTest.MSTest;
using IdleTest.TDDEntityFramework.IServices;
using IdleTest.TDDEntityFramework.IRepositories.Fakes;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Text;
using System.Threading.Tasks;
using IdleTest.TDDEntityFramework.Models;
using IdleTest.TDDEntityFramework.IRepositories;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace IdleTest.TDDEntityFramework.ServiceTest
{
    public abstract class BaseUserServiceTest
    {
        protected string ExistedLoginName = "zhangsan";

        protected string ExistedPassword = "123456";

        protected string NotExistedLoginName = "zhangsan1";

        protected string NotExistedPassword = "123";

        private IUserRepository userRepository;

        protected IList<UserModel> ExistedUsers;

        protected abstract IUserService UserService
        {
            get;
        }
        
        /// <summary>
        /// IUserRepository模擬物件
        /// </summary>
        public virtual IUserRepository UserRepository
        {
            get
            {
                if (this.userRepository == null)
                {
                    StubIUserRepository stubUserRepository = new StubIUserRepository();
                    //模擬Get方法
                    stubUserRepository.GetExpressionOfFuncOfUserModelBooleanFuncOfIQueryableOfUserModelIOrderedQueryableOfUserModelString
                        = (x, y, z) =>
                        {
                            return this.ExistedUsers.Where<UserModel>(x.Compile());
                        };

                    //模擬GetSingle方法
                    stubUserRepository.GetSingleString = p => this.ExistedUsers.FirstOrDefault<UserModel>(o => o.LoginName == p);

                    //模擬Insert方法
                    stubUserRepository.InsertUserModel = (p) => this.ExistedUsers.Add(p);

                    this.userRepository = stubUserRepository;
                }

                return this.userRepository;
            }
        }

        [TestInitialize]
        public void InitUserList()
        {
            //每次測試前都初始化
            this.ExistedUsers = new List<UserModel> { new UserModel { LoginName = ExistedLoginName, Password = ExistedPassword } };
        }

        public virtual void LoginTest()
        {
            //驗證登陸失敗的場景
            AssertCommon.AssertBoolean<UserModel>(
                new UserModel[] { 
                    null, new UserModel(),
                    new UserModel { LoginName = string.Empty, Password = ExistedPassword }, //賬戶為空
                    new UserModel { LoginName = ExistedLoginName, Password = string.Empty }, //密碼為空
                    new UserModel { LoginName = ExistedLoginName, Password = NotExistedPassword }, //密碼錯誤
                    new UserModel { LoginName = NotExistedLoginName, Password = NotExistedPassword },  //賬戶密碼錯誤                    
                    new UserModel { LoginName = NotExistedLoginName, Password = ExistedLoginName }  //賬戶錯誤
                }, false, p => UserService.Login(p));

            //賬戶密碼正確,驗證成功,這裡假設正確的賬戶密碼是"zhangsan"、"123456"
            UserModel model = new UserModel { LoginName = ExistedLoginName, Password = ExistedPassword };
            AssertCommon.AssertEqual<bool>(true, UserService.Login(model));
        }

        public virtual void RegisterTest()
        {
            //驗證註冊失敗的場景
            AssertCommon.AssertBoolean<UserModel>(
                new UserModel[] { 
                    null, new UserModel(),
                    new UserModel { LoginName = string.Empty, Password = NotExistedPassword }, //賬戶為空
                    new UserModel { LoginName = NotExistedLoginName, Password = string.Empty }, //密碼為空
                    new UserModel { LoginName = ExistedLoginName, Password = NotExistedPassword }, //賬戶已存在
                }, false, p => UserService.Register(p));

            //驗證註冊成功的場景
            //密碼與他人相同也可註冊
            UserModel register1 = new UserModel { LoginName = "register1", Password = ExistedPassword };
            UserModel register2 = new UserModel { LoginName = "register2", Password = NotExistedPassword };
            UserModel register3 = new UserModel { LoginName = "register3", Password = NotExistedPassword, Age = 18 }; 
            AssertCommon.AssertBoolean<UserModel>(
                new UserModel[] { register1, register2, register3 }, true, p => UserService.Register(p));

            //獲取使用者且應與註冊的資訊保持一致
            UserModel actualRegister1 = UserService.GetModel(register1.LoginName);
            AssertCommon.AssertEqual<string>(register1.LoginName, actualRegister1.LoginName);
            AssertCommon.AssertEqual<string>(register1.Password, actualRegister1.Password);
            AssertCommon.AssertEqual<int>(register1.Age, actualRegister1.Age);

            UserModel actualRegister2 = UserService.GetModel(register2.LoginName);
            AssertCommon.AssertEqual<string>(register2.LoginName, actualRegister2.LoginName);
            AssertCommon.AssertEqual<string>(register2.Password, actualRegister2.Password);
            AssertCommon.AssertEqual<int>(register2.Age, actualRegister2.Age);

            UserModel actualRegister3 = UserService.GetModel(register3.LoginName);
            AssertCommon.AssertEqual<string>(register3.LoginName, actualRegister3.LoginName);
            AssertCommon.AssertEqual<string>(register3.Password, actualRegister3.Password);
            AssertCommon.AssertEqual<int>(register3.Age, actualRegister3.Age);
        }

        public virtual void GetModelTest()
        {
            AssertCommon.AssertIsNull<string, UserModel>(TestCommon.GetEmptyStrings(), true, p => UserService.GetModel(p));
            AssertCommon.AssertIsNull(true, UserService.GetModel(NotExistedLoginName));

            UserModel actual = UserService.GetModel(ExistedLoginName);
            AssertCommon.AssertEqual<string>(ExistedLoginName, actual.LoginName);
            AssertCommon.AssertEqual<string>(ExistedPassword, actual.Password);
        }
    }
}
BaseUserServiceTest

 

  BaseUserServiceTest類本身不會具有任何測試,只有子類去繼承它,且實現抽象屬性“UserService”、Override相應的測試方法(LoginTest、RegisterTest、GetModelTest)並宣告“TestMethod”特性後才能進行測試。

12. 在測試專案再編寫類UserServiceTest,繼承BaseUserServiceTest。 

    [TestClass]
    public class UserServiceTest : BaseUserServiceTest
    {     
        protected override IUserService UserService
        {
            get { return new UserService(this.UserRepository); }
        }

        [TestMethod]
        public override void GetModelTest()
        {
            base.GetModelTest();
        }

        [TestMethod]
        public override void LoginTest()
        {
            base.LoginTest();
        }

        [TestMethod]
        public override void RegisterTest()
        {
            base.RegisterTest();
        }
    }
UserServiceTest

  由於父類已做好了相應的測試程式碼,此時編寫UserServiceTest就有點一勞永逸的感覺了。

  注意在實現“UserService”屬性時,編寫如下圖所示程式碼後按“Alt+Shift+F10”在彈出的小選單中選中“為UserService生成類”回車,這時發現它生成在了我們的測試專案中,我暫時不會去理會這些,現在最要緊的是我需要在最短時間最少程式碼量上使得我的測試通過。

  接著去修改剛生成的UserService類。 

    public class UserService : IUserService
    {
        private IUserRepository userRepository;

        public UserService(IUserRepository userRepository)
        {
            // TODO: Complete member initialization
            this.userRepository = userRepository;
        }

        public bool Login(UserModel model)
        {
            throw new NotImplementedException();
        }

        public bool Register(UserModel model)
        {
            throw new NotImplementedException();
        }

        public UserModel GetModel(string loginName)
        {
            throw new NotImplementedException();
        }
    }
UserService

13. 生成之後開啟“測試資源管理器”稍等幾秒即可發現三個需要測試的方法呈現了。此時測試當然都是全部不通過。繼續往下修改UserService,直至測試通過。

 

    public class UserService : IUserService
    {
        private IUserRepository userRepository;

        public UserService(IUserRepository userRepository)
        {
            // TODO: Complete member initialization
            this.userRepository = userRepository;
        }

        #region IUserService成員
        public bool Login(UserModel model)
        {
            if (!IsValidModel(model))
            {
                return false;
            }

            IList<UserModel> list = 
                userRepository.Get(p => p.LoginName == model.LoginName && p.Password == model.Password).ToList();

            return list != null && list.Count > 0;
        }

        public bool Register(UserModel model)
        {
            if (!IsValidModel(model))
            {
                return false;
            }

            if (GetModel(model.LoginName) != null)
            {
                return false;
            }

            userRepository.Insert(model);
            return true;
        }

        public UserModel GetModel(string loginName)
        {
            if (!string.IsNullOrEmpty(loginName))
                return userRepository.GetSingle(loginName);

            return null;
        }
        #endregion

        private bool IsValidModel(UserModel model)
        {
            return model != null && !string.IsNullOrEmpty(model.LoginName) && !string.IsNullOrEmpty(model.Password);
        }
    }
UserService

 

14. 此時測試已通過,檢視程式碼覆蓋率,雙擊”UserService“下未達到100%覆蓋率的行(如下圖所示)可以檢視哪些程式碼尚未覆蓋,然後酌情再看是否需要增加或修改程式碼以使覆蓋率達到100%,我這裡分析當前未覆蓋的對專案沒有什麼影響,故不再修改。

15. 最後將UserService類剪下到專案”IdleTest.TDDEntityFramework.Services“,新增引用,修改相應名稱空間。

再次執行測試並順利通過,那麼這一階段的開發與單元測試均大功告成。

 

【總結】


  上述過程簡言之,就是先搭建VS解決方案的專案結構,然後編寫Model(此無需測試,也是整個專案傳遞資料的基本),再寫專案需要的介面,接著針對介面編寫單元測試, 最後才是編寫實現介面的類程式碼。


  對於實現介面的類中的一些方法(如“UserService”類的“IsValidModel”方法)我並沒有針對它編寫測試,首先它是一個私有方法(關於私有方法需不需要測試的爭論貌似現在還沒有統一的結論,鄙人能力有限,不敢妄加評價);其次即使它是一個public方法,我也仍然不會去測試它,因為它只是為“IUserService”介面成員服務的,或者說該方法原本就不需要,只是我寫程式碼中重構出來,編寫完UserService我只關心該類中的“IUserService”介面成員,所以…… 其實,這裡也可以通過程式碼覆蓋率看到,即使沒有專門對“IsValidModel”方法編寫相應測試,但是它的覆蓋率仍然是100%,我不能確定私有方法到底要不要測試,但是在這裡我不測“IsValidModel”方法肯定沒有錯。


  測試基類“BaseUserServiceTest”是針對“IUserService”介面編寫的,而它的子類貌似什麼都不做,我之所以這麼寫,只是為了以後如果有新的類實現“IUserService”介面 時,我仍然只需要簡單的新增“BaseUserServiceTest”的一個子類,就可以完成測試,文中貌似也提到,有種一勞永逸的感覺,除非介面改變,否則對類的修改等等基本都不會影響 到原有測試。這樣就足以保證了以後修改bug、程式碼重構或需求變化時對程式碼修改後仍能。

 

  由於使用了依賴注入,故而測試時就可以隔離依賴,文中Service層原本是依賴Repository,但是我這裡在未具體實現Repository前都不會影響對Service層的開發與測試。


  TDD前期工作量比較大,但是對於後期程式碼(例如整體測試修改bug、程式碼重構或需求變化時對程式碼修改)質量的保證是非常可靠的。

  未完待續。。。。。。

相關文章