【单元测试篇】.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)

 

 

posted @   Harley-Chang  阅读(2284)  评论(0编辑  收藏  举报
编辑推荐:
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
阅读排行:
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· Docker 太简单,K8s 太复杂?w7panel 让容器管理更轻松!
点击右上角即可分享
微信分享提示