如何以 NSubstitute 處理 Expression & IQueryable


Posted by WayneCheng on 2021-01-07

關於 NSubstitute 本篇將討論以下幾個問題

1. 什麼是 NSubstitute?

2. 如何處理 Expression & IQueryable?

3. NSubstitute 學習資源 & 參考資料


測試環境:

OS:Windows 10
IDE:Visual Studio 2019
UnitTest:xUnit


1. 什麼是 NSubstitute?

舉例來說,我們寫一個稍微複雜的方法 A,通常不可避免的會需要呼叫其他外部方法,此時若要對這個方法 A 撰寫單元測試時,就需要寫個假的實體(Mock、stub)來取代被呼叫的外部方法,以隔離外部方法,避免因外部方法而造成測試執行失敗。

但呼叫的外部方法一多,我們寫假的實體(Mock、stub)就會寫到天荒地老,好不快樂,此時我們就可以使用 NSubstitute 來幫我們建立假的實體(Mock、stub),將無數行程式碼濃縮成短短幾行!


2. 如何處理 Expression & IQueryable?

在開始寫單元測試前,我們先建立一個情境

1. 情境

資料庫有個 User 資料表,裡面存著 User Id 跟 User Name,今天接到兩個需求

  • 取得所有 User 資料
  • 依據 UserId 取得一筆 User 資料

要實作功能,我們預期會有

  • UserModel:對應資料庫中的資料
  • GetDataService:取得資料庫中的 User 資料並傳回
  • IRepository:GetDataService 中呼叫的外部方法取得 DB 中的資料

2. 建立 UserModel & GetDataService & 外部方法 IRepository

    // UserModel
    public class User
    {
        public int Id { get; set; }
        public string Name { get; set; }
    }
    // GetDataService
    public class GetDataService
    {
        private IRepository<User> _UserRepo { get; }

        public GetData (IRepository<User> userRepo)
        {
            _UserRepo = userRepo;
        }

        public IEnumerable<User> GetUsers()
        {
            var data = _UserRepo.Query();

            return data.AsEnumerable();
        }

        public User GetUserById(int id)
        {
            var data = _UserRepo.Query(x => x.Id == id);

            return data.FirstOrDefault();
        }
    }
    // IRepository
    public interface IRepository<T>
    {
        IQueryable<T> Query(Expression<Func<T, bool>> filter = null);
    }

3. 單元測試

        [Fact]
        public void GetUsers_取得兩筆UserData_回傳兩筆UserData()
        {
            try
            {
                //Arrange
                var userRepo = Substitute.For<IRepository<User>>();
                var userData = new List<User>
                               {
                                   new User { Id = 1, Name = "User01" },
                                   new User { Id = 2, Name = "User02" }
                               };
                // 不包含搜尋條件,filter = null,直接將 userData 轉 IQueryable 回傳
                userRepo.Query(Arg.Any<Expression<Func<User, bool>>>())
                        .Returns(userData.AsQueryable());
                var getData = new GetDataService(userRepo);

                //act
                var result = getData.GetUsers();

                //assert
                Assert.Equal(result.Any(x => x.Id == 1 && x.Name == "User01"), true);
                Assert.Equal(result.Any(x => x.Id == 2 && x.Name == "User02"), true);
            }
            catch (Exception e)
            {
                Console.WriteLine(e);
                throw;
            }
        }

        [Fact]
        public void GetUser_輸入Id_回傳UserData()
        {
            //Arrange
            var userRepo = Substitute.For<IRepository<User>>();
            var userData = new List<User>
                           {
                               new User { Id = 1, Name = "User01" },
                               new User { Id = 2, Name = "User02" }
                           };
            // 包含搜尋條件,將 userData.Where 使用 filter 條件搜尋後回傳
            userRepo.Query(Arg.Any<Expression<Func<User, bool>>>())
                    .Returns(arg => userData.Where(arg.ArgAt<Expression<Func<User, bool>>>(0).Compile()).AsQueryable());
            var getData = new GetDataService(userRepo);

            //act
            var result = getData.GetUsersById(1);

            //assert
            Assert.Equal(result.Id == 1 && result.Name == "User01", true);
        }

4. 說明

// 不包含搜尋條件,filter = null,直接將 userData 轉 IQueryable 回傳
userRepo.Query(Arg.Any<Expression<Func<User, bool>>>())
        .Returns(userData.AsQueryable());

取得所有 User 時不包含搜尋條件,filter = null,直接將 userData 轉 IQueryable 回傳,所以呼叫外部方法時會回傳全部 userData 的資料

// 包含搜尋條件,將 userData.Where 使用 filter 條件搜尋後回傳
userRepo.Query(Arg.Any<Expression<Func<User, bool>>>())
        .Returns(arg => userData.Where(arg.ArgAt<Expression<Func<User, bool>>

將外部方法 Returns 回傳的 userData 集合使用 LinQ Where,依據 GetUser 中所設定 filter 條件來篩選,以此方式隔離外部方法,因為外部方法是否正常運作不該影響當前測試方法


3. NSubstitute 學習資源

  1. NSubstitute 官網
  2. 聊聊程式 NSub說明書
  3. 余小章 @ 大內殿堂 #NSubstitute

完整程式碼 & 單元測試

GitHub:NSubstituteWithExpression

GitHub:NSubstituteWithExpressionTests


總結

NSubstitute 或是其他隔離工具對於有在寫單元測試的開發者來說應該都不陌生,所以對於一般使用方法就沒有多做說明,最近剛好寫到使用 Expression 的外部方法,但討論 & 教學好像滿少的(?),所以就記錄一下。


參考資料

  1. stack overflow

新手上路,若有錯誤還請告知,謝謝


#NSubstitute #unittest







Related Posts

測試替身(上篇)

測試替身(上篇)

常用的 React Hooks 簡介

常用的 React Hooks 簡介

元件介紹-Day01

元件介紹-Day01


Comments