第七章 对MVC应用进行单元测试

本章中, 我将介绍如何对MVC应用进行单元测试. 单元测试将某个组件与应用的其他部分隔离, 以便验证各种行为. ASP.NET Core MVC的设计易于进行单元测试, VS提供了大量的单元测试框架. 我将展示如何建立单元测试, 解释如何安装最受欢迎的测试框架, 描述测试的过程.

!决定何时进行单元测试

易测试性是ASP.NET Core MVC的长处之一, 但并不适合所有情况.
我喜欢在项目中使用单元测试, 但并不是在所有地方都进行测试. 我倾向于为功能和函数编写单元测试, 因为我觉得这些功能和函数很难写, 很可能会出bug. 在这些情况下, 单元测试有助于实现我的想法. 在实际处理之前, 先考虑需要测试哪些内容, 会有很大的帮助.
也就是说, 单元测试只是一个工具, 而不是信仰. 只有你自己知道需要测试多少东西. 如果感觉单元测试没用, 或者有更好的办法, 就不要仅仅因为跟风才进行单元测试(但如果没有更好的方法, 甚至根本没有测试, 可能会让客户发现你的bug, 单元测试就是一个很好的解决方案. 你不一定需要进行单元测试, 但确实需要进行某种测试)
如果你以前没有进行过单元测试, 那么鼓励您尝试一下, 看看它如何工作. 如果您不是单元测试的粉丝, 可以跳过这一章.

章节概括

问题 解决方案
创建单元测试 创建测试项目, 安装测试包, 添加包含测试的类
为单元测试隔离组件 使用接口来分离应用组件, 并在单元测试中使用带有受限测试数据的伪实现
使用不同数据进行xUnit测试 使用参数化单元测试, 或从方法或属性中获取测试数据
简化创建伪测试对象的过程 使用mocking框架

准备示例项目

本章将使用第六章中创建的WorkingWithVisualStudio项目, 在本章中为在存储库中创建新Product对象提供支持

启用内置的Tag helper

在本章中使用了内置的tag helper来设置元素的href属性. 我将在第二十三到第二十五章详细解释tag helper的工作原理, 但为了简单地启用他们, 我创建了一个视图导入文件\Views\_ViewImports.cshtml, 内容如下

@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

这个语句启用了内置的tag helpers.

向控制器中添加行为

第一步是向Home中添加行为, 该行为渲染用于输入和从浏览器接收数据的视图.

// Controllers\HomeController.cs

using Microsoft.AspNetCore.Mvc;
using WorkingWithVisualStudio.Models;
using System.Linq;

namespace WorkingWithVisualStudio.Controllers
{

    public class HomeController : Controller
    {

        SimpleRepository Repository = SimpleRepository.SharedRepository;

        public IActionResult Index() => View(Repository.Products.Where(p => p?.Price < 50));

        [HttpGet]
        public IActionResult AddProduct() => View(new Product());

        [HttpPost]
        public IActionResult AddProduct(Product p)
        {
            Repository.AddProduct(p);
            return RedirectToAction("Index");
        }
    }
}

创建数据实体表单

为了允许用户创建新产品, 创建了视图\Views\Home\AddProduct.cshtml.

@model WorkingWithVisualStudio.Models.Product
@{ Layout = null; }

<!DOCTYPE html>
<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>Working with Visual Studio</title>
    <link rel="stylesheet" href="/lib/bootstrap/dist/css/bootstrap.min.css" />
</head>
<body class="p-2">
    <h3 class="text-center">Create Product</h3>
    <form asp-action="AddProduct" method="post">
        <div class="form-group">
            <label asp-for="Name">Name:</label>
            <input asp-for="Name" class="form-control" />
        </div>
        <div class="form-group">
            <label asp-for="Price">Price:</label>
            <input asp-for="Price" class="form-control" />
        </div>
        <div class="text-center">
            <button type="submit" class="btn btn-primary">Add</button>
            <a asp-action="Index" class="btn btn-secondary">Cancel</a>
        </div>
    </form>
</body>
</html>

更新Index视图

预备过程的最后最后一步是在Index中添加一个新建表单的链接.

<!-- Views\Home\Index.cshtml -->

@model IEnumerable<WorkingWithVisualStudio.Models.Product>
@{ Layout = null; }

<!DOCTYPE html>
<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>Working with Visual Studio</title>
    <link rel="stylesheet" href="/lib/bootstrap/dist/css/bootstrap.min.css" />
</head>
<body class="p-1">
    <h3 class="text-center">Products</h3>
    <table class="table table-bordered table-striped">
        <thead>
            <tr><td>Name</td><td>Price</td></tr>
        </thead>
        <tbody>
            @foreach (var p in Model)
            {
                <tr>
                    <td>@p.Name</td>
                    <td>@($"{p.Price:C2}")</td>
                </tr>
            }
        </tbody>
    </table>
    <div class="text-center">
        <a class="btn btn-primary" asp-action="AddProduct">
            Add New Product
        </a>
    </div>
</body>
</html>

对MVC应用进行单元测试

单元测试用于验证应用程序中各个组件和功能的行为. ASP.NET Core和MVC框架都被设计成尽可能容易地进行单元测试. 下面我将解释如何在VS中配置单元测试, 并演示如何为MVC程序编写单元测试, 还将介绍一些让单元测试更加简单可靠的工具.
在这本书中使用的是xUnit.net, 因为它与VS集成得很好, 而且微软使用它为ASP.NET Core进行单元测试. 下表是xUnit.net的介绍

注意: 程序员们在单元测试上争论不休. 有些开发人员不喜欢将单元测试从应用程序代码中分离出来, 更喜欢在同一个项目中甚至同一个类文件中进行测试, 这取决于个人习惯和团队约定. 我在这里描述的是一种常用方法, 如果你不喜欢, 可以尝试不同的风格.

问题 回答
xUnit.net是什么? 一个可用于测试ASP.NET Core MVC应用的测试框架
xUnit.net为什么有用处? xUnit可以很轻松地集成到VS中
如何使用? 测试是使用”Fact”或”Theory”属性注解的方法, 在方法体中, “Assert”类定义的方法用于比较预测值和真实值.
有缺陷吗? 单元测试的主要缺陷是不能有效地隔离被测试的组件. 详情请阅读”为单元测试隔离组件”一节. xUnit最大的问题是文档不完善, 基本信息可以从http://xunit.github.io/获取, 但高级用法需要一些指导和犯错.
有替代品吗? 有很多可用的测试框架. 两个热门的替代品是MSTest和NUnit

创建一个单元测试项目

在newCore应用中, 可用创建一个单独的VS项目来进行单元测试, 每一个都被定义成C#类的一个方法. 使用独立的项目意味着在部署应用的时候不需要部署测试.
要创建测试项目, 在解决方案资源管理器中右键解决方案, 新建netCore xUint测试项目WorkingWithVisualStudio.Tests.
惯例是将单元测试命名为<ApplicationName>.Tests

移除默认测试类

VS为测试项目添加了一个C#类, 这个类会让之后的示例结果令人迷惑. 删除UnitTest1.cs文件.

添加项目引用

为测试项目添加依赖项WorkingWithVisualStudio

编写并执行单元测试

创建类ProductTests.cs, 这是一个简单的类, 但包含了开始单元测试需要的一切.

using WorkingWithVisualStudio.Models;
using Xunit;

namespace WorkingWithVisualStudio.Tests {
    public class ProductTests {

        [Fact]
        public void CanChangeProductName() {
            // Arrange
            var p = new Product { Name = "Test", Price = 100M };
            // Act
            p.Name = "New Name";
            //Assert
            Assert.Equal("New Name", p.Name);
        }

        [Fact]
        public void CanChangeProductPrice() {
            // Arrange
            var p = new Product { Name = "Test", Price = 100M };
            // Act
            p.Price = 200M;
            //Assert
            Assert.Equal(100M, p.Price);
        }
    }
}

注意: 我故意在CanChangeProductPrice方法中留下了一个错误, 之后会改正它.

ProductTests类中有两个单元测试, 每条测试Product模型类的一个不同的行为. 测试项目可以包含很多类, 每个类中可以包含很多测试条目.
通常用测试方法来描述测试什么行为, 类名描述测试的对象, 这使得在项目中组织测试更容易, 并且在VS中运行所有测试时, 可以更容易理解它们的结果.
Fact属性被应用于每个方法, 以表明它们是一个测试. 在方法体中, 单元测试遵循”arrange, act, assert”(AAA)模式.

  • arrange: 设置测试条件
  • act: 执行测试操作
  • assert: 验证操作结果是否符合预期

arrange和act部分都是普通的C#代码, 但断言部分使用xUnit.net提供的Assert类的静态方法. 下面列举了Assert`类的一些方法

  • Equal(expected, result): 断言输出与预期相等, 有很多重载来比较不同的类型或集合, 还有一个版本接受实现IEqualityComparer<T>接口的附加参数用于比较
  • NotEqual(expected, result)
  • True(result)
  • False(result)
  • IsType(expected, result)
  • IsNotType(expected, result)
  • IsNull(result)
  • IsNotNull(result)
  • InRange(result, low, high)
  • NotInRange(result, low, high)
  • Throws(exception, expression)

如果结果不是预期的, Assert类将抛出异常.

在测试资源管理器中运行测试

VS在”测试资源管理器”中集成了查找和运行单元测试的功能. 该窗口可以在测试 > 窗口 > 测试资源管理器中打开.
在测试之前需要先生成解决方案, 编译会触发查找测试.
运行全部测试, VS将使用xUnit.net在项目中运行全部测试并显示结果. 如前所述, CanChangeProductPrice中包含了一个导致测试失败的错误. 下面的代码纠正了问题

    [Fact]
    public void CanChangeProductPrice() {
        // Arrange
        var p = new Product { Name = "Test", Price = 100M };
        // Act
        p.Price = 200M;
        //Assert
        Assert.Equal(200M, p.Price);
    }

提示 发现测试失败后, 先检测测试参数再查看被测试的逻辑代码.

(反正我是没运行成功, VS版本15.7.1, 错误为”响应超时”……续…更新到15.7.5, 运行成功了, 两个测试运行了一分钟左右……续…重启之后运行得很快了)

如果测试很多, 那么需要一段时间才能全部执行. 测试资源管理器提供了多种方案, 可以按相应策略执行.

为单元测试分离组件

Product这样的模型类编写单元测试很简单. 类不仅简单, 而且是自包含的, 这意味着在对Product对象执行操作时, 我可以确信我正在测试产品类提供的功能.
而MVC应用的其他组件情况更复杂, 因为它们之间存在依赖关系. 我定义的下一组测试将在控制器上运行, 检查在控制器和视图之间传递的产品对象序列.
在比较自定义类生成的对象时, 需要使用接受实现IEqualityComparer<T>接口参数的Assert.Equal. 第一步是新建文件Comparer.cs

using System;
using System.Collections.Generic;

namespace WorkingWithVisualStudio.Tests {

    public class Comparer {
        public static Comparer<U> Get<U>(Func<U, U, bool> func) {
            return new Comparer<U>(func);
        }
    }

    public class Comparer<T> : Comparer, IEqualityComparer<T> {
        private Func<T, T, bool> comparisonFunction;

        public Comparer(Func<T, T, bool> func) {
            comparisonFunction = func;
        }

        public bool Equals(T x, T y) {
            return comparisonFunction(x, y);
        }

        public int GetHashCode(T obj) {
            return obj.GetHashCode();
        }
    }
}

这两个类可以使用lambda表达式创建IEqualityComparer<T>对象, 不是必须的, 但可以简化代码.
在能够轻松比较两个对象后, 我应当说明组件之间的依赖性问题. 我添加了一个新类HomeControllerTests

using Microsoft.AspNetCore.Mvc;
using System.Collections.Generic;
using WorkingWithVisualStudio.Controllers;
using WorkingWithVisualStudio.Models;
using Xunit;

namespace WorkingWithVisualStudio.Tests {
    public class HomeControllerTests {

        [Fact]
        public void IndexActionModelIsComplete() {
            // Arrange
            var controller = new HomeController();
            // Act
            var model = (controller.Index() as ViewResult)?.ViewData.Model
                as IEnumerable<Product>;
            // Assert
            Assert.Equal(SimpleRepository.SharedRepository.Products, model,
                Comparer.Get<Product>((p1, p2) => p1.Name == p2.Name
                    && p1.Price == p2.Price));
        }
    }
}

这个测试检查Index动作将存储库中的所有数据传递给视图. (现在先忽略测试的act部分. 我将在第十七章中解释ViewResult. 现在只要知道获取Index行为返回的模型数据就行了)
运行测试会失败, 说明存储库中的对象集合和Index方法提供的对象集合不同. 但在寻找错误原因时有一个问题: 测试应该执行在Home控制器上, 但控制器类依赖SimpleRepository类, 这就很难弄清楚到底是哪个部分出的问题.
示例程序很简单, 只需要查看两个类的代码就可以轻松解决, 但在实际的成语中则很困难, 依赖链会导致很难找到测试失败的具体原因. 通常数据将依赖于某种持久性存储系统, 如数据库等, 而单元测试可以作用于整个复杂的组件链, 其中任何一个都可能导致问题.
在针对应用程序的小部分(如单个方法和类)时, 是有效的. 我需要将Home控制器和应用程序的其他部分隔离, 这样就可以限制测试的范围, 并排除存储库引起的影响.

隔离组件

隔离组件的关键在于使用C#接口. 要从存储库中分离控制器, 我添加了一个新类Models\IRepository.cs

using System.Collections.Generic;
namespace WorkingWithVisualStudio.Models {
    public interface IRepository {
        IEnumerable<Product> Products { get; }
        void AddProduct(Product p);
    }
}

这个接口没有什么特别的, 但让我可以更容易地为测试隔离组件. 第一步是更新SimpleRepository

public class SimpleRepository : IRepository {
    // ...
}

下一步是修改控制器类Controllers\HomeController.cs

public IRepository Repository = SimpleRepository.SharedRepository;

这看上去不是什么重大的改变, 但它允许我在测试期间更改控制器使用的存储库, 这就是将控制器与其他组件隔离的方法.

// HomeControllerTests.cs

using Microsoft.AspNetCore.Mvc;
using System.Collections.Generic;
using WorkingWithVisualStudio.Controllers;
using WorkingWithVisualStudio.Models;
using Xunit;

namespace WorkingWithVisualStudio.Tests
{
    public class HomeControllerTests
    {
        class ModelCompleteFakeRepository : IRepository
        {
            public IEnumerable<Product> Products { get; } = new Product[] {
                new Product { Name = "P1", Price = 275M },
                new Product { Name = "P2", Price = 48.95M },
                new Product { Name = "P3", Price = 19.50M },
                new Product { Name = "P3", Price = 34.95M }};

            public void AddProduct(Product p)
            {
                // do nothing - not required for test
            }
        }

        [Fact]
        public void IndexActionModelIsComplete()
        {
            // Arrange
            var controller = new HomeController();
            controller.Repository = new ModelCompleteFakeRepository();
            // Act
            var model = (controller.Index() as ViewResult)?.ViewData.Model
                as IEnumerable<Product>;
            // Assert
            Assert.Equal(controller.Repository.Products, model,
                Comparer.Get<Product>((p1, p2) => p1.Name == p2.Name
                    && p1.Price == p2.Price));
        }
    }
}

我定义了一个仅实现我要测试的属性的IRepository接口的伪实现, 测试数据永远是固定的.
修改后的单元测试仍然会失败, 这表明问题是HomeController类中的索引操作方法引起的, 而不是它依赖的组件. 单元测试正在执行的操作方法非常简单, 通过检查它可以明显地发现问题.

public IActionResult Index() => View(Repository.Products.Where(p => p.Price < 50));

问题是由LINQ的Where语句引起的. 这时我已经清楚地知道了问题的来源, 但在纠正修改之前, 创建一个用于确认问题的测试是很好的实践.

提示: 前面的测试中有很多冗余, 我将在下一章中描述如何简化它.

添加一个新的测试

// HomeControllerTests.cs

using Microsoft.AspNetCore.Mvc;
using System.Collections.Generic;
using WorkingWithVisualStudio.Controllers;
using WorkingWithVisualStudio.Models;
using Xunit;

namespace WorkingWithVisualStudio.Tests
{
    public class HomeControllerTests
    {
        class ModelCompleteFakeRepository : IRepository
        {
            public IEnumerable<Product> Products { get; } = new Product[] {
                new Product { Name = "P1", Price = 275M },
                new Product { Name = "P2", Price = 48.95M },
                new Product { Name = "P3", Price = 19.50M },
                new Product { Name = "P3", Price = 34.95M }};
            public void AddProduct(Product p)
            {
                // do nothing - not required for test
            }
        }

        [Fact]
        public void IndexActionModelIsComplete()
        {
            // Arrange
            var controller = new HomeController();
            controller.Repository = new ModelCompleteFakeRepository();
            // Act
            var model = (controller.Index() as ViewResult)?.ViewData.Model
                as IEnumerable<Product>;
            // Assert
            Assert.Equal(controller.Repository.Products, model,
                Comparer.Get<Product>((p1, p2) => p1.Name == p2.Name
                    && p1.Price == p2.Price));
        }

        class ModelCompleteFakeRepositoryPricesUnder50 : IRepository
        {
            public IEnumerable<Product> Products { get; } = new Product[] {
                new Product { Name = "P1", Price = 5M },
                new Product { Name = "P2", Price = 48.95M },
                new Product { Name = "P3", Price = 19.50M },
                new Product { Name = "P3", Price = 34.95M }};
            public void AddProduct(Product p)
            {
                // do nothing - not required for test
            }
        }

        [Fact]
        public void IndexActionModelIsCompletePricesUnder50()
        {
            // Arrange
            var controller = new HomeController();
            controller.Repository = new ModelCompleteFakeRepositoryPricesUnder50();
            // Act
            var model = (controller.Index() as ViewResult)?.ViewData.Model
                as IEnumerable<Product>;
            // Assert
            Assert.Equal(controller.Repository.Products, model,
                Comparer.Get<Product>((p1, p2) => p1.Name == p2.Name
                    && p1.Price == p2.Price));
        }
    }
}

我定义了存储库的一个新的伪实现, 仅包含价格小于50的对象, 并编写了一个新的测试. 运行测试将会发现成功了, 这会增加认为问题是Index行为中的Where引起的怀疑.
在实际项目中, 理解测试失败的原因是协调测试目的与应用程序的规范的关键所在. 很可能Index方法应该按照价格筛选产品对象, 这种情况下就应该修改测试. 这是常见的情况, 失败的测试并不一定代表存在真正的问题. 另一方面, 如果Index方法不应该过滤模型对象, 就需要进行修改.

// Controllers\HomeController.cs

using Microsoft.AspNetCore.Mvc;
using WorkingWithVisualStudio.Models;
using System.Linq;

namespace WorkingWithVisualStudio.Controllers
{
    public class HomeController : Controller
    {
        public IRepository Repository = SimpleRepository.SharedRepository;

        public IActionResult Index() => View(Repository.Products);

        [HttpGet]
        public IActionResult AddProduct() => View(new Product());

        [HttpPost]
        public IActionResult AddProduct(Product p)
        {
            Repository.AddProduct(p);
            return RedirectToAction("Index");
        }
    }
}

再次运行, 会看到测试都通过了.
对于这样一个简单的问题来说, 似乎需要做很多工作. 但测试特定组件的能力在实际应用程序中是必不可少的. 只有当能够有效隔离组件时, 才能确定问题所在并编写测试来验证修复.

!理解测试驱动开发(TDD)

在本章中, 我遵循了最常用的单元测试风格, 先编写了应用程序功能, 然后进行测试, 以确保它能按要求工作. 这种风格很流行, 因为大多数开发人员首先考虑的是应用程序代码, 其次是测试(这当然是我要考虑的类别).
这种方法的问题是, 它倾向于只对应用程序的难以编写或需要重点关注的部分进行测试, 而对某个特性的某些方面只进行部分测试, 甚至完全未测试.
另一种方案是测试驱动开发, 有很多变体, 但核心思想都是在实现特性本身之前为特性编写测试. 先编写测试会让你更仔细考虑正在实现的规范, 以及判断特性被正确实现的标准. TDD没有深入实现细节, 而是让你预先考虑成败的度量.
测试在一开始都会失败, 因为新特性还没有实现. 但在向应用程序添加代码时, 测试将逐渐由红色(失败)转为绿色(成功), 当特性完成后, 所有的测试都将通过. TDD需要规则, 但它确实生成了更全面的测试, 也可以驱动更健壮可靠的代码.

改善单元测试

在本节中, 我将介绍一些更高级的工具和特性, 用来更简洁\直观地编写测试. 如果你沉浸在单元测试的氛围中, 你就可以得到大量的测试代码, 代码的清晰程度也会更重要. 特别是当你在维护过程中, 需要修改应用测试以反映开发的变化时

参数化单元测试

我为HomeController编写的测试解释了一个只针对某些数据值的问题. 为了测试这种情况, 最后创建了两个类似的测试, 每个测试都有自己的伪存储库. 这是一种冗余, 特别是因为这些测试之间唯一的区别是伪存储库中Product.Price的值.
xUnit.net提供了参数化测试支持, 其中测试用的数据被从测试中删除, 这样一个方法就可以用于多个测试. 示例如下

using Microsoft.AspNetCore.Mvc;
using System.Collections.Generic;
using WorkingWithVisualStudio.Controllers;
using WorkingWithVisualStudio.Models;
using Xunit;

namespace WorkingWithVisualStudio.Tests
{
    public class HomeControllerTests
    {
        class ModelCompleteFakeRepository : IRepository
        {
            public IEnumerable<Product> Products { get; set; }

            public void AddProduct(Product p)
            {
                // do nothing - not required for test
            }
        }

        [Theory]
        [InlineData(275, 48.95, 19.50, 24.95)]
        [InlineData(5, 48.95, 19.50, 24.95)]
        public void IndexActionModelIsComplete(decimal price1, decimal price2,
                decimal price3, decimal price4)
        {
            // Arrange
            var controller = new HomeController();
            controller.Repository = new ModelCompleteFakeRepository
            {
                Products = new Product[] {
                    new Product {Name = "P1", Price = price1 },
                    new Product {Name = "P2", Price = price2 },
                    new Product {Name = "P3", Price = price3 },
                    new Product {Name = "P4", Price = price4 },
                }
            };
            // Act
            var model = (controller.Index() as ViewResult)?.ViewData.Model
                as IEnumerable<Product>;
            // Assert
            Assert.Equal(controller.Repository.Products, model,
                Comparer.Get<Product>((p1, p2) => p1.Name == p2.Name
                    && p1.Price == p2.Price));
        }
    }
}
``

参数化单元测试使用`Theory`属性进行标记, 而标准测试使用`Fact`属性进行标记. 我也使用了`InlineData`属性, 用于指定测试方法使用的参数值. C#限制了数据值在属性中表示的方式, 因此我在测试方法中定义了四个decimal参数, 并使用`InlineData`属性为他们提供值.
每个`Inline`属性定义一个单独的单元测试, 在VS中显示为一个不同的项目.

#### 从方法或属性获取测试数据

在属性中表示数据的限制条件, 限制了`InlineData`属性的有效性, 另一种方法是创建一个静态方法或属性, 返回测试所需对象. 这种方法对定义数据的方式没有限制, 可以创建更大范围的测试值. 演示如下, 创建了一个类`ProductTestData.cs`

```csharp
using System.Collections;
using System.Collections.Generic;
using WorkingWithVisualStudio.Models;

namespace WorkingWithVisualStudio.Tests {

    public class ProductTestData : IEnumerable<object[]> {

        public IEnumerator<object[]> GetEnumerator() {
            yield return new object[] { GetPricesUnder50() };
            yield return new object[] { GetPricesOver50 };
        }

        IEnumerator IEnumerable.GetEnumerator() {
            return this.GetEnumerator();
        }

        private IEnumerable<Product> GetPricesUnder50() {
            decimal[] prices = new decimal[] { 275, 49.95M, 19.50M, 24.95M };
            for (int i = 0; i < prices.Length; i++) {
                yield return new Product { Name = $"P{i + 1}", Price = prices[i] };
            }
        }

        private Product[] GetPricesOver50 => new Product[] {
            new Product { Name = "P1", Price = 5 },
            new Product { Name = "P2", Price = 48.95M },
            new Product { Name = "P3", Price = 19.50M },
            new Product { Name = "P4", Price = 24.95M }};
    }
}




<div class="se-preview-section-delimiter"></div>

测试数据通过实现IEnumerable<object>[]接口的类提供. 该接口返回一个对象数组序列. 序列中的每个对象数组都包含一组参数, 这些参数将被传递给测试方法. 我将重新定义测试方法, 给测试数据添加另一层, 以便接受一个Product对象数组, 该层是对象数组的枚举, 每个数组包含一个产品对象数组. 测试数据中的这种数据结构深度令人迷惑, 但正确处理很重要, 因为如果xUnit.net试图传递给测试方法的参数数量和方法签名不匹配, 那么测试将无法工作.
我倾向于编写自己的测试数据类, 以便使用私有方法或属性定义单独的测试数据集, 然后通过GetEnumerator方法将这些数据组合成对象数组的序列. 为了演示不同的技术, 我用方法和属性分别创建了产品对象数组, 但我倾向于在自己的项目中使用方法(这取决于我正在测试的数据类型). 下面展示如何使用Theory属性的测试数据类设置测试.

提示: 如果想在单元测试类中包含数据测试类, 使用MemberData属性而不是ClassData属性. MemberData属性使用一个字符串指定提供IEnumerable<object[]>类型的静态方法.

using Microsoft.AspNetCore.Mvc;
using System.Collections.Generic;
using WorkingWithVisualStudio.Controllers;
using WorkingWithVisualStudio.Models;
using Xunit;

namespace WorkingWithVisualStudio.Tests {
    public class HomeControllerTests {
        class ModelCompleteFakeRepository : IRepository {
            public IEnumerable<Product> Products { get; set; }
            public void AddProduct(Product p) {
                // do nothing - not required for test
            }
        }
        [Theory]
        [ClassData(typeof(ProductTestData))]
        public void IndexActionModelIsComplete(Product[] products ) {
            // Arrange
            var controller = new HomeController();
            controller.Repository = new ModelCompleteFakeRepository {
                Products = products
            };
            // Act
            var model = (controller.Index() as ViewResult)?.ViewData.Model
                as IEnumerable<Product>;
            // Assert
            Assert.Equal(controller.Repository.Products, model,
                Comparer.Get<Product>((p1, p2) => p1.Name == p2.Name
                    && p1.Price == p2.Price));
        }
    }
}




<div class="se-preview-section-delimiter"></div>

ClassData属性使用测试数据类的类型进行配置, 在这里是ProductTestData. 运行时xUnit将创建ProductTestData的新实例, 并得到测试数据序列.

注意: 在测试资源管理器中只能看到一项测试, 即使数据类提供了两个测试数据集, 这是因为测试数据对象不能被正确地序列化

高级伪实现

隔离组件需要类的伪实现来提供测试数据或检查组件的行为方式. 在前面的示例中, 我创建了一个实现IRepository接口的类, 这可能很有效, 但会为每种测试都创建一个实现类. 下面的代码添加了一个测试, 该测试检查Index方法是否只调用存储库中的Products`方法一次(当担心组件对存储库重复查询, 导致多个数据库查询时, 这种测试很常见)

using Microsoft.AspNetCore.Mvc;
using System.Collections.Generic;
using WorkingWithVisualStudio.Controllers;
using WorkingWithVisualStudio.Models;
using Xunit;
using System;

namespace WorkingWithVisualStudio.Tests {
    public class HomeControllerTests {
        class ModelCompleteFakeRepository : IRepository {
            public IEnumerable<Product> Products { get; set; }
            public void AddProduct(Product p) {
                // do nothing - not required for test
            }
        }
        [Theory]
        [ClassData(typeof(ProductTestData))]
        public void IndexActionModelIsComplete(Product[] products ) {
            // Arrange
            var controller = new HomeController();
            controller.Repository = new ModelCompleteFakeRepository {
                Products = products
            };
            // Act
            var model = (controller.Index() as ViewResult)?.ViewData.Model
                as IEnumerable<Product>;
            // Assert
            Assert.Equal(controller.Repository.Products, model,
                Comparer.Get<Product>((p1, p2) => p1.Name == p2.Name
                    && p1.Price == p2.Price));
        }
        class PropertyOnceFakeRepository : IRepository {
            public int PropertyCounter { get; set; } = 0;
            public IEnumerable<Product> Products {
                get {
                    PropertyCounter++;
                    return new[] { new Product { Name = "P1", Price = 100 } };
                }
            }
            public void AddProduct(Product p) {
                // do nothing - not required for test
            }
        }
        [Fact]
        public void RepositoryPropertyCalledOnce() {
            // Arrange
            var repo = new PropertyOnceFakeRepository();
            var controller = new HomeController { Repository = repo };
            // Act
            var result = controller.Index();
            // Assert
            Assert.Equal(1, repo.PropertyCounter);
        }
    }
}




<div class="se-preview-section-delimiter"></div>

伪实现并不仅仅是简单的数据源, 还可以用来评估组件执行工作的方式. 在本例中, 添加了一个简单的计数器属性, 确保只调用一次Products属性

添加Mocking框架

创建这样的伪对象是会脱离控制的, 而让事情重新得到控制的最好方法是使用fakes框架, 也就是所谓的mocking框架(fake和mock对象存在技术上的区别, 但现代测试工具将它们混淆在一起方便使用, 所以我将交替使用这些术语). 我在本章中使用的框架是”Moq”

  • “Moq”是什么? Moq是一个创建组件伪实现的软件包
  • 为什么有用? mocking框架让为测试创建伪实现更容易
  • 怎么使用? Moq使用lambda表达式来创建满足测试所需功能的伪实现
  • 有什么缺陷? 熟悉语法很困难, 点击这里查看文档和示例
  • 有替代品吗? 有许多替代品包括NSubstituteFakeItEasy, 这些框架提供差不多的功能, 主要看你喜欢哪种语法

首先在测试项目中安装NuGet包”Moq”

创建Mock对象

创建mock对象的意思是告诉Moq你需要什么类型的对象, 配置行为, 应用到测试中. 在下面的代码中我用Moq替换了两个存储库的伪实现

using Microsoft.AspNetCore.Mvc;
using System.Collections.Generic;
using WorkingWithVisualStudio.Controllers;
using WorkingWithVisualStudio.Models;
using Xunit;
using System;
using Moq;

namespace WorkingWithVisualStudio.Tests
{
    public class HomeControllerTests
    {
        [Theory]
        [ClassData(typeof(ProductTestData))]
        public void IndexActionModelIsComplete(Product[] products)
        {
            // Arrange
            var mock = new Mock<IRepository>();
            mock.SetupGet(m => m.Products).Returns(products);
            var controller = new HomeController { Repository = mock.Object };
            // Act
            var model = (controller.Index() as ViewResult)?.ViewData.Model
                as IEnumerable<Product>;
            // Assert
            Assert.Equal(controller.Repository.Products, model,
                Comparer.Get<Product>((p1, p2) => p1.Name == p2.Name
                    && p1.Price == p2.Price));
        }

        [Fact]
        public void RepositoryPropertyCalledOnce()
        {
            // Arrange
            var mock = new Mock<IRepository>();
            mock.SetupGet(m => m.Products)
                .Returns(new[] { new Product { Name = "P1", Price = 100 } });
            var controller = new HomeController { Repository = mock.Object };
            // Act
            var result = controller.Index();
            // Assert
            mock.VerifyGet(m => m.Products, Times.Once);
        }
    }
}




<div class="se-preview-section-delimiter"></div>

使用Moq可以让我删除IRepository的伪实现, 并用几行代码替换. 我将简要解释在示例中使用Moq的方式. 第一部是创建mock对象的新实例, 指定应该实现的接口, 如下

var mock = new Mock<IRepository>();




<div class="se-preview-section-delimiter"></div>

我创建的Mock对象将伪造IRepository接口. 下一步是定义测试所需的功能. 与接口的常规类实现不同, mock对象只配置测试所需的行为. 对于第一个mock存储库, 我要实现Products属性, 使其返回products

mock.SetupGet(m => m.Products).Returns(products);




<div class="se-preview-section-delimiter"></div>

SetupGet属性用于实现属性的getter访问器. 第二个语句类似

mock.SetupGet(m => m.Products)
                .Returns(new[] { new Product { Name = "P1", Price = 100 } });




<div class="se-preview-section-delimiter"></div>

Mock类定义了一个Object属性, 用于返回实现接口的对象和已定义的行为. 在两个测试中, 都使用Object属性来获取存储库, 并配置控制器.

var controller = new HomeController { Repository = mock.Object };




<div class="se-preview-section-delimiter"></div>

用到的最后一个Moq特性是检查Products属性只被调用了一次

mock.VerifyGet(m => m.Products, Times.Once);

VerifyGet方法是Mock类定义的方法, 用于监视mock对象的状态. 在此例中, VerifyGet方法允许我独处Products属性被调用的次数.

总结

主要讲了xUnit和Moq的使用….
下一章将开始一个更实际的MVC应用, “SportsStore”

猜你喜欢

转载自blog.csdn.net/crf_moonlight/article/details/81099546
今日推荐