【單元測試篇】.Net單元測試


目錄

一、什么是單元測試

二、什么是集成測試

三、使用NUnit框架進行單元測試

3.1、如何進行單元測試

3.1.1、其中常用的Attribute

3.1.1.1、[TestFixture]

3.1.1.2、[Test]

3.1.1.3、[SetUp]

3.1.1.4、[TearDown]

3.1.1.5、[TestAction]

3.1.1.6、[Category]

3.1.1.7、[SetUpFixture]

3.1.2、UnitTest類執行順序

3.2、編寫測試的步驟

3.2.1、初始化對象,並配置它們(Arrange)

3.2.2、根據對象調用被測方法(Act)

3.2.3、根據Assert(斷言)判斷結果是否符合預期(Assert)

3.3、Assert類常用方法(NUnit.Framework Version 3.12.0.0)

3.4、外部依賴、樁對象(stub)、模擬對象(mock)

3.4.1、外部依賴

3.4.2、樁對象

3.4.3、模擬對象

3.4.3.1、基於狀態的測試

3.4.3.2、交互測試

3.4.3.3、模擬對象與樁對象的區別

3.4.4、手寫模擬對象與樁對象

3.4.4.1、通過屬性注入樁對象

3.4.4.2、通過工廠方法創建樁對象

3.4.4.3、使用條件編譯、Conditional標簽(只能標記Class、Method),內部構造函數

四、使用隔離框架

4.1、什么是隔離框架?

4.2、Moq隔離框架使用詳解

4.2.1、泛型類Mock的構造函數

4.2.2、Mock的Setup方法

4.2.3、Mock中的CallBase用法

4.2.4、Mock的DefaultValue屬性

4.2.5、Mock的SetupProperty與SetupAllProperties

4.2.6、Mock的As方法

4.2.7、Mock如何設置異步方法

4.2.8、Mock的Sequence用法

4.2.9、Mock的Protected用法

4.2.10、Mock的Of用法

4.2.11、Mock泛型參數進行匹配

4.2.12、Mock的Events用法

五、單元測試的映射

5.1、映射到項目

5.2、測試類映射到類

5.3、測試類映射到功能

5.4、測試方法映射到被測類的方法

 

一、什么是單元測試?

通過代碼自動化地判斷另外一段代碼的邏輯的正確性。單元指的是一個方法或函數。

單元測試的特點?

  • 自動的運行所有測試、並且是可以進行重復的執行。
  • 在任何時候都可以運行,並可以得到結果。

二、什么是集成測試?

是將兩個或者多個相依賴的軟件模塊作為一組進行測試。通常是通過GUI(圖形用戶界面)來進行測試某個功能。

集成測試的特點?

  • 是所有單元或軟件模塊都必須參與。
  • 是以更粗粒度或者宏觀的角度來測試產品功能。

單元測試與集成測試對比:

集成測試是將相依賴的多個單元協同測試。單元測試是對某一個單元進行測試

三、使用NUnit框架進行單元測試

3.1、如何進行單元測試

單元測試的項目需要添加NUnit Nuget包,並且在類中需引用

using NUnit.Framework;

3.1.1、其中常用的Attribute有:

3.1.1.1、[TestFixture]

標識NUnit自動化測試的類。

3.1.1.2、[Test]

只作用在方法上,表示這是一個需要被調用的自動化測試。

3.1.1.3、[SetUp]

只作用在方法上。會在所有Test運行前執行,用來裝配對象(樁對象/模擬對象)時使用,可以把它當作構造函數。

3.1.1.4、[TearDown]

只作用在方法上,用來釋放對象(還原變量狀態/釋放靜態變量)時使用。會在每次Test運行后執行(拋出異常后也會執行),可以把它當作析構函數。

PS:請不要在SetUp/TearDown中創建或者釋放那些並不是所有測試都在使用的對象,否則會讓閱讀代碼的人很難清楚的知道哪些測試方法使用了這些對象。

3.1.1.5、[TestAction]

這個是抽象類,需要新建子類來實現抽象類,類似AOP(面向切面編程)的思想,會在測試方法前后執行。代碼清單如下

using NUnit.Framework;
using NUnit.Framework.Interfaces;

namespace XUnitDemo.NUnitTests.AOP
{
    public class LogTestActionAttribute : TestActionAttribute
    {
        public override void BeforeTest(ITest test)
        {
            System.Diagnostics.Debug.WriteLine($"{nameof(BeforeTest)}-ClassName:{test.ClassName};Fixture:{test.Fixture};FullName:{test.FullName};MethodName:{test.MethodName};Name:{test.Name}");
            base.BeforeTest(test);
        }

        public override void AfterTest(ITest test)
        {
            System.Diagnostics.Debug.WriteLine($"{nameof(AfterTest)}-ClassName:{test.ClassName};Fixture:{test.Fixture};FullName:{test.FullName};MethodName:{test.MethodName};Name:{test.Name}");
            base.AfterTest(test);
        }
    }
}

using NUnit.Framework;
using XUnitDemo.NUnitTests.AOP;

namespace XUnitDemo.Tests
{
    [TestFixture]
    public class AccountServiceUnitTest
    {
        [LogTestAction]
        [Test]
        [Category("*用戶名認證的測試*")]
        public void Auth_UserNamePwd_ReturnTrue()
        {
            Assert.Pass();
        }

        [Test]
        [Category("*郵箱認證的測試*")]
        public void Auth_EmailPwd_ReturnTrue()
        {
            Assert.Pass();
        }

        [Test]
        [Category("*手機號認證的測試*")]
        public void Auth_MobilePwd_ReturnTrue()
        {
            Assert.Pass();
        }

        [Test]
        [Category("*手機號認證的測試*")]
        public void Auth_MobileCode_ReturnTrue()
        {
            Assert.Pass();
        }
    }
}

//BeforeTest-ClassName:XUnitDemo.Tests.AccountCountrollerUnitTest;Fixture:XUnitDemo.Tests.AccountCountrollerUnitTest;FullName:XUnitDemo.Tests.AccountCountrollerUnitTest.Auth_UserNamePwd_ReturnTrue;MethodName:Auth_UserNamePwd_ReturnTrue;Name:Auth_UserNamePwd_ReturnTrue

//AfterTest-ClassName:XUnitDemo.Tests.AccountCountrollerUnitTest;Fixture:XUnitDemo.Tests.AccountCountrollerUnitTest;FullName:XUnitDemo.Tests.AccountCountrollerUnitTest.Auth_UserNamePwd_ReturnTrue;MethodName:Auth_UserNamePwd_ReturnTrue;Name:Auth_UserNamePwd_ReturnTrue

3.1.1.6、[Category]

可以作用在程序集、類、方法上,作用是設置測試類別,可以在測試資源管理器上通過特征進行篩選類別。

using NUnit.Framework;
using XUnitDemo.NUnitTests.AOP;

[assembly: Category("NUnit相關的測試")]
namespace XUnitDemo.Tests
{
    [TestFixture]
    [Category("*賬號相關的測試*")]
    public class AccountServiceUnitTest
    {
        public void SetUp()
        { }

        [LogTestAction]
        [Test]
        [Category("*用戶名認證的測試*")]
        public void Auth_UserNamePwd_ReturnTrue()
        {
            Assert.Pass();
        }

        [Test]
        [Category("*郵箱認證的測試*")]
        public void Auth_EmailPwd_ReturnTrue()
        {
            Assert.Pass();
        }

        [Test]
        [Category("*手機號認證的測試*")]
        public void Auth_MobilePwd_ReturnTrue()
        {
            Assert.Pass();
        }

        [Test]
        [Category("*手機號認證的測試*")]
        public void Auth_MobileCode_ReturnTrue()
        {
            Assert.Pass();
        }
    }
}

項目整體運行后的GUI展示情況:

可以選擇測試資源管理器里面的分組依據來進行分組:

3.1.1.7、[SetUpFixture]

這個標簽只作用在類上,通常與[OneTimeSetUp]、[OneTimeTearDown]這兩個標簽一同使用。

在運行所有測試時,同一命名空間下,[OneTimeSetUp]這個標簽標記的方法會在所有的測試類中的[SetUp]標記的方法前執行,[OneTimeTearDown]這個標簽標記的方法會在所有的測試類中的[TearDown]標記的方法后執行,並且[OneTimeSetUp]、[OneTimeTearDown]只能標記在方法上。

using NUnit.Framework;

namespace XUnitDemo.NUnitTests.Product
{

    [SetUpFixture]
    public class SettingProductUnitTest
    {
        [OneTimeSetUp]
        public void OneTimeSetUp()
        {
            System.Diagnostics.Debug.WriteLine($"{nameof(SettingProductUnitTest)}-{nameof(OneTimeSetUp)}");
        }

        [OneTimeTearDown]
        public void OneTimeTearDown()
        { 
            System.Diagnostics.Debug.WriteLine($"{nameof(SettingProductUnitTest)}-{nameof(OneTimeTearDown)}");
        }
    }
}

using NUnit.Framework;

namespace XUnitDemo.NUnitTests.User
{

    [SetUpFixture]
    public class SettingUserUnitTest
    {
        [OneTimeSetUp]
        public void OneTimeSetUp()
        {
            System.Diagnostics.Debug.WriteLine($"{nameof(SettingUserUnitTest)}-{nameof(OneTimeSetUp)}");
        }

        [OneTimeTearDown]
        public void OneTimeTearDown()
        {
            System.Diagnostics.Debug.WriteLine($"{nameof(SettingUserUnitTest)}-{nameof(OneTimeTearDown)}");
        }
    }
}

顯示效果如下:

SettingProductUnitTest-OneTimeSetUp

OrderServiceUnitTest-SetUp

OrderServiceUnitTest-TearDown

ProductServiceUnitTest-SetUp

ProductServiceUnitTest-TearDown

SettingProductUnitTest-OneTimeTearDown

SettingUserUnitTest-OneTimeSetUp

UserMenuServiceUnitTest-SetUp

UserMenuServiceUnitTest-TearDown

UserServiceUnitTest-SetUp

UserServiceUnitTest-TearDown

SettingUserUnitTest-OneTimeTearDown

根據以上特點,[SetUpFeature]標簽特別適合在某一組相似的測試類中設置局部的變量,並且這個變量可以在這組測試類中使用。

3.1.2、UnitTest類執行順序為:

  • Constructor(構造函數)
  • SetUp(若多個方法標記SetUp則按照先后順序正序執行)
  • Test(測試方法)
  • TearDown(若多個方法標記TearDown則按照先后順序倒敘執行)

3.2、編寫測試的步驟:

3.2.1、初始化對象,並配置它們(Arrange)。

一般是指初始化被測類對象,並創建好被測類對象依賴的對象,通常使用偽對象(樁對象、模擬對象)來代替。

3.2.2、根據對象調用被測方法(Act)。

調用被測方法,一些狀態可以使用模擬對象來記錄,被測方法期間使用的返回值可以使用樁對象來返回。

3.2.3、根據Assert(斷言)判斷結果是否符合預期(Assert)。

根據Assert斷言判斷我們被測方法是否符合我們的預期邏輯,包括參數、異常拋出都可以進行斷言。

3.3、Assert類常用方法(NUnit.Framework Version 3.12.0.0):

Assert.AreEqual

斷言兩個雙精度或者兩個對象是否相等

Assert.AreNotEqual

斷言兩個對象不相等

Assert.AreNotSame

斷言兩個對象不引用同一個對象

Assert.AreSame

斷言兩個對象引用同一個對象

Assert.ByVal

斷言值是否滿足約束

Assert.Catch

斷言委托是否拋出特定異常

Assert.Contains

斷言對象是否在集合中

Assert.DoesNotThrow

斷言委托未引發異常

Assert.False

斷言條件是false

Assert.Fail

被其他斷言函數使用

Assert.Greater

斷言第一個值大於第二個值

Assert.GreaterOrEqual

斷言第一個值大於或者登錄第二個值

Assert.Ignore

調試時會出現異常,正常運行將會忽略,並返回警告

Assert.Inconclusive

調試時會出現異常,正常運行顯示無結論(未運行)

Assert.IsAssignableFrom

斷言某對象是某一類型

Assert.IsEmpty

斷言某個字符串變量為""或者String.Empty

Assert.IsFalse

斷言某條件為False

Assert.IsInstanceOf

斷言某對象為給定類型的實例對象

Assert.IsNaN

斷言某double變量是NaN(例如:Sqrt(-1))

Assert.IsNotAssignableFrom

斷言某對象不是某一類型

Assert.IsNotEmpty

斷言某個字符串變量不為"",也不為String.Empty

Assert.IsNotInstanceOf

斷言某對象不為給定類型的實例對象

Assert.IsNotNull

斷言某對象不為NULL

Assert.IsNull

斷言某對象為NULL

Assert.IsTrue

斷言某條件表達式為True

Assert.Less

斷言第一個decimal值小於第二個decimal值

Assert.LessOrEqual

斷言第一個decimal值小於等於第二個decimal值

Assert.Multiple

包裝多個斷言的代碼,即使失敗也會繼續執行,結果保存在代碼塊的結尾

Assert.Negative

斷言傳入浮點數為負數

Assert.NotNull

斷言傳入的對象不為NULL

Assert.NotZero

斷言傳入的整型不為0

Assert.Null

斷言傳入的對象為NULL

Assert.Pass

斷言將成功結果返回NUnit

Assert.Positive

斷言傳入浮點數為正數

Assert.ReferenceEquals

拋出異常,使用AreSame代替

Assert.Throws

斷言委托執行后拋出指定類型異常

Assert.That

斷言傳入的條件為True/斷言某個對象滿足約束表達式,例如:Assert.That<string>("3", Is.EqualTo("12"));

Assert.True

斷言傳入的條件為True

Assert.Warn

跳過斷言,並返回警告

Assert.Zero

斷言傳入的整型數值為Zero

3.4、外部依賴、樁對象(stub)、模擬對象(mock)

3.4.1、外部依賴

系統中代碼需要與之交互的對象,並且還無法進行人為控制,這些依賴被稱為外部依賴。比如:文件系統、線程、內存、時間等。

3.4.2、樁對象

可以將不可人為控制的外部依賴對象替換為可以進行人為控制的外部依賴對象。

例如:發表博客時,需檢查博客中是否含有敏感詞,如果存在敏感詞,則將其替換為"*",獲取敏感詞列表時需要通過文件系統讀取txt文件來獲取。

代碼清單如下:

using Microsoft.AspNetCore.Mvc;
using System.Threading.Tasks;
using XUnitDemo.IService;

namespace XUnitDemo.WebApi.Controller
{
    [Route("api/[controller]")]
    [ApiController]
    public class BlogController : ControllerBase
    {
        private readonly IBlogService _blogService;
        public BlogController(IBlogService blogService)
        {
            _blogService = blogService;
        }

        [HttpPut("security/content")]
        public async Task<string> GetSecurityBlog([FromBody]string originContent)
        {
            return await _blogService.GetSecurityBlogAsync(originContent);
        }
    }
}

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using XUnitDemo.IService;

namespace XUnitDemo.Service
{
    public class BlogService : IBlogService
    {
        public async Task<string> GetSecurityBlogAsync(string originContent)
        {
            if (string.IsNullOrWhiteSpace(originContent)) return originContent;

            var sensitiveList = new List<string> { "Political.txt", "YellowRelated.txt" };
            StringBuilder sbOriginContent = new StringBuilder(originContent);
            foreach (var item in sensitiveList)
            {
                var filePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "SensitiveWords", item);
                if (File.Exists(filePath))
                {
                    using FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Read);
                    using StreamReader sr = new StreamReader(fs);
                    var words = sr.ReadToEnd();
                    var wordList = words.Split("\r\n");
                    if (wordList.Any())
                    {
                        foreach (var word in wordList)
                        {
                            sbOriginContent.Replace(word, string.Join("", word.Select(s => "*")));
                        }
                    }
                }
            }

            return await Task.FromResult(sbOriginContent.ToString());
        }
    }
}

敏感詞列表

Political.txt

0000
1111
2222

YellowRelated.txt

3333
4444
5555

調用測試接口,顯示結果如下:

 

現在要使用單元測試對BlogService類進行單元測試,因為這個方法內部依賴了文件系統,現在需要我們使用樁對象來模擬文件系統,解除單元測試對文件系統的依賴,從而使我們的單元測試不受影響。

 

具體可以分為以下幾個步驟:

第一步:將文件系統相關代碼邏輯進行剝離,遷移到一個單獨的類FileManager,使我們的BlogService依賴於文件服務類的接口IFileManager(依賴倒置原則),而不要依賴具體的服務類。

IFileManager接口與具體的實現類FileManager

using System.IO;
using System.Threading.Tasks;

namespace XUnitDemo.Infrastucture.Interface
{
    public interface IFileManager
    {
        public Task<bool> IsExistsFileAsync(string filePath);
        public Task<string> GetStringFromTxt(string filePath);
    }
}


using System.IO;
using System.Threading.Tasks;
using XUnitDemo.Infrastucture.Interface;

namespace XUnitDemo.Infrastucture
{
    public class FileManager : IFileManager
    {
        public async Task<bool> IsExistsFileAsync(string filePath)
        {
            return await Task.FromResult(File.Exists(filePath));
        }

        public async Task<string> GetStringFromTxt(string filePath)
        {
            using FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Read);
            using StreamReader sr = new StreamReader(fs);
             return await sr.ReadToEndAsync();
        }
    }
}

 

第二步:對BlogService的進行重構,將IFileManager的通過依賴注入的方式注入進來。

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using XUnitDemo.Infrastucture.Interface;
using XUnitDemo.IService;

namespace XUnitDemo.Service
{
    public class BlogService : IBlogService
    {
        private readonly IFileManager _fileManager;
        public BlogService(IFileManager fileManager)
        {
            _fileManager = fileManager;
        }

        public async Task<string> GetSecurityBlogAsync(string originContent)
        {
            if (string.IsNullOrWhiteSpace(originContent)) return originContent;

            var sensitiveList = new List<string> { "Political.txt", "YellowRelated.txt" };
            StringBuilder sbOriginContent = new StringBuilder(originContent);
            foreach (var item in sensitiveList)
            {
                var filePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "SensitiveWords", item);
                if (await _fileManager.IsExistsFileAsync(filePath))
                {
                    var words = await _fileManager.GetStringFromTxtAsync(filePath);
                    var wordList = words.Split("\r\n");
                    if (wordList.Any())
                    {
                        foreach (var word in wordList)
                        {
                            sbOriginContent.Replace(word, string.Join("", word.Select(s => "*")));
                        }
                    }
                }
            }

            return await Task.FromResult(sbOriginContent.ToString());
        }
    }
}

 

第三步:新建一個實現IFileManager的樁類StubFileManager,來替換FileManager。

using NUnit.Framework;
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using XUnitDemo.Infrastucture.Interface;
using XUnitDemo.IService;
using XUnitDemo.Service;

namespace XUnitDemo.NUnitTests.Blog
{
    [TestFixture]
    [Category("BlogService相關測試")]
    public class BlogServiceUnitTest
    {
        private IBlogService _blogService;

        [SetUp]
        public void SetUp()
        {
            _blogService = new BlogService(new StubFileManager());
        }

        [Test]
        public async Task GetSecurityBlogAsync_OriginContent_ReturnSecurityContentAsync()
        {
            string originContent = "1111 2222 3333 4444 0000 5555 為了節能環保000,為了環境安全,請使用可降解垃圾袋。";
            string targetContent = "**** **** **** **** **** **** 為了節能環保000,為了環境安全,請使用可降解垃圾袋。";
            var result = await _blogService.GetSecurityBlogAsync(originContent);
            Assert.AreEqual(result, targetContent, $"{nameof(_blogService.GetSecurityBlogAsync)} 未能正確的將內容替換為合法內容");
        }

        [Test]
        public async Task GetSecurityBlogAsync_OriginContentIsEmpty_ReturnEmptyAsync()
        {
            string originContent = "";
            var result = await _blogService.GetSecurityBlogAsync(originContent);
            Assert.AreEqual(result, string.Empty, $"{nameof(_blogService.GetSecurityBlogAsync)} 方法參數為空時,返回值也需要為空");
        }

        [TearDown]
        public void TearDown()
        {
            _blogService = null;
        }
    }

    internal class StubFileManager : IFileManager
    {
        public async Task<string> GetStringFromTxtAsync(string filePath)
        {
            var sensitiveList = new List<string> { "Political.txt", "YellowRelated.txt" };
            if (Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "SensitiveWords", "Political.txt").Equals(filePath))
            {
                return await Task.FromResult("0000\r\n1111\r\n2222");
            }

            if (Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "SensitiveWords", "YellowRelated.txt").Equals(filePath))
            {
                return await Task.FromResult("3333\r\n4444\r\n5555");
            }
            return null;
        }

        public async Task<bool> IsExistsFileAsync(string filePath)
        {
            return await Task.FromResult(true);
        }
    }
}

顯示結果如下:

組名稱: BlogService相關測試

持續時間: 0:00:00.016

0 個測試失敗

0 個測試跳過

2 個測試通過

 

可以通過下圖更清楚的了解一下BlogService如何進行重構,以適應單元測試的

 

重構:在不影響已有功能而改變代碼設計的一種行為。

注意:BlogService的樁對象只用於一個測試中,相較於放在不同文件,可以將其與測試類放在同一個文件中,方便查找、閱讀、維護。

 

3.4.3、模擬對象

3.4.3.1、基於狀態的測試

也稱狀態驗證,是指在方法執行后,通過檢查被測系統或者其依賴項的狀態/變量值來檢測該方法的邏輯是否正確。

例如:我們要求敏感詞的文本文件列表在調用GetSecurityBlogAsync方法后都會被正確執行。

我們對BlogService進行一下改進,如下為代碼清單:

using NLog;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using XUnitDemo.Infrastucture.Interface;
using XUnitDemo.IService;
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("XUnitDemo.NUnitTests")]
namespace XUnitDemo.Service
{
    public class BlogService : IBlogService
    {
        private readonly IFileManager _fileManager;
        private ILogger _logger;
        private static readonly List<string> _sensitiveList;
        private int _effectiveSensitiveNum;

        static BlogService()
        {
            _sensitiveList = new List<string> { "Political.txt", "YellowRelated.txt" };
        }

        public BlogService(IFileManager fileManager)
        {
            _fileManager = fileManager;
        }

        //#if DEBUG
        //        public BlogService(ILogger logger)
        //        {
        //            _logger = logger;
        //        }
        //#endif

        //[Conditional("DEBUG")]
        //public void SetLogger(ILogger logger)
        //{
        //    _logger = logger;
        //}

        internal BlogService(ILogger logger)
        {
            _logger = logger;
        }

        public async Task<string> GetSecurityBlogAsync(string originContent)
        {
            if (string.IsNullOrWhiteSpace(originContent)) return originContent;
            _effectiveSensitiveNum = 0;

            StringBuilder sbOriginContent = new StringBuilder(originContent);
            foreach (var item in _sensitiveList)
            {
                var filePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "SensitiveWords", item);
                if (await _fileManager.IsExistsFileAsync(filePath))
                {
                    var words = await _fileManager.GetStringFromTxtAsync(filePath);
                    var wordList = words.Split("\r\n");
                    if (wordList.Any())
                    {
                        _effectiveSensitiveNum++;
                        foreach (var word in wordList)
                        {
                            sbOriginContent.Replace(word, string.Join("", word.Select(s => "*")));
                        }
                    }
                }
            }

            return await Task.FromResult(sbOriginContent.ToString());
        }

        public async Task<bool> IsAllSensitiveListIsEffectiveAsync()
        {
            if (_effectiveSensitiveNum == 0) return await Task.FromResult(false);

            return await Task.FromResult(_sensitiveList.Count == _effectiveSensitiveNum);
        }
    }
}

 

測試代碼如下:

[Test]
public async Task GetSecurityBlogAsync_OriginContent_ReturnAllSensitiveListIsEffectiveAsync()
{
    string originContent = "1111 2222 3333 4444 0000 5555 為了節能環保000,為了環境安全,請使用可降解垃圾袋。";
    var result = await _blogService.GetSecurityBlogAsync(originContent);
    Assert.AreEqual(true,await _blogService.IsAllSensitiveListIsEffectiveAsync(), $"{nameof(_blogService.GetSecurityBlogAsync)} 敏感詞文本列表與系統提供的數量不符");
}

測試結果如下:

 

3.4.3.2、交互測試

交互測試是用來測試一個對象如何向另一個對象傳遞消息,也即測試對象如何與其他對象進行交互。

注重交互而非結果。也可以把交互測試看作動作驅動測試,把基於狀態的測試看作結果驅動測試。動作驅動是指動作是否正確發生,比如一個對象調用另一個對象,但是沒有返回值,也沒有狀態發生。結果驅動是指測試某些最終的結果是否正確,比如說在調用方法時一個屬性值發生了變化。

模擬對象:模擬對象是一個偽對象,用來測試被測對象與模擬對象之間的交互是否正確發生,測試方法通常會斷言模擬對象相關數據來間接判斷交互是否預期執行,通常模擬對象會決定測試是否通過,一個測試方法通常只有一個模擬對象。

3.4.3.3、模擬對象與樁對象的區別

圖3.3.1展示了在使用樁對象時,斷言是針對被測類進行斷言的。樁對象會間接影響測試是否通過。

圖3.3.1

 

圖3.3.2展示了在使用模擬對象時,斷言是針對模擬對象進行斷言的。通過判斷模擬對象中的間接狀態來驗證被測類與模擬對象的交互是否正確執行,從而判斷測試是否通過。

圖3.3.2

 

我們來新增一個需求,模擬一個使用模擬對象的場景。

例如:博客服務中,如果發現博客內容含有敏感字符,則會向Logger服務發送一條錯誤日志,來記錄詳細信息。博客服務的代碼清單如下:

 

using NLog;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using XUnitDemo.Infrastucture.Interface;
using XUnitDemo.IService;
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("XUnitDemo.NUnitTests")]
namespace XUnitDemo.Service
{
    public class BlogService : IBlogService
    {
        private readonly IFileManager _fileManager;
        private readonly ILoggerService _loggerService;
        private ILogger _logger;
        private static readonly List<string> _sensitiveList;
        private int _effectiveSensitiveNum;


        static BlogService()
        {
            _sensitiveList = new List<string> { "Political.txt", "YellowRelated.txt" };
        }

        public BlogService(IFileManager fileManager, ILoggerService loggerService)
        {
            _fileManager = fileManager;
            _loggerService = loggerService;
        }

        public async Task<string> GetSecurityBlogAsync(string originContent)
        {
            if (string.IsNullOrWhiteSpace(originContent)) return originContent;
            _effectiveSensitiveNum = 0;

            StringBuilder sbOriginContent = new StringBuilder(originContent);
            foreach (var item in _sensitiveList)
            {
                var filePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "SensitiveWords", item);
                if (await _fileManager.IsExistsFileAsync(filePath))
                {
                    var words = await _fileManager.GetStringFromTxtAsync(filePath);
                    var wordList = words.Split("\r\n");
                    if (wordList.Any())
                    {
                        _effectiveSensitiveNum++;
                        foreach (var word in wordList)
                        {
                            sbOriginContent.Replace(word, string.Join("", word.Select(s => "*")));
                        }
                    }
                }
            }

            if (originContent != sbOriginContent.ToString())
            {
                _loggerService.LogError($"{nameof(BlogService)}-{nameof(GetSecurityBlogAsync)}-【{originContent}】含有敏感字符", null);
            }

            return await Task.FromResult(sbOriginContent.ToString());
        }

        public async Task<bool> IsAllSensitiveListIsEffectiveAsync()
        {
            if (_effectiveSensitiveNum == 0) return await Task.FromResult(false);

            return await Task.FromResult(_sensitiveList.Count == _effectiveSensitiveNum);
        }
    }
}

然后需要我們手動創建一個模擬對象,用來記錄錯誤信息,間接判斷博客服務是否正確的調用了Logger服務。如下是測試類的代碼清單:

using NUnit.Framework;
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using XUnitDemo.Infrastucture.Interface;
using XUnitDemo.IService;
using XUnitDemo.Service;

namespace XUnitDemo.NUnitTests.Blog
{
    [TestFixture]
    [Category("BlogService相關測試")]
    public class BlogServiceUnitTest
    {
        private IBlogService _blogService;
        private MockLoggerService _mockLoggerService;

        [SetUp]
        public void SetUp()
        {
            _mockLoggerService = new MockLoggerService();
            _blogService = new BlogService(new StubFileManager(), _mockLoggerService);
        }

        [Test]
        public async Task GetSecurityBlogAsync_OriginContent_ReturnSecurityContentAsync()
        {
            string originContent = "1111 2222 3333 4444 0000 5555 為了節能環保000,為了環境安全,請使用可降解垃圾袋。";
            string targetContent = "**** **** **** **** **** **** 為了節能環保000,為了環境安全,請使用可降解垃圾袋。";
            var result = await _blogService.GetSecurityBlogAsync(originContent);
            Assert.AreEqual(result, targetContent, $"{nameof(_blogService.GetSecurityBlogAsync)} 未能正確的將內容替換為合法內容");
        }

        [Test]
        public async Task GetSecurityBlogAsync_OriginContentIsEmpty_ReturnEmptyAsync()
        {
            string originContent = "";
            var result = await _blogService.GetSecurityBlogAsync(originContent);
            Assert.AreEqual(result, string.Empty, $"{nameof(_blogService.GetSecurityBlogAsync)} 方法參數為空時,返回值也需要為空");
        }

        [Test]
        public async Task GetSecurityBlogAsync_OriginContent_ReturnAllSensitiveListIsEffectiveAsync()
        {
            string originContent = "1111 2222 3333 4444 0000 5555 為了節能環保000,為了環境安全,請使用可降解垃圾袋。";
            var result = await _blogService.GetSecurityBlogAsync(originContent);
            Assert.AreEqual(true, await _blogService.IsAllSensitiveListIsEffectiveAsync(), $"{nameof(_blogService.GetSecurityBlogAsync)} 敏感詞文本列表與系統提供的數量不符");
        }

        [Test]
        public async Task GetSecurityBlogAsync_OriginContent_ErrorMessageIsSendedAsync()
        {
            string originContent = "1111 2222 3333 4444 0000 5555 為了節能環保000,為了環境安全,請使用可降解垃圾袋。";
            await _blogService.GetSecurityBlogAsync(originContent);
            Assert.AreEqual(_mockLoggerService.LastErrorMessage, $"【{originContent}】含有敏感字符","LoggerService未能正確記錄錯誤消息");
        }

        [TearDown]
        public void TearDown()
        {
            _blogService = null;
        }
    }

    internal class StubFileManager : IFileManager
    {
        public async Task<string> GetStringFromTxtAsync(string filePath)
        {
            var sensitiveList = new List<string> { "Political.txt", "YellowRelated.txt" };
            if (Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "SensitiveWords", "Political.txt").Equals(filePath))
            {
                return await Task.FromResult("0000\r\n1111\r\n2222");
            }

            if (Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "SensitiveWords", "YellowRelated.txt").Equals(filePath))
            {
                return await Task.FromResult("3333\r\n4444\r\n5555");
            }
            return null;
        }

        public async Task<bool> IsExistsFileAsync(string filePath)
        {
            return await Task.FromResult(true);
        }
    }

    internal class MockLoggerService : ILoggerService
    {
        public string LastErrorMessage { get; set; }
        public void LogError(string content, Exception ex)
        {
            LastErrorMessage = content;
        }
    }
}

運行單元測試結果如下:

 

我們再改變一下需求,模擬使用模擬對象與樁對象的場景。

例如:在使用Logger服務時如果拋出異常,則會向開發者發送一封郵件,來通知開發者系統有錯誤發生,此時我們需要添加郵件服務,代碼清單如下:

 

using System.Threading.Tasks;

namespace XUnitDemo.Infrastucture.Interface
{
    public interface IEmailService
    {
        Task SendEmailAsync(string to, string from, string subject, string body);
    }
}

using System.Threading.Tasks;
using XUnitDemo.Infrastucture.Interface;

namespace XUnitDemo.Infrastucture
{
    public class EmailService : IEmailService
    {
        public async Task SendEmailAsync(string to, string from, string subject, string body)
        {
            //發送郵件邏輯
            await Task.CompletedTask;
        }
    }
}

如果要使用單元測試測試此場景的話,我們需要兩個偽對象,一個用來模擬Logger服務拋出異常的服務,在這個場景,這個模擬Logger服務的類就不是模擬對象了,因為它需要返回一個異常,所以在這里它是一個樁對象,來返回異常信息,而對於Email服務,我們需要知道在Blog服務與Email服務的交互是否正確發生,所以在這里Email服務是模擬對象。相關代碼如下:

private IBlogService _blogService;
private MockLoggerService _mockLoggerService;
private StubLoggerService _stubLoggerService;
private MockEmailService _mockEmailService;

[SetUp]
public void SetUp()
{
    //_mockLoggerService = new MockLoggerService();
    //_blogService = new BlogService(new StubFileManager(), _mockLoggerService);

    _stubLoggerService = new StubLoggerService();
    _mockEmailService = new MockEmailService();
    _blogService = new BlogService(new StubFileManager(),      _stubLoggerService, _mockEmailService);
}    

[Test]
public async Task GetSecurityBlogAsync_LoggerServiceThrow_SendEmail()
{
    string error = "Custom Exception";
    _stubLoggerService.Exception = new Exception(error);

    string originContent = "1111 2222 3333 4444 0000 5555 為了節能環保000,為了環境安全,請使用可降解垃圾袋。";
    await _blogService.GetSecurityBlogAsync(originContent);
    Assert.Multiple(() =>
    {
        Assert.AreEqual("Harley", _mockEmailService.To);
        Assert.AreEqual("System", _mockEmailService.From);
        Assert.AreEqual("LoggerService拋出異常", _mockEmailService.Subject);
        Assert.AreEqual(error, _mockEmailService.Body);
    });
}
internal class StubLoggerService : ILoggerService
{
    public Exception Exception { get; set; }

    public void LogError(string content, Exception ex)
    {
        if (Exception is not null)
        {
            throw Exception;
        }
    }
}

internal class MockEmailService : IEmailService
{
    public string To { get; set; }
    public string From { get; set; }
    public string Subject { get; set; }
    public string Body { get; set; }
    public async Task SendEmailAsync(string to, string from, string subject, string body)
    {
        To = to;
        From = from;
        Subject = subject;
        Body = body;
        await Task.CompletedTask;
    }
}

運行單元測試結果如下:

 

圖3.3.3

 

圖3.3.3展示了在同時使用樁對象與模擬對象時,BlogService如何與其它對象進行交互,其中Logger服務的樁對象會模擬返回一個異常;電子郵件服務的模擬對象將會根據模擬對象記錄的相關狀態來檢查它是否被正確調用。

3.4.4、手寫模擬對象與樁對象

前面的例子都是使用我們手寫的樁對象或者模擬對象,這樣會費時費力,而且還可能會影響生產的代碼結構,會為了單元測試犧牲一些優秀的設計,它可能會有一以下一些缺點:

  • 費時費力
  • 如果類中有很多屬性,則模擬對象與樁對象很難寫
  • 模擬對象被調用多次,相關狀態很難保存
  • 樁對象與模擬對象很難復用

如果仍然要使用手寫模擬對象與樁對象,這里有一些可行性方案

因為通過構造函數注入對象還有一個明顯的缺點,如果當前被測類要依賴多個對象,那我們就需要創建多個構造函數,來滿足不用情況的測試,這樣會造成很大困擾,降低代碼的可讀性、可維護性。

我們需要保證不影響被測類生產代碼的前提下預留一些方式,來保證我們的被測類的依賴對象可以更方便的進行替換成樁對象/模擬對象。

3.4.4.1、通過屬性注入樁對象

服務類中含有IFileManager的公開屬性,不在構造函數中進行注入,當使用這個屬性時,再進行注入。

3.4.4.2、通過工廠方法創建樁對象

使用抽象工廠方式,來創建IFileManager的對象。

3.4.4.3、使用條件編譯、Conditional標簽(只能標記Class、Method),內部構造函數

使用[InternalsVisibleTo],可以將內部方法、成員對測試集可見

using NLog;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using XUnitDemo.Infrastucture.Interface;
using XUnitDemo.IService;
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("XUnitDemo.NUnitTests")]
namespace XUnitDemo.Service
{
    public class BlogService : IBlogService
    {
        private readonly IFileManager _fileManager;
        private ILogger _logger;

        public BlogService(IFileManager fileManager)
        {
            _fileManager = fileManager;
        }

        //#if DEBUG
        //        public BlogService(ILogger logger)
        //        {
        //            _logger = logger;
        //        }
        //#endif

        //[Conditional("DEBUG")]
        //public void SetLogger(ILogger logger)
        //{
        //    _logger = logger;
        //}

        internal BlogService(ILogger logger)
        {
            _logger = logger;
        }

        public async Task<string> GetSecurityBlogAsync(string originContent)
        {
            if (string.IsNullOrWhiteSpace(originContent)) return originContent;

            var sensitiveList = new List<string> { "Political.txt", "YellowRelated.txt" };
            StringBuilder sbOriginContent = new StringBuilder(originContent);
            foreach (var item in sensitiveList)
            {
                var filePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "SensitiveWords", item);
                if (await _fileManager.IsExistsFileAsync(filePath))
                {
                    var words = await _fileManager.GetStringFromTxtAsync(filePath);
                    var wordList = words.Split("\r\n");
                    if (wordList.Any())
                    {
                        foreach (var word in wordList)
                        {
                            sbOriginContent.Replace(word, string.Join("", word.Select(s => "*")));
                        }
                    }
                }
            }

            return await Task.FromResult(sbOriginContent.ToString());
        }
    }
}

四、使用隔離框架

除了使用手動創建樁對象/模擬對象,為了提高單元測試效率,一些開源的隔離框架也會幫助創建樁對象/模擬對象,它們會在運行時創建和配置樁對象/模擬對象,接下來我們使用Moq框架來進行演示,如何使用隔離框架,來進行單元測試,並提高單元測試的效率。

4.1、什么是隔離框架?

隔離框架(Isolation Framework):是可以非常方便的創建樁對象/模擬對象,並且提供了相關API方法幫助我們進行高效的單元測試。

 

現在我們使用隔離框架Moq(是一款開源的隔離框架,許可協議BSD 3-Clause License),將重寫BlogServiceUnitTest中的測試方法

首先,引用Moq的Nuget包

 

新建一個測試類為MoqBlogServiceUnitTest,代碼清單如下:

using Moq;
using NUnit.Framework;
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using XUnitDemo.Infrastucture.Interface;
using XUnitDemo.IService;
using XUnitDemo.Service;

namespace XUnitDemo.NUnitTests.Blog
{
    [Category("*使用隔離框架的測試*")]
    [TestFixture]
    public class MoqBlogServiceUnitTest
    {
        private List<string> SendEmailArgsList = new List<string>();
        private IFileManager _fileManager;
        private ILoggerService _loggerService;
        private IEmailService _emailService;
        private IBlogService _blogService;

        [SetUp]
        public void SetUp()
        {
            //FileManager stub object
            //Return the fake result
            var fileManager = new Mock<IFileManager>();
            fileManager.Setup(f => f.GetStringFromTxtAsync(It.Is<string>(s => s == Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "SensitiveWords", "Political.txt")))).Returns(Task.FromResult("0000\r\n1111\r\n2222"));
            fileManager.Setup(f => f.GetStringFromTxtAsync(It.Is<string>(s => s == Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "SensitiveWords", "YellowRelated.txt")))).Returns(Task.FromResult("3333\r\n4444\r\n5555"));
            fileManager.Setup(f => f.IsExistsFileAsync(It.IsAny<string>())).Returns(Task.FromResult(true));
            _fileManager = fileManager.Object;
            //LoggerService stub object
            //Throw an exception
            var loggerService = new Mock<ILoggerService>();
            loggerService.Setup(s => s.LogError(It.IsAny<string>(), It.IsAny<Exception>())).Throws(new Exception("Custom Exception"));
            _loggerService = loggerService.Object;
            //EmailService mock object
            var emailService = new Mock<IEmailService>();
            emailService
                .Setup(f => f.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
                .Callback(() => SendEmailArgsList.Clear())
                .Returns(Task.CompletedTask)
                .Callback<string, string, string, string>((arg1, arg2, arg3, arg4) =>
                {
                    SendEmailArgsList.Add(arg1); 
                    SendEmailArgsList.Add(arg2); 
                    SendEmailArgsList.Add(arg3); 
                    SendEmailArgsList.Add(arg4);
                });
            _emailService = emailService.Object;

            _blogService = new BlogService(_fileManager, _loggerService, _emailService);
        }

        [Test]
        public async Task GetSecurityBlogAsync_OriginContent_ReturnSecurityContentAsync()
        {
            //Arrange
            string originContent = "1111 2222 3333 4444 0000 5555 為了節能環保000,為了環境安全,請使用可降解垃圾袋。";
            string targetContent = "**** **** **** **** **** **** 為了節能環保000,為了環境安全,請使用可降解垃圾袋。";

            //Act
            var blogService = new BlogService(_fileManager, _loggerService, _emailService);
            var result = await _blogService.GetSecurityBlogAsync(originContent);

            //Assert
            Assert.Multiple(() =>
            {
                Assert.AreEqual(result, targetContent, "GetSecurityBlogAsync 未能正確的將內容替換為合法內容");
                CollectionAssert.AreEqual(SendEmailArgsList, new List<string> { "Harley", "System", "LoggerService拋出異常", "Custom Exception" }, "GetSecurityBlogAsync記錄錯誤日志時出現錯誤,未能正確發送郵件給開發者");
            });

            await Task.CompletedTask;
        }

        [Test]
        public async Task GetSecurityBlogAsync_OriginContentIsEmpty_ReturnEmptyAsync()
        {
            string originContent = "";
            var result = await _blogService.GetSecurityBlogAsync(originContent);
            Assert.AreEqual(result, string.Empty, $"{nameof(_blogService.GetSecurityBlogAsync)} 方法參數為空時,返回值也需要為空");
        }

        [Test]
        public async Task GetSecurityBlogAsync_OriginContent_ReturnAllSensitiveListIsEffectiveAsync()
        {
            string originContent = "1111 2222 3333 4444 0000 5555 為了節能環保000,為了環境安全,請使用可降解垃圾袋。";
            var result = await _blogService.GetSecurityBlogAsync(originContent);
            Assert.AreEqual(true, await _blogService.IsAllSensitiveListIsEffectiveAsync(), $"{nameof(_blogService.GetSecurityBlogAsync)} 敏感詞文本列表與系統提供的數量不符");
        }

        [Test]
        public async Task GetSecurityBlogAsync_OriginContent_ErrorMessageIsSendedAsync()
        {
            string originContent = "1111 2222 3333 4444 0000 5555 為了節能環保000,為了環境安全,請使用可降解垃圾袋。";
            var loggerService = new Mock<ILoggerService>();
            loggerService.Setup(s => s.LogError(It.IsAny<string>(), It.IsAny<Exception>())).Verifiable();
            var blogService = new BlogService(_fileManager, loggerService.Object, _emailService);
            await blogService.GetSecurityBlogAsync(originContent);
            loggerService.Verify(s => s.LogError(It.IsAny<string>(), It.IsAny<Exception>()), "LoggerService未能正確記錄錯誤消息");
        }

        [Test]
        public async Task GetSecurityBlogAsync_LoggerServiceThrow_SendEmail()
        {
            string originContent = "1111 2222 3333 4444 0000 5555 為了節能環保000,為了環境安全,請使用可降解垃圾袋。";
            await _blogService.GetSecurityBlogAsync(originContent);
            CollectionAssert.AreEqual(SendEmailArgsList, new List<string> { "Harley", "System", "LoggerService拋出異常", "Custom Exception" }, "GetSecurityBlogAsync記錄錯誤日志時出現錯誤,未能正確發送郵件給開發者");
        }

        [TearDown]
        public void TearDown()
        { }
    }
}

可以看見,使用隔離框架后,我們節省了手動創建樁對象/模擬對象的時間,並節省了大量的工作量,同使不會對生產代碼優秀的設計產生影響,通過Mock的鏈式調用可以模擬返回值/驗證方法是否被調用,可以很方便的進行了單元測試。下面是單元測試的結果:

雖然我們看到手寫樁對象/模擬對象的單元測試運行更快,但是相比於投入產出比,總體來說,使用Moq隔離框架的方式,性價比會更高。

4.2、Moq隔離框架使用詳解

4.2.1、泛型類Mock的構造函數

public Mock();
public Mock(params object[] args);
public Mock(MockBehavior behavior);
public Mock(MockBehavior behavior, params object[] args);
public Mock(Expression<Func<T>> newExpression, MockBehavior behavior = MockBehavior.Loose);

Mock實例化時可以用於類或者接口,當類構造函數需要傳遞參數時,使用帶args參數的構造函數可以實現。示例如下:

public class Book
{
    public string BookName;
    public double Price;
    public Book(string bookName)
    {
        BookName = bookName;
    }

    public Book(string bookName, double price)
    {
        BookName = bookName;
        Price = price;
    }
}

[Test]
public void Constructor_WithParams_CheckProperty()
{
    var mockBook = new Mock<Book>("演進式架構");
    var mockBook1 = new Mock<Book>("演進式架構", 59);

    Assert.Multiple(()=> {
        Assert.AreEqual("演進式架構", mockBook.Object.BookName);
        Assert.AreEqual(0, mockBook.Object.Price);

        Assert.AreEqual("演進式架構", mockBook1.Object.BookName);
        Assert.AreEqual(59, mockBook1.Object.Price);
    });
}

 

MockBehavior有三種值可選:

Strict:選擇當前值時,當泛型類型指定為接口時,Mock對象必須調用Setup進行設置,否則會拋出異常,言外之意,對接口進行Mock時使用了Strict,則這個接口必須實現(進行Setup),否則會拋出異常

Loose:選擇當前值時,Mock對象無須調用Setup,不會拋出異常

Default:默認值為Loose

 

示例如下:

public interface IBookService
{
    public bool AddBook(string bookName, double price);
}

public class BookService : IBookService
{
    public bool AddBook(string bookName, double price)
    {
        return true;
    }
}

/// <summary>
/// Moq.MockException : IBookService.AddBook("演進式架構", 59) invocation failed with mock behavior Strict.
/// All invocations on the mock must have a corresponding setup.
/// </summary>
[Category("*1、泛型類Mock的構造函數*")]
[Test]
public void Constructor_WithInterfaceMockBehaviorStrict_ThrowException()
{
    var mockBookService = new Mock<IBookService>(MockBehavior.Strict);
    mockBookService.Object.AddBook("演進式架構", 59);
}

/// <summary>
/// 無異常拋出
/// </summary>
[Category("*1、泛型類Mock的構造函數*")]
[Test]
public void Constructor_WithInterfaceMockBehaviorStrictAndSetup_NotThrowException()
{
    var mockBookService = new Mock<IBookService>(MockBehavior.Strict);
    mockBookService.Setup(s => s.AddBook("演進式架構", 59)).Returns(true);
    mockBookService.Object.AddBook("演進式架構", 59);
}

/// <summary>
/// 無異常拋出
/// </summary>
[Category("*1、泛型類Mock的構造函數*")]
[Test]
public void Constructor_WithClassMockBehaviorStrict_ThrowException()
{
    var mockBookService = new Mock<BookService>(MockBehavior.Strict);
    mockBookService.Object.AddBook("演進式架構", 59);
}

/// <summary>
/// 無異常拋出
/// </summary>
[Category("*1、泛型類Mock的構造函數*")]
[Test]
public void Constructor_WithClassMockBehaviorLoose_NotThrowException()
{
    var mockBookService = new Mock<BookService>(MockBehavior.Loose);
    mockBookService.Object.AddBook("演進式架構", 59);
}

 

通過newExpression 表達式目錄樹來創建一個被Mock的對象,示例如下

[Category("*1、泛型類Mock的構造函數*")]
[Test]
public void Constructor_WithNewExpression_ReturnMockBookServiceObject()
{
    var mockBookService = new Mock<IBookService>(() => new BookService());
    mockBookService.Object.AddBook("演進式架構", 59);

    var mockBook = new Mock<Book>(() => new Book("演進式架構", 59));
    Assert.Multiple(()=> {
        Assert.AreEqual("演進式架構", mockBook.Object.BookName);
        Assert.AreEqual(59, mockBook.Object.Price);
    });
}

4.2.2、Mock的Setup方法

場景一:設置期望返回的值。

只有使用"演進式架構"這個參數時,AddProduct才會返回true.

public interface IProductService
{
    public bool AddProduct(string name);
}

public abstract class AbstractProductService : IProductService
{
    public abstract bool AddProduct(string name);
}

public class ProductService : AbstractProductService
{
    public override bool AddProduct(string name)
    {
        return DateTime.Now.Hour > 10;
    }
}

[Category("*2、Mock的Setup用法*")]
[Test]
public void AddProduct_WithProductName_ReturnTrue()
{
    var mockProductService = new Mock<IProductService>();
    mockProductService.Setup(s => s.AddProduct("演進式架構")).Returns(true);

    var mockProductService1 = new Mock<AbstractProductService>();
    mockProductService1.Setup(s => s.AddProduct("演進式架構")).Returns(true);

    var mockProductService2 = new Mock<ProductService>();
    mockProductService2.Setup(s => s.AddProduct("演進式架構")).Returns(true);

    Assert.Multiple(()=> {
        Assert.IsTrue(mockProductService.Object.AddProduct("演進式架構"));
        Assert.IsFalse(mockProductService.Object.AddProduct("演進式架構_59"));

        Assert.IsTrue(mockProductService1.Object.AddProduct("演進式架構"));
        Assert.IsFalse(mockProductService1.Object.AddProduct("演進式架構_59"));

        Assert.IsTrue(mockProductService2.Object.AddProduct("演進式架構"));
        Assert.IsFalse(mockProductService2.Object.AddProduct("演進式架構_59"));
    });
}

 

場景二:設置拋出異常。

只有使用Guid.Empty參數時,DeleteProduct才會拋出異常

public interface IProductService
{
    public bool AddProduct(string name);
    public bool DeleteProduct(Guid id);
}

[Category("*2、Mock的Setup用法*")]
[Test]
public void DeleteProduct_WithGuidEmpty_ThrowArgumentException()
{
    var mockProductService = new Mock<IProductService>();
    mockProductService.Setup(s => s.DeleteProduct(Guid.Empty)).Throws(new ArgumentException("id"));

    Assert.Multiple(()=> {
        Assert.Throws<ArgumentException>(() => mockProductService.Object.DeleteProduct(Guid.Empty));
        Assert.DoesNotThrow(() => mockProductService.Object.DeleteProduct(Guid.NewGuid()));
    });
}

 

場景三:設置返回值,並使用參數計算返回值。

public interface IProductService
{
    public bool AddProduct(string name);
    public bool DeleteProduct(Guid id);
    public string GetProudctName(Guid id);
}

[Category("*2、Mock的Setup用法*")]
[Test]
public void GetProudctName_WithGuid_ReturnParamAsResult()
{
    var mockProductService = new Mock<IProductService>();
    var id=Guid.Empty;
    mockProductService.Setup(s => s.GetProudctName(It.IsAny<Guid>()))
        .Returns<Guid>(s=>s.ToString()+"123");

    var a = mockProductService.Object.GetProudctName(Guid.Empty);
    Assert.AreEqual(a, $"{Guid.Empty.ToString()}123");
}

 

場景四:設置方法的回調,並在回調用使用方法參數,在調用方法前/后進行回調,相當於AOP(面向切面)。

[Category("*2、Mock的Setup用法*")]
[Test]
public void GetProudctName_WithGuid_ReturnEmptyWithAOP()
{
    var mockProductService = new Mock<IProductService>();
    var id = Guid.Empty;
    mockProductService.Setup(s => s.GetProudctName(It.IsAny<Guid>()))
        .Callback<Guid>(s => System.Diagnostics.Debug.WriteLine($"Before Invoke GetProudctName"))
        .Returns<Guid>(s => string.Empty)
        .Callback<Guid>(s => System.Diagnostics.Debug.WriteLine($"After Invoke GetProudctName"));

    mockProductService.Object.GetProudctName(Guid.Empty);
    Assert.Pass();
}

 

場景五:Setup與It靜態類一起使用,在配置方法時進行方法參數的約束。

It.Is<TValue>():參數滿足匿名函數的邏輯

It.IsInRange<TValue>():參數必須在范圍區間

It.IsRegex():參數必須匹配正則表達式

It.IsAny<TValue>():參數可以是任意值

It.IsIn<TValue>():參數必須在集合中

It.IsNotIn<TValue>():參數不在集合中

It.IsNotNull<TValue>():參數不為NULL

 

示例一:

mockProductService.Setup(s => s.GetProductList(It.Is<string>(a => a == "Book"), It.IsInRange<double>(20, 60, Moq.Range.Inclusive)))
        .Returns(new List<ProductModel> { item });

當使用mockProductService.Object調用GetProductList時,只有在bookType滿足It.Is<string>(a=>a=="Book")里面的匿名函數時,price范圍在[20,60]之間,才會返回正確的結果,代碼清單如下:

public interface IProductService
{
    public bool AddProduct(string name);
    public bool DeleteProduct(Guid id);
    public string GetProudctName(Guid id);
    public IEnumerable<ProductModel> GetProductList(string productType, double price);
}

[Category("*2、Mock的Setup用法*")]
[Test]
public void GetProductList_WithProductTypePriceInRange_ReturnProductList()
{
    var mockProductService = new Mock<IProductService>();
    var item = new ProductModel()
    {
        ProductId = Guid.NewGuid(),
        ProductName = "演進式架構",
        ProductPrice = 59
    };
    mockProductService.Setup(s => s.GetProductList(It.Is<string>(a => a == "Book"), It.IsInRange<double>(20, 60, Moq.Range.Inclusive)))
        .Returns(new List<ProductModel> { item });

    var productService = mockProductService.Object;
    var result = productService.GetProductList("Book", 59);
    var result1 = productService.GetProductList("Books", 59);
    var result2 = productService.GetProductList("Book", 5);
    Assert.Multiple(() =>
    {
        CollectionAssert.AreEqual(new List<ProductModel>() { item }, result);
        CollectionAssert.AreEqual(new List<ProductModel>() { item }, result1, "param:bookType=Books,price=59返回的result1與預期的不相符");
        CollectionAssert.AreEqual(new List<ProductModel>() { item }, result2, "param:bookType=Book,price=5返回的result2與預期的不相符");
    });
}

 

示例二:

mockProductService.Setup(s => s.GetProductList(It.IsRegex("^[1-9a-z_]{1,10}$", System.Text.RegularExpressions.RegexOptions.IgnoreCase), It.IsAny<double>()))
                .Returns(new List<ProductModel> { item });

當使用mockProductService.Object調用GetProductList時,只有在bookType滿足^[1-9a-z_]{1,10}$正則表達式時,price可以是任意值,才會返回正確的結果,代碼清單如下:

[Category("*2、Mock的Setup用法*")]
[Test]
public void GetProductList_WithProductTypeRegexPriceIsAny_ReturnProductList()
{
    var mockProductService = new Mock<IProductService>();
    var item = new ProductModel()
    {
        ProductId = Guid.NewGuid(),
        ProductName = "演進式架構",
        ProductPrice = 59
    };
    mockProductService.Setup(s => s.GetProductList(It.IsRegex("^[1-9a-z_]{1,10}$", System.Text.RegularExpressions.RegexOptions.IgnoreCase), It.IsAny<double>()))
        .Returns(new List<ProductModel> { item });

    var productService = mockProductService.Object;
    var result = productService.GetProductList("Book_123", 59);
    var result1 = productService.GetProductList("BookBookBookBookBook", 123);
    var result2 = productService.GetProductList("書籍", 5);
    Assert.Multiple(() =>
    {
        CollectionAssert.AreEqual(new List<ProductModel>() { item }, result);
        CollectionAssert.AreEqual(new List<ProductModel>() { item }, result1, "param:bookType=BookBookBookBookBook,price=123返回的result1與預期的不相符");
        CollectionAssert.AreEqual(new List<ProductModel>() { item }, result2, "param:bookType=書籍,price=5返回的result2與預期的不相符");
    });
}

 

場景五:設置當調用方法時,將會觸發事件。

當調用如下方法時

mockProductService.Object.Repaire(id);

會調用 ProductServiceHandler中構造函數注冊的Invoke方法,代碼清單如下:

public interface IProductService
{
    public event EventHandler MyHandlerEvent;
    public void Repaire(Guid id);
}

public abstract class AbstractProductService : IProductService
{
    public event EventHandler MyHandlerEvent;
    public void Repaire(Guid id)
    {}
}

public class MyEventArgs : EventArgs {
    public Guid Id { get; set; }
    public MyEventArgs(Guid id)
    {
        Id = id;
    }
}

public class ProductServiceHandler
{
    private IProductService _productService;
    public ProductServiceHandler(IProductService productService)
    {
        _productService = productService;
        _productService.MyHandlerEvent += Invoke;
    }

    public void Invoke(object sender, EventArgs args)
    {
        System.Diagnostics.Debug.WriteLine($"This is {nameof(ProductServiceHandler)} {nameof(Invoke)} Id={((MyEventArgs)args).Id}");
    }
}

[Category("*2、Mock的Setup用法*")]
[Test]
public void Repaire_WithIdIsAny_TriggerMyHandlerEvent()
{
    var mockProductService = new Mock<IProductService>();
    var id = Guid.NewGuid();
    mockProductService.Setup(s => s.Repaire(It.IsAny<Guid>())).Raises<Guid>((s) =>
    {
        s.MyHandlerEvent += null;//這個注冊的委托不會被調用,實際上是調用ProductServiceHandler中的Invoke方法
    }, s => new MyEventArgs(id));

    var myHandler = new ProductServiceHandler(mockProductService.Object);
    System.Diagnostics.Debug.WriteLine($"This is {nameof(Repaire_WithIdIsAny_TriggerMyHandlerEvent)} Id={id}");
    mockProductService.Object.Repaire(id);
}

 

 

 

場景六:與Verifiable,Verify一起使用,驗證方法是否被調用/被調用了幾次

其中Verifiable是標記當前設置需要執行Verify進行驗證,在進行Verify時可以使用Mock實例的Verify,也可以使用Mock.Verify(只能驗證是否被調用,無法驗證執行具體次數)進行驗證方法是否被調用,Mock.VerifyAll是指Setup之后無論是否標記Verifiable,都會進行驗證。

示例一:使用mockProductService的Verify進行驗證,代碼清單如下:

[Category("*2、Mock的Setup用法*")]
[Test]
public void GetProductList_WithProductTypePrice_VerifyTwice()
{
    var mockProductService = new Mock<IProductService>();
    mockProductService.Setup(s => s.GetProductList(It.IsAny<string>(), It.IsAny<double>()))
        .Returns(new List<ProductModel>())
        .Verifiable();
    var result = mockProductService.Object.GetProductList("Book", 59);

    mockProductService.Verify(s => s.GetProductList(It.IsAny<string>(), It.IsAny<double>()), Times.AtLeast(2), "GetProductList 沒有按照預期執行2次");
}

修改一下,讓方法執行兩次,結果如下

var result = mockProductService.Object.GetProductList("Book", 59);
result = mockProductService.Object.GetProductList("Books", 5);

 

示例二:使用Mock.Verify靜態方法進行驗證,Mock.Verify參數如下,可以傳遞多個Mock實例對象

 

代碼清單如下:

[Category("*2、Mock的Setup用法*")]
[Test]
public void GetProductList_WithProductTypePrice_MockVerify()
{
    var mockProductService = new Mock<IProductService>();
    mockProductService.Setup(s => s.GetProductList(It.IsAny<string>(), It.IsAny<double>()))
        .Returns(new List<ProductModel>())
        .Verifiable("GetProductList沒有按照預期執行一次");
    //var result = mockProductService.Object.GetProductList("Book", 59);
    Mock.Verify(mockProductService);
}

修改一下,將注釋取消

var result = mockProductService.Object.GetProductList("Book", 59);

 

示例三:使用Mock.VerifyAll靜態方法進行驗證,Mock.VerifyAll參數如下

代碼清單如下:

[Category("*2、Mock的Setup用法*")]
[Test]
public void GetProductList_WithProductTypePrice_MockVerifyAll()
{
    var mockProductService = new Mock<IProductService>();
    mockProductService.Setup(s => s.GetProductList(It.IsAny<string>(), It.IsAny<double>()))
        .Returns(new List<ProductModel>());

    var mockAbstractProductService = new Mock<AbstractProductService>();
    mockAbstractProductService.Setup(s => s.GetProductList(It.IsAny<string>(), It.IsAny<double>()))
        .Returns(new List<ProductModel>());

    mockProductService.Object.GetProductList("Book", 59);
    //mockAbstractProductService.Object.GetProductList("Book", 59);
    Mock.VerifyAll(mockProductService, mockAbstractProductService);
}

取消注釋

mockAbstractProductService.Object.GetProductList("Book", 59);

說明在使用Mock.VerifyAll時,無論是否標記Verifiable,Mock實例的Setup都會被驗證。

4.2.3、Mock中的CallBase用法

實例化Mock對象時,使用屬性初始化可以指定CallBase是否為True(默認為False),來決定當使用Object調用方法時是否調用基類的方法

示例一:當Mock接口類型時(MockBehavior為Default),指定CallBase為True時,如果未對方法返回值進行Setup,則方法則返回NULL,如果對方法進行返回值設置后,則方法則返回預期設置的值,代碼如下:

[Category("*3、Mock的CallBase用法*")]
[Test]
public void GetProductName_WithIProductServiceAnyGuidCallBaseIsTrue_ReturnEmpty()
{
    var mockProductService = new Mock<IProductService>() { CallBase = true };
    //mockProductService.Setup(s => s.GetProudctName(It.IsAny<Guid>())).Returns(string.Empty);
    var result = mockProductService.Object.GetProudctName(Guid.NewGuid());
    Assert.AreEqual(string.Empty, result);
}

取消注釋后

 

示例二:當Mock抽象類時,指定CallBase為True,如果不對方法返回值進行設置,如果基類方法已經實現(虛方法),此時調用的將會是基類的方法,如果對方法進行設置,則方法返回預期設置的值,代碼如下:

public abstract class AbstractProductService : IProductService
{
  public virtual string GetProudctName(Guid id)
  {
      return "演進式架構";
  }
}

[Category("*3、Mock的CallBase用法*")]
[Test]
public void GetProductName_WithAbstractProductServiceAnyGuidCallBaseIsTrue_ReturnBaseResult()
{
    var mockProductService = new Mock<AbstractProductService>() { CallBase = true };
    //mockProductService.Setup(s => s.GetProudctName(It.IsAny<Guid>())).Returns(string.Empty);
    var result = mockProductService.Object.GetProudctName(Guid.NewGuid());
    Assert.AreEqual("演進式架構", result);
}

取消注釋后,將會對GetProductName方法進行設置,則此時將返回string.Empty

 

示例三:當Mock普通類(非抽象類,非抽象方法時),此時方法將不能被Setup,如果進行Setup將會拋出異常,提示此方法無法被Override,Mock時無論CallBase=True/False,則在使用Object調用方法時,都將會調用基類的方法,代碼清單如下:

public class ProductService : AbstractProductService
{
  public string ToCurrentString()
  {
      return $"{ nameof(ProductService)}_{nameof(ToString)}";
  }
}

[Category("*3、Mock的CallBase用法*")]
[Test]
public void ToCurrentString_WithProductServiceCallBaseIsTrue_ReturnBaseResult()
{
    var mockProductService = new Mock<ProductService>() { CallBase = true };
    mockProductService.Setup(s => s.ToCurrentString()).Returns(string.Empty);
    var result = mockProductService.Object.ToCurrentString();
    Assert.AreEqual("ProductService_ToString", result);
}

運行此測試將會拋出異常

注釋mockProductService.Setup,結果如下

取消CallBase的初始化,結果也如上所示。

 

4.2.4、Mock的DefaultValue屬性

這個屬性的作用是,當我們使用Mock模擬接口/類/抽象類的時候,選擇是否進行對類中的屬性自動進行遞歸的Mock,如下我們在Mock IRolePermissionService接口時,初始化Mock時,指定DefaultValue為DefaultValue.Mock時,會自動對IPermissionRepository與IRoleRepository進行Mock,其中更深入的一層Repository中的DbContext也會進行Mock,代碼清單如下:

public interface IRolePermissionService
{
  public IRoleRepository RoleRepository { get; set; }
  public IPermissionRepository PermissionRepository { get; set; }
  public IEnumerable<string> GetPermissionList(Guid roleId);
}
public interface IRoleRepository
{
  public IDbContext DbContext { get; set; }
  public string GetRoleName(Guid roleId);
}

public interface IPermissionRepository
{
  public IDbContext DbContext { get; set; }
  public string GetPermissionName(Guid permissionId);
}

public interface IDbContext { 

}

[Category("*4、Mock的DefaultValue用法*")]
[Test]
public void MockDefaultValue_WithDefaultValueMock_ReturnExpect()
{
    var mockRolePermissionService = new Mock<IRolePermissionService>() { DefaultValue = DefaultValue.Mock };
    var roleRepos = mockRolePermissionService.Object.RoleRepository;
    var permissionRepos = mockRolePermissionService.Object.PermissionRepository;
    Assert.Multiple(()=> {
        Assert.NotNull(roleRepos);
        Assert.NotNull(permissionRepos);
        Assert.NotNull(roleRepos.DbContext);
        Assert.NotNull(permissionRepos.DbContext);
    });
}

如果指定DefaultValue為DefaultValue.Empty時,則不會對接口進行遞歸Mock,結果如下所示:

也可以通過Mock.Get()方法獲取屬性的Mock對象,然后對其進行Setup,代碼清單如下:

[Category("*4、Mock的DefaultValue用法*")]
[Test]
public void GetRoleName_WidthRolePermissionServiceDefaultValueMock_ReturnExpect()
{
    var mockRolePermissionService = new Mock<IRolePermissionService>() { DefaultValue = DefaultValue.Mock };
    var roleRepos = mockRolePermissionService.Object.RoleRepository;
    var mockRoleRepos = Mock.Get(roleRepos);
    mockRoleRepos.Setup(s => s.GetRoleName(It.IsAny<Guid>())).Returns("Admin");
    var result = mockRolePermissionService.Object.RoleRepository.GetRoleName(Guid.NewGuid());

    Assert.AreEqual("Admin", result);
}

 

4.2.5、Mock的SetupProperty與SetupAllProperties

當我們Mock一個類的時候,如果這個類的屬性需要設置的時候,我們可以通過

SetupProperty進行設置屬性的值,或者對這個屬性進行跟蹤,只有跟蹤后,才可以通過Object.[PropertyName]=Value進行賦值,如果不進行跟蹤,則無法對屬性進行賦值

比如如下代碼:

public interface ICar
{
    public IEnumerable<IWheel> Wheels { get; set; }
    public string CarBrand { get; set; }
    public string CarModel { get; set; }
}
public interface IWheel
{
    public string WheelHub { get; set; }
    public string WheelTyre { get; set; }
    public string WheelTyreTube { get; set; }
}

public class Car : ICar
{
    public IEnumerable<IWheel> Wheels { get; set; }
    public string CarBrand { get; set; }
    public string CarModel { get; set; }
}

public class CarWheel : IWheel
{
    public string WheelHub { get; set; }
    public string WheelTyre { get; set; }
    public string WheelTyreTube { get; set; }
}

[Category("*5、SetupProperty與SetupAllProperties的用法*")]
[Test]
public void CheckProperty_WithSetupProperty_ShouldPass()
{
    var mockCar = new Mock<ICar>();
    //mockCar.SetupProperty(s => s.CarBrand).SetupProperty(s => s.CarModel);
    //mockCar.SetupProperty(s => s.CarBrand, "一汽大眾")
    //    .SetupProperty(s => s.CarModel, "七座SUV");

    mockCar.Object.CarBrand = "一汽大眾";
    mockCar.Object.CarModel = "七座SUV";
    Assert.Multiple(() =>
    {
        Assert.AreEqual("七座SUV", mockCar.Object.CarModel);
        Assert.AreEqual("一汽大眾", mockCar.Object.CarBrand);
    });
}

實際上CarModel、CarBrand值還為NULL,賦值沒有生效,進行如下修改,將如下代碼取消注釋,這句代碼的意義在於可以跟蹤CardBrand與CardModel屬性,使其具有屬性行為(可以進行Set賦值)

mockCar.SetupProperty(s => s.CarBrand).SetupProperty(s => s.CarModel);

也可以通過如下代碼,直接對屬性進行賦值操作,也可以起到相同效果

mockCar.SetupProperty(s => s.CarBrand, "一汽大眾")
        .SetupProperty(s => s.CarModel, "七座SUV");

mockCar.Object.CarBrand = "上汽大眾";
mockCar.Object.CarModel = "五座SUV";

也可以使用SetAllProperties進行跟蹤所有屬性,所有屬性都可以進行Set賦值操作,代碼如下

[Category("*5、SetupProperty與SetupAllProperties的用法*")]
[Test]
public void CheckProperty_WithSetupAllProperties_ShouldPass()
{
    var mockCar = new Mock<ICar>();
    mockCar.SetupAllProperties();

    mockCar.Object.CarBrand = "上汽大眾";
    mockCar.Object.CarModel = "五座SUV";
    Assert.Multiple(() =>
    {
        Assert.AreEqual("七座SUV", mockCar.Object.CarModel);
        Assert.AreEqual("一汽大眾", mockCar.Object.CarBrand);
    });
}

可以使用SetupSet方式設置預期,使用VerifySet的方式驗證預期是否被正確執行,只有預期至少執行一次后,VerifySet才可以通過驗證,比如下面代碼:

[Category("*5、SetupProperty與SetupAllProperties的用法*")]
[Test]
public void CheckProperty_WithSetupSetVerifySet_ShouldPass()
{
    var mockCar = new Mock<ICar>();
    mockCar.SetupSet(s => s.CarBrand ="上汽大眾");
    mockCar.SetupSet(s => s.CarModel = "五座SUV");

    //mockCar.Object.CarBrand = "上汽大眾";
    //mockCar.Object.CarModel = "五座SUV";
    
    mockCar.Object.CarBrand = "一汽大眾";
    mockCar.Object.CarModel = "七座SUV";

    mockCar.VerifySet(s => s.CarBrand = "上汽大眾"); ;
    mockCar.VerifySet(s => s.CarModel = "五座SUV"); ;
}

取消注釋,釋放下面代碼

mockCar.Object.CarBrand = "上汽大眾";
mockCar.Object.CarModel = "五座SUV";
    
//mockCar.Object.CarBrand = "一汽大眾";
//mockCar.Object.CarModel = "七座SUV"

只有屬性設置的預期可以被正確執行后,VerifySet才能正確通過。

4.2.6、Mock的As方法

當Mock接口類型時,可以使用Mock.As<TInterface>將Mock出的對象實現多個接口,代碼清單如下:

public interface IComputer
{
    public string ComputerType { get; set; }
}

public interface IScreen
{
  public string GetScreenType();
}

public interface IMainBoard
{
  public ICpu GetCpu();
}

public interface IKeyboard
{
  public string GetKeyboardType();
}

public interface ICpu
{
  public string GetCpuType();
}

[Category("*6、Mock的As用法*")]
[Test]
public void MockAs_WithMultipleInterface_ShouldPass()
{
    var mockComputer = new Mock<IComputer>();

    var mockKeyBoard = mockComputer.As<IKeyboard>();
    var mockScreen = mockComputer.As<IScreen>();
    var mockCpu = mockComputer.As<ICpu>();
    mockKeyBoard.Setup(s => s.GetKeyboardType()).Returns("機械鍵盤");
    mockScreen.Setup(s => s.GetScreenType()).Returns("OLED");
    mockCpu.Setup(s => s.GetCpuType()).Returns("Intel-11代I7");
    var keyboardType = ((dynamic)mockComputer.Object).GetKeyboardType();
    var screenType = ((dynamic)mockComputer.Object).GetScreenType();
    var cpuType = ((dynamic)mockComputer.Object).GetCpuType();

    Assert.Multiple(() =>
    {
        Assert.AreEqual("機械鍵盤", keyboardType);
        Assert.AreEqual("OLED", screenType);
        Assert.AreEqual("Intel-11代I7", cpuType);
    });
}

必須要注意的是,如果要使用Mock.As給Mock的對象添加多個接口,必須在Mock.Object對象訪問其屬性前進行添加接口,否則會拋出異常,此方法的解釋為,如果訪問其屬性,則其runtime type 就會生成,此時將無法使其繼承多個接口。被Mock的類型必須是接口時,才可以向Mock的對象添加多個接口,代碼清單如下:

[Category("*6、Mock的As用法*")]
[Test]
public void MockAs_WithMultipleInterfaceAndInvokePropertyBeforeAs_ShouldPass()
{
    var mockComputer = new Mock<IComputer>();
    mockComputer.Setup(s => s.ComputerType).Returns("台式機");
    var computerType = mockComputer.Object.ComputerType;

    var mockKeyBoard = mockComputer.As<IKeyboard>();
    var mockScreen = mockComputer.As<IScreen>();
    var mockCpu = mockComputer.As<ICpu>();
    mockKeyBoard.Setup(s => s.GetKeyboardType()).Returns("機械鍵盤");
    mockScreen.Setup(s => s.GetScreenType()).Returns("OLED");
    mockCpu.Setup(s => s.GetCpuType()).Returns("Intel-11代I7");
    var keyboardType = ((dynamic)mockComputer.Object).GetKeyboardType();
    var screenType = ((dynamic)mockComputer.Object).GetScreenType();
    var cpuType = ((dynamic)mockComputer.Object).GetCpuType();

    Assert.Multiple(() =>
    {
        Assert.AreEqual("台式機", computerType);
        Assert.AreEqual("機械鍵盤", keyboardType);
        Assert.AreEqual("OLED", screenType);
        Assert.AreEqual("Intel-11代I7", cpuType);
    });
}

測試調試結果如下,會拋出一個InvalidOperatiohnException的異常:

4.2.7、Mock如何設置異步方法

其中兩種寫法都可以進行異步方法Setup

第一種方式:

mockProductService.Setup(s => s.AddProductAsync(It.IsAny<string>()).Result).Returns(true);

第二種方式:

mockProductService.Setup(s => s.AddProductAsync(It.IsAny<string>())).ReturnsAsync(true);

 

public interface IProductService
{
    public Task<bool> AddProductAsync(string name);
}

[Category("7、Mock的異步方法設置")]
[Test]
public async Task AddProductAsync_WithName_ReturnTrue()
{
    var mockProductService = new Mock<IProductService>();
    mockProductService.Setup(s => s.AddProductAsync(It.IsAny<string>()).Result).Returns(true);
    //mockProductService.Setup(s => s.AddProductAsync(It.IsAny<string>())).ReturnsAsync(true);

    var result = await mockProductService.Object.AddProductAsync("演進式架構");
    Assert.AreEqual(true, result);
}

4.2.8、Mock的Sequence用法

SetupSequence:可以設置當多次調用同一個對象的同一個方法時,可以返回不同的值,代碼清單如下:

[Category("*8、Mock的Sequence用法*")]
[Test]
public void GetProductName_WithId_ReturnDifferenctValueInMultipleInvoke()
{
    var mockProductService = new Mock<IProductService>();
    mockProductService.SetupSequence(s => s.GetProudctName(It.IsAny<Guid>()))
        .Returns("漸進式架構")
        .Returns("Vue3實戰")
        .Returns("Docker實戰")
        .Returns("微服務架構設計模式");

    var result = mockProductService.Object.GetProudctName(Guid.Empty);
    var result1 = mockProductService.Object.GetProudctName(Guid.Empty);
    var result2 = mockProductService.Object.GetProudctName(Guid.Empty);
    var result3 = mockProductService.Object.GetProudctName(Guid.Empty);
    Assert.Multiple(() =>
    {
        Assert.AreEqual("漸進式架構", result);
        Assert.AreEqual("Vue3實戰", result1);
        Assert.AreEqual("Docker實戰", result2);
        Assert.AreEqual("微服務架構設計模式", result3);
    });
}

InSequence:可以設置多個方法的執行順序,設置后如果未按照這個順序執行,則會拋出異常,這里演示的是同一個Mock對象的不同方法,不同對象的不同方法同樣也適用,代碼如下:

場景一:按照與設置不同順序執行方法:

[Category("*8、Mock的Sequence用法*")]
[Test]
public void InSequence_WithMultipleNonSequenceInvoke_ThrowException()
{
    var mockProductService = new Mock<IProductService>(MockBehavior.Strict);
    var sequence = new MockSequence();
    mockProductService.InSequence(sequence)
        .Setup(s => s.AddProductAsync(It.IsAny<string>())).ReturnsAsync(true);
    mockProductService.InSequence(sequence)
        .Setup(s => s.GetProudctName(It.IsAny<Guid>())).Returns("漸進式架構");
    mockProductService.InSequence(sequence)
        .Setup(s => s.DeleteProduct(It.IsAny<Guid>())).Returns(true);

    mockProductService.Object.AddProductAsync("漸進式架構");
    mockProductService.Object.GetProudctName(Guid.Empty);
    mockProductService.Object.DeleteProduct(Guid.Empty);
}

 

 

場景二:按照與設置相同順序執行方法:

[Category("*8、Mock的Sequence用法*")]
[Test]
public void InSequence_WithMultipleInSequenceInvoke_WillPass()
{
    var mockProductService = new Mock<IProductService>(MockBehavior.Strict);
    var sequence = new MockSequence();
    mockProductService.InSequence(sequence)
        .Setup(s => s.AddProductAsync(It.IsAny<string>())).ReturnsAsync(true);
    mockProductService.InSequence(sequence)
        .Setup(s => s.GetProudctName(It.IsAny<Guid>())).Returns("漸進式架構");
    mockProductService.InSequence(sequence)
        .Setup(s => s.DeleteProduct(It.IsAny<Guid>())).Returns(true);

    mockProductService.Object.AddProductAsync("漸進式架構");
    mockProductService.Object.GetProudctName(Guid.Empty);
    mockProductService.Object.DeleteProduct(Guid.Empty);
}

4.2.9、Mock的Protected用法

對Protected的成員進行Setup,因為被protected修飾后,類的成員將不會對外開放,所以Mock時需要通過成員的字符串名稱進行設置,

需要注意以下幾點:

(1)、必須使用Mock實例Protected方法進行Setup設置。

(2)、對受保護的成員,進行Setup時需要使用成員的字符串名稱進行設置。

(3)、對於參數匹配,需要使用Mock.Protected.ItExpr靜態類來進行參數匹配。

代碼清單如下:

//需要引用命名空間
using Moq.Protected

public class Calculator
{
  public Calculator(int first, int second, double number, double divisor)
  {
      First = first;
      Second = second;
      Number = number;
      Divisor = divisor;
  }
  protected int First { get; set; }
  protected int Second { get; set; }
  protected double Number { get; set; }
  protected double Divisor { get; set; }
  public double Sum()
  {
      return (First + Second) * GetPercent() * GetSalt(0.9);
  }
  public double Division()
  {
      return Number * GetPercent() * GetSalt(0.9) / Divisor;
  }

  protected virtual double GetPercent()
  {
      return 0.9;
  }
  protected virtual double GetSalt(double salt)
  {
      return salt;
  }
}

[Category("*9、Mock的Protected用法*")]
[Test]
public void Calculate_WithProtectedMembers_CanAccessProtectedMembers()
{
    var mockCalculator = new Mock<Calculator>(12, 10, 100, 5);
    mockCalculator.Protected().Setup<double>("GetPercent").Returns(0.5);
    mockCalculator.Protected().Setup<double>("GetSalt",ItExpr.IsAny<double>()).Returns(0.9);

    var obj = mockCalculator.Object;
    var sum = obj.Sum();
    var division = obj.Division();

    Assert.Multiple(()=> {
        Assert.AreEqual(22 * 0.5 *0.9, sum);
        Assert.AreEqual(100 * 0.5 * 0.9 / 5, division);
    });
}

Moq4.8(包括)版本以后,可以通過完全不相關的接口(類未實現的接口)來設置被保護成員,比如下面代碼:

//單獨的接口,未被Caculator類繼承
public interface ICaculatorProtectedMembers
{
    double GetPercent();
    double GetSalt(double salt);
}

[Category("*9、Mock的Protected用法*")]
[Test]
public void Calculate_WithProtectedMembersUnRelatedInterface_CanAccessProtectedMembers()
{
    var mockCalculator = new Mock<Calculator>(12, 10, 100, 5);
    var caculatorProtectedMembers= mockCalculator.Protected().As<ICaculatorProtectedMembers>();
    caculatorProtectedMembers.Setup(s => s.GetPercent()).Returns(0.6);
    caculatorProtectedMembers.Setup(s => s.GetSalt(It.IsAny<double>())).Returns(0.8);

    var obj = mockCalculator.Object;
    var sum = obj.Sum();
    var division = obj.Division();

    Assert.Multiple(() =>
    {
        Assert.AreEqual(22 * 0.6 * 0.8, sum);
        Assert.AreEqual(100 * 0.6 * 0.8 / 5, division);
    });
}

4.2.10、Mock的Of用法

Mock可以使用靜態方法Of加上Linq的方式快速的對模擬對象進行Setup,通過Linq的方式進行Setup時,(要設置的成員1)==(想要設置的預期1)&&(要設置的成員2)==(想要設置的預期2),而且對成員屬性進行預期設置時,也可以通過鏈式的方式進行設置(在Mock.Of中再次使用Mock.Of對成員進行Mock),注意Mock.Of<T>()相當於new Mock<T>().Object對象,代碼清單如下:

[Category("*10、Mock的Of用法*")]
[Test]
public void MockOf_WithLinq_QuickSetup()
{
    var context = Mock.Of<HttpContext>(hc =>
      hc.User.Identity.IsAuthenticated == true &&
      hc.User.Identity.Name == "harley" &&
      hc.Response.ContentType == "application/json" &&
      hc.RequestServices == Mock.Of<IServiceProvider>
      (a => a.GetService(typeof(ICaculatorProtectedMembers)) == Mock.Of<ICaculatorProtectedMembers>
     (p => p.GetPercent() == 0.2 && p.GetSalt(It.IsAny<double>()) == 0.3)));

    Assert.Multiple(() =>
    {
        Assert.AreEqual(true, context.User.Identity.IsAuthenticated);
        Assert.AreEqual("harley", context.User.Identity.Name);
        Assert.AreEqual("application/json", context.Response.ContentType);
        Assert.AreEqual(0.2,context.RequestServices.GetService<ICaculatorProtectedMembers>().GetPercent());
        Assert.AreEqual(0.3, context.RequestServices.GetService<ICaculatorProtectedMembers>().GetSalt(1));
        ;
    });
}

4.2.11、Mock泛型參數進行匹配

當被Mock的對象含有泛型方法時,可以使用It.IsAnyType對泛型T類型/T類型參數進行匹配,代碼清單如下:

public interface IRedisService
{
  public bool SaveJsonToString<T>(T TObject);
  public bool SavePersonToString<T>(T TObject);
}
public class Person
{
  public string Name { get; set; }
}

public class Male : Person
{
}

[Category("*10、Mock的Of用法*")]
[Test]
public void SaveJsonToString_TObject_ReturnTrue()
{
    var mockRedisService = new Mock<IRedisService>();
    mockRedisService.Setup(s => s.SaveJsonToString(It.IsAny<It.IsAnyType>()))
        .Returns(true);
    var kv = new KeyValuePair<string, string>("Harley", "Coder");
    Assert.Multiple(() =>
    {
        Assert.AreEqual(true, mockRedisService.Object.SaveJsonToString(kv));
        Assert.AreEqual(true, mockRedisService.Object.SaveJsonToString(new { Name = "Harley", JobType = "Coder" }));
    });
}

[Category("*10、Mock的Of用法*")]
[Test]
public void SavePersonToString_WithMale_ReturnTrue()
{
    var mockRedisService = new Mock<IRedisService>();
    var male = new Male { Name = "Harley" };
    mockRedisService.Setup(s => s.SavePersonToString(It.IsAny<It.IsSubtype<Person>>())).Returns(true);
    var result1 = mockRedisService.Object.SavePersonToString(new { Name = "Harley", JobType = "Coder" });
    var result2 = mockRedisService.Object.SavePersonToString(new Person { Name = "Harley" });
    var result3 = mockRedisService.Object.SavePersonToString(new Male { Name = "Harley" });
    Assert.Multiple(() =>
    {
        Assert.AreEqual(true, result1,"使用匿名類型生成的對象無法通過測試");
        Assert.AreEqual(true, result2,"使用Person類型生成的對象無法通過測試");
        Assert.AreEqual(true, result3, "使用Male類型生成的對象無法通過測試");
    });
}

 

需要注意的是,

當含有泛型參數時,不能通過Method<It.IsAnyType>(T arg)這種方式進行Setup,只能通過Method(It.IsAny<It.IsAnyType>())或者Method(It.IsAny<It.IsSubType<Male>>())這種方式進行Setup。

如果只有泛型類型,無泛型參數時,可以通過Method<It.IsAnyType>()或者Method<It.IsSubType<Male>>()這種方式進行Setup。

其中Male繼承自Person類

4.2.12、Mock的Events用法

Mock的Raise方法,拋出一個事件,Raise本身不會注冊相關事件委托,只是會即時觸發事件,如下代碼:

public class ProductServiceHandler
{
    private IProductService _productService;
    public ProductServiceHandler(IProductService productService)
    {
        _productService = productService;
        _productService.MyHandlerEvent += Invoke;
    }

    public void Invoke(object sender, EventArgs args)
    {
        System.Diagnostics.Debug.WriteLine($"This is {nameof(ProductServiceHandler)} {nameof(Invoke)} Id={((MyEventArgs)args).Id}");
    }
}

[Category("*12、Mock的Events用法*")]
[Test]
public void Repaire_WithIdIsAny_RaiseMyHandlerEvent()
{
    var mockProductService = new Mock<IProductService>();
    var id = Guid.NewGuid();
    var myHandler = new ProductServiceHandler(mockProductService.Object);
    System.Diagnostics.Debug.WriteLine($"This is {nameof(Repaire_WithIdIsAny_RaiseMyHandlerEvent)} Id={id}");
    mockProductService.Setup(s => s.Repaire(It.IsAny<Guid>())).Verifiable();
    //這個注冊的委托不會被調用,實際上是觸發ProductServiceHandler中的Invoke委托
    mockProductService.Raise(s => s.MyHandlerEvent += null, new MyEventArgs(id));
    mockProductService.Object.Repaire(id);
}

SetupAdd與SetupRemove需要搭配VerifyAdd與VerifyRemove使用,目的是確定我們事件是否正確注冊/正確取消注冊,並且在Verify時還可以驗證注冊/取消了多少次,代碼清單如下:

public class ProductServiceHandler
{
private IProductService _productService;
public ProductServiceHandler(IProductService productService)
{
    _productService = productService;
    _productService.MyHandlerEvent += Invoke;
    _productService.MyHandlerEvent -= Invoke;
}

public void Invoke(object sender, EventArgs args)
{
    System.Diagnostics.Debug.WriteLine($"This is {nameof(ProductServiceHandler)} {nameof(Invoke)} Id={((MyEventArgs)args).Id}");
}
}

[Category("*12、Mock的Events用法*")]
[Test]
public void MockSetupAddRemove_WithIdIsAny_RaiseMyHandlerEvent()
{
    var mockProductService = new Mock<IProductService>();
    var id = Guid.NewGuid();
    mockProductService.SetupAdd(s => s.MyHandlerEvent += It.IsAny<EventHandler>());

    var myHandler = new ProductServiceHandler(mockProductService.Object);
    mockProductService.VerifyAdd(s => s.MyHandlerEvent += It.IsAny<EventHandler>(), Times.Once);
    mockProductService.VerifyRemove(s => s.MyHandlerEvent -= It.IsAny<EventHandler>(), Times.Never);
}

上面的VerifyAdd將會通過,但是VerifyRemove將不會通過,因為MyHandlerEvent在構造

ProductServiceHandler實例時,執行了一次,運行測試結果如下:

五、單元測試的映射

在測試類和被測代碼之間建立映射,這樣我們可以更方便的

  • 找到與一個項目有關的所有測試
  • 找到一個類有關的所有測試
  • 找到一個類某個功能的所有測試
  • 找到一個方法有關的所有測試

5.1、映射到項目

新建一個項目來放測試,命名為被測項目名稱加上".Tests",比如項目名稱為

Harley.ERP,那么單元測試項目名稱可以是Harley.ERP.Tests。

5.2、測試類映射到類

為每一個類映射一個測試類,測試類命名規則,被測類加上"Tests"后綴。

5.3、測試類映射到功能

如果被測方法邏輯比較復雜,或者為了測試類擁有更好的可讀性,可以將被測方法單獨映射一個類,比如我們有個AccountService被測類,其中有一個Login方法的邏輯比較復雜,那么我們可以新建一個測試類AccountServiceTests,它放其它方法的測試,再建一個測試類AccountServiceTestsLogin,它放Login方法的所有測試。

5.4、測試方法映射到被測類的方法

為了更方便在測試類中找到被測類的測試方法,一般會將測試方法按照以下規則命名:[被測方法]_[場景]_[期望表現],比如GetProducName_WithProductId_ReturnProductList()

 

文章中用到的示例代碼可以在GitHub上進行下載,地址為:

https://github.com/Harley-Blog/XUnitDemo

References:

.Net單元測試藝術

Moq(https://github.com/Moq/moq4/wiki/Quickstart)

 

 


免責聲明!

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



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