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


【前言】

開發工具: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、代碼重構或需求變化時對代碼修改)質量的保證是非常可靠的。

  未完待續。。。。。。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM