迭代 5 — 创建单元测试 (C#)

Microsoft

下载代码

在第五次迭代中,我们通过添加单元测试使应用程序更易于维护和修改。 我们将模拟数据模型类,并为控制器和验证逻辑生成单元测试。

生成联系人管理 ASP.NET MVC 应用程序 (C#)

在此系列教程中,我们将从头到尾构建整个联系人管理应用程序。 通过 Contact Manager 应用程序,可以存储联系人列表的联系人信息(姓名、电话号码和电子邮件地址)。

我们通过多次迭代生成应用程序。 每次迭代都会逐步改进应用程序。 这种多次迭代方法的目标是使你能够了解每次更改的原因。

  • 迭代 #1 - 创建应用程序。 在第一次迭代中,我们以最简单的方式创建联系人管理器。 添加了对基本数据库操作的支持:创建、读取、更新和删除 (CRUD) 。

  • 迭代 #2 - 使应用程序看起来不错。 在此迭代中,我们通过修改默认 ASP.NET MVC 视图母版页和级联样式表来改进应用程序的外观。

  • 迭代 #3 - 添加表单验证。 在第三次迭代中,我们添加了基本表单验证。 我们会阻止用户在未完成所需表单字段的情况下提交表单。 我们还验证电子邮件地址和电话号码。

  • 迭代 #4 - 使应用程序松散耦合。 在第四次迭代中,我们利用多种软件设计模式来更轻松地维护和修改 Contact Manager 应用程序。 例如,我们将应用程序重构为使用存储库模式和依赖项注入模式。

  • 迭代 #5 - 创建单元测试。 在第五次迭代中,我们通过添加单元测试使应用程序更易于维护和修改。 我们将模拟数据模型类,并为控制器和验证逻辑生成单元测试。

  • 迭代 #6 - 使用测试驱动开发。 在此第六次迭代中,我们通过先编写单元测试并针对单元测试编写代码,向应用程序添加新功能。 在此迭代中,我们将添加联系人组。

  • 迭代 #7 - 添加 Ajax 功能。 第七次迭代中,我们通过添加对 Ajax 的支持来提高应用程序的响应能力和性能。

此迭代

在上一次对 Contact Manager 应用程序的迭代中,我们将应用程序重构为更松散的耦合。 我们将应用程序分为不同的控制器、服务和存储库层。 每一层通过接口与下面的层交互。

我们重构了应用程序,使应用程序更易于维护和修改。 例如,如果需要使用新的数据访问技术,只需更改存储库层,而无需接触控制器或服务层。 通过使联系人管理器松散耦合,我们使应用程序对更改更具弹性。

但是,当我们需要向 Contact Manager 应用程序添加新功能时会发生什么情况? 或者,修复 bug 时会发生什么情况? 编写代码的一个可悲但经过充分证明的事实是,每当接触代码时,都会产生引入新 bug 的风险。

例如,有一天,经理可能会要求你向联系人管理器添加新功能。 她希望你添加对联系人组的支持。 她希望你能够让用户将其联系人组织成组,例如“好友”、“商务”等。

若要实现此新功能,需要修改 Contact Manager 应用程序的所有三个层。 需要向控制器、服务层和存储库添加新功能。 一旦开始修改代码,就有可能破坏以前工作的功能。

将应用程序重构为单独的层,就像在上一次迭代中所做的那样,这是一件好事。 这是一件好事,因为它使我们能够在不接触应用程序的其余部分的情况下更改整个层。 但是,如果要使层中的代码更易于维护和修改,则需要为代码创建单元测试。

使用单元测试来测试单个代码单元。 这些代码单元小于整个应用程序层。 通常,使用单元测试来验证代码中的特定方法的行为是否符合预期。 例如,你将为由 ContactManagerService 类公开的 CreateContact () 方法创建单元测试。

应用程序的单元测试的工作方式就像安全网一样。 每当修改应用程序中的代码时,都可以运行一组单元测试,以检查修改是否破坏了现有功能。 单元测试使代码可安全修改。 单元测试使应用程序中的所有代码对更改更具弹性。

在此迭代中,我们将单元测试添加到 Contact Manager 应用程序。 这样,在下一次迭代中,我们可以将联系人组添加到应用程序,而无需担心破坏现有功能。

注意

有多种单元测试框架,包括 NUnit、xUnit.net 和 MbUnit。 在本教程中,我们将使用 Visual Studio 附带的单元测试框架。 但是,你可以同样轻松地使用这些替代框架之一。

测试的内容

在完美的世界中,所有代码都将由单元测试涵盖。 在完美的世界中,你会有完美的安全网。 你将能够修改应用程序中的任何代码行,并通过执行单元测试立即知道更改是否破坏了现有功能。

然而,我们并不生活在一个完美的世界。 实际上,在编写单元测试时,可以专注于为业务逻辑编写测试 (例如验证逻辑) 。 特别是, 不要 为数据访问逻辑或视图逻辑编写单元测试。

为了有用,单元测试必须非常快速地执行。 可以轻松地为应用程序累积数百 (甚至数千) 单元测试。 如果单元测试需要很长时间才能运行,则可以避免执行它们。 换句话说,长时间运行的单元测试对日常编码毫无用处。

因此,通常不会为与数据库交互的代码编写单元测试。 针对实时数据库运行数百个单元测试太慢。 相反,请模拟数据库并编写与模拟数据库交互的代码, (下面讨论模拟数据库) 。

出于类似原因,通常不会为视图编写单元测试。 若要测试视图,必须启动 Web 服务器。 由于启动 Web 服务器的过程相对较慢,因此不建议为视图创建单元测试。

如果视图包含复杂的逻辑,则应考虑将逻辑移动到帮助程序方法中。 可以为帮助程序方法编写单元测试,这些方法无需启动 Web 服务器即可执行。

注意

虽然在编写单元测试时,为数据访问逻辑或视图逻辑编写测试不是一个好主意,但这些测试在生成功能或集成测试时可能非常有价值。

注意

ASP.NET MVC 是Web Forms视图引擎。 虽然Web Forms视图引擎依赖于 Web 服务器,但其他视图引擎可能不是。

使用 Mock 对象框架

生成单元测试时,几乎始终需要利用 Mock Object 框架。 借助 Mock 对象框架,可以为应用程序中的类创建模拟和存根。

例如,可以使用 Mock 对象框架生成存储库类的模拟版本。 这样,就可以在单元测试中使用模拟存储库类而不是实际存储库类。 使用模拟存储库可以避免在执行单元测试时执行数据库代码。

Visual Studio 不包括 Mock 对象框架。 但是,有几个商业和开放源代码 Mock 对象框架可用于 .NET 框架:

  1. Moq - 此框架在 BSD 开放源代码 许可证下提供。 可以从 下载 Moq https://code.google.com/p/moq/
  2. Rhino Mocks - 此框架根据 BSD 开放源代码 许可证提供。 可以从 下载 Rhino Mocks http://ayende.com/projects/rhino-mocks.aspx
  3. Typemock 隔离器 - 这是一个商业框架。 可以从 下载试用版 http://www.typemock.com/

在本教程中,我决定使用 Moq。 但是,同样可以轻松地使用 Rhino Mock 或 Typemock 隔离器为 Contact Manager 应用程序创建 Mock 对象。

在使用 Moq 之前,需要完成以下步骤:

  1. .
  2. 在解压缩下载之前,请确保右键单击文件并单击标记为“ 取消阻止 ”的按钮, (请参阅图 1) 。
  3. 解压缩下载。
  4. 通过右键单击 ContactManager.Tests 项目中的 References 文件夹并选择“ 添加引用”,添加对 Moq 程序集的引用。 在“浏览”选项卡下,浏览到解压缩 Moq 的文件夹,然后选择Moq.dll程序集。 单击“确定”按钮。
  5. 完成这些步骤后,“引用”文件夹应如图 2 所示。

取消阻止 Moq

图 01:取消阻止 Moq (单击以查看全尺寸图像)

添加 Moq 后的引用

图 02:添加 Moq (单击以查看全尺寸图像)

为服务层创建单元测试

让我们首先为 Contact Manager 应用程序的服务层创建一组单元测试。 我们将使用这些测试来验证验证逻辑。

在 ContactManager.Tests 项目中创建名为 Models 的新文件夹。 接下来,右键单击“模型”文件夹,然后选择“ 添加”、“新建测试”。 此时会显示图 3 中显示的 “添加新测试 ”对话框。 选择 单元测试 模板并将新测试命名为 ContactManagerServiceTest.cs。 单击“ 确定” 按钮,将新测试添加到测试项目。

注意

通常,你希望测试项目的文件夹结构与 ASP.NET MVC 项目的文件夹结构相匹配。 例如,将控制器测试放在 Controllers 文件夹中,将模型测试放在 Models 文件夹中,等等。

Models\ContactManagerServiceTest.cs

图 03:Models\ContactManagerServiceTest.cs (单击以查看全尺寸图像)

最初,我们希望测试由 ContactManagerService 类公开的 CreateContact () 方法。 我们将创建以下五个测试:

  • CreateContact () - 测试 CreateContact () 在将有效的 Contact 传递给方法时返回值 true。
  • CreateContactRequiredFirstName () - 测试在将缺少名字的 Contact 传递给 CreateContact () 方法时,是否将错误消息添加到模型状态。
  • CreateContactRequiredLastName () - 测试将缺少姓氏的联系人传递到 CreateContact () 方法时,是否将错误消息添加到模型状态。
  • CreateContactInvalidPhone () - 测试将电话号码无效的联系人传递到 CreateContact () 方法时,是否向模型状态添加错误消息。
  • CreateContactInvalidEmail () - 测试将电子邮件地址无效的联系人传递到 CreateContact () 方法时,是否将错误消息添加到模型状态。

第一个测试验证有效的联系人不会生成验证错误。 其余测试检查每个验证规则。

这些测试的代码包含在清单 1 中。

列表 1 - Models\ContactManagerServiceTest.cs

using System.Web.Mvc;
using ContactManager.Models;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;

namespace ContactManager.Tests.Models
{
    [TestClass]
    public class ContactManagerServiceTest
    {
        private Mock<IContactManagerRepository> _mockRepository;
        private ModelStateDictionary _modelState;
        private IContactManagerService _service;

        [TestInitialize]
        public void Initialize()
        {
            _mockRepository = new Mock<IContactManagerRepository>();
            _modelState = new ModelStateDictionary();
            _service = new ContactManagerService(new ModelStateWrapper(_modelState), _mockRepository.Object);
        }

        [TestMethod]
        public void CreateContact()
        {
            // Arrange
            var contact = Contact.CreateContact(-1, "Stephen", "Walther", "555-5555", "steve@somewhere.com");

            // Act
            var result = _service.CreateContact(contact);
        
            // Assert
            Assert.IsTrue(result);
        }

        [TestMethod]
        public void CreateContactRequiredFirstName()
        {
            // Arrange
            var contact = Contact.CreateContact(-1, string.Empty, "Walther", "555-5555", "steve@somewhere.com");

            // Act
            var result = _service.CreateContact(contact);

            // Assert
            Assert.IsFalse(result);
            var error = _modelState["FirstName"].Errors[0];
            Assert.AreEqual("First name is required.", error.ErrorMessage);
        }

        [TestMethod]
        public void CreateContactRequiredLastName()
        {
            // Arrange
            var contact = Contact.CreateContact(-1, "Stephen", string.Empty, "555-5555", "steve@somewhere.com");

            // Act
            var result = _service.CreateContact(contact);

            // Assert
            Assert.IsFalse(result);
            var error = _modelState["LastName"].Errors[0];
            Assert.AreEqual("Last name is required.", error.ErrorMessage);
        }

        [TestMethod]
        public void CreateContactInvalidPhone()
        {
            // Arrange
            var contact = Contact.CreateContact(-1, "Stephen", "Walther", "apple", "steve@somewhere.com");

            // Act
            var result = _service.CreateContact(contact);

            // Assert
            Assert.IsFalse(result);
            var error = _modelState["Phone"].Errors[0];
            Assert.AreEqual("Invalid phone number.", error.ErrorMessage);
        }

        [TestMethod]
        public void CreateContactInvalidEmail()
        {
            // Arrange
            var contact = Contact.CreateContact(-1, "Stephen", "Walther", "555-5555", "apple");

            // Act
            var result = _service.CreateContact(contact);

            // Assert
            Assert.IsFalse(result);
            var error = _modelState["Email"].Errors[0];
            Assert.AreEqual("Invalid email address.", error.ErrorMessage);
        }
    }
}

由于我们使用清单 1 中的 Contact 类,因此需要向测试项目添加对 Microsoft 实体框架的引用。 添加对 System.Data.Entity 程序集的引用。

列表 1 包含一个名为 Initialize () 的方法,该方法使用 [TestInitialize] 属性进行修饰。 此方法在运行每个单元测试之前自动调用, (在每个单元测试) 之前调用此方法 5 次。 Initialize () 方法使用以下代码行创建一个模拟存储库:

_mockRepository = new Mock<IContactManagerRepository>();

此代码行使用 Moq 框架从 IContactManagerRepository 接口生成模拟存储库。 使用模拟存储库而不是实际的 EntityContactManagerRepository,以避免在运行每个单元测试时访问数据库。 模拟存储库实现 IContactManagerRepository 接口的方法,但这些方法实际上不执行任何操作。

注意

使用 Moq 框架时,_mockRepository 和 _mockRepository.Object 之间存在区别。 前者指的是 Mock<IContactManagerRepository> 类,该类包含用于指定模拟存储库的行为方式的方法。 后者是指实现 IContactManagerRepository 接口的实际模拟存储库。

创建 ContactManagerService 类的实例时,在 Initialize () 方法中使用模拟存储库。 所有单独的单元测试都使用此 ContactManagerService 类的实例。

清单 1 包含对应于每个单元测试的五种方法。 其中每个方法都使用 [TestMethod] 属性进行修饰。 运行单元测试时,将调用具有此属性的任何方法。 换句话说,使用 [TestMethod] 属性修饰的任何方法都是单元测试。

第一个名为 CreateContact () 的单元测试验证调用 CreateContact () 在将 Contact 类的有效实例传递给 方法时是否返回 true 值。 该测试创建 Contact 类的实例,调用 CreateContact () 方法,并验证 CreateContact () 是否返回值 true。

其余测试验证当使用无效 Contact 调用 CreateContact () 方法时,该方法返回 false,并将预期的验证错误消息添加到模型状态。 例如,CreateContactRequiredFirstName () 测试使用其 FirstName 属性的空字符串创建 Contact 类的实例。 接下来,使用无效的联系人调用 CreateContact () 方法。 最后,测试验证 CreateContact () 是否返回 false,并且模型状态包含预期的验证错误消息“名字是必需的”。

可以通过选择菜单选项“ 测试”、“运行”、“解决方案中的所有测试” (CTRL+R、A) 来运行清单 1 中的单元测试。 测试结果显示在“测试结果”窗口中, (请参阅图 4) 。

测试结果

图 04:测试结果 (单击以查看全尺寸图像)

为控制器创建单元测试

Asp。NETMVC 应用程序控制用户交互流。 测试控制器时,需要测试控制器是否返回正确的操作结果并查看数据。 你可能还想要测试控制器是否按预期方式与模型类交互。

例如,清单 2 包含联系人控制器 Create () 方法的两个单元测试。 第一个单元测试验证当有效的联系人传递到 Create () 方法时,Create () 方法会重定向到 Index 操作。 换句话说,传递有效的联系人时,Create () 方法应返回一个表示 Index 操作的 RedirectToRouteResult。

我们不希望在测试控制器层时测试 ContactManager 服务层。 因此,我们使用 Initialize 方法中的以下代码模拟服务层:

_service = new Mock();

在 CreateValidContact () 单元测试中,我们使用以下代码行模拟调用服务层 CreateContact () 方法的行为:

_service.Expect(s => s.CreateContact(contact)).Returns(true);

此代码行导致 mock ContactManager 服务在调用其 CreateContact () 方法时返回值 true。 通过模拟服务层,我们可以测试控制器的行为,而无需在服务层中执行任何代码。

第二个单元测试验证在将无效联系人传递到 方法时,Create () 操作是否返回“创建”视图。 我们会导致服务层 CreateContact () 方法使用以下代码行返回值 false:

_service.Expect(s => s.CreateContact(contact)).Returns(false);

如果 Create () 方法的行为符合预期,则当服务层返回值 false 时,它应返回“创建”视图。 这样,控制器就可以在“创建”视图中显示验证错误消息,并且用户有机会更正该无效的“联系人”属性。

如果计划为控制器生成单元测试,则需要从控制器操作返回显式视图名称。 例如,不要返回如下所示的视图:

return View();

而是返回如下所示的视图:

返回 View (“Create”) ;

如果在返回视图时不显式,则 ViewResult.ViewName 属性将返回一个空字符串。

清单 2 - Controllers\ContactControllerTest.cs

using System.Web.Mvc;
using ContactManager.Controllers;
using ContactManager.Models;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;

namespace ContactManager.Tests.Controllers
{
    [TestClass]
    public class ContactControllerTest
    {
        private Mock<IContactManagerService> _service;

        [TestInitialize]
        public void Initialize()
        {
            _service = new Mock<IContactManagerService>();
        }

        [TestMethod]
        public void CreateValidContact()
        {
            // Arrange
            var contact = new Contact();
            _service.Expect(s => s.CreateContact(contact)).Returns(true);
            var controller = new ContactController(_service.Object);
        
            // Act
            var result = (RedirectToRouteResult)controller.Create(contact);

            // Assert
            Assert.AreEqual("Index", result.RouteValues["action"]);
        }

        [TestMethod]
        public void CreateInvalidContact()
        {
            // Arrange
            var contact = new Contact();
            _service.Expect(s => s.CreateContact(contact)).Returns(false);
            var controller = new ContactController(_service.Object);

            // Act
            var result = (ViewResult)controller.Create(contact);

            // Assert
            Assert.AreEqual("Create", result.ViewName);
        }

    }
}

总结

在此迭代中,我们为 Contact Manager 应用程序创建了单元测试。 我们可以随时运行这些单元测试,以验证应用程序是否仍按预期方式运行。 单元测试充当应用程序的安全网,使我们能够在将来安全地修改应用程序。

我们创建了两组单元测试。 首先,我们通过为服务层创建单元测试来测试验证逻辑。 接下来,我们通过为控制器层创建单元测试来测试流控制逻辑。 测试服务层时,我们通过模拟存储库层将服务层的测试与存储库层隔离开来。 测试控制器层时,我们通过模拟服务层来隔离控制器层的测试。

在下一次迭代中,我们将修改 Contact Manager 应用程序,使其支持联系人组。 我们将使用名为“测试驱动开发”的软件设计过程向应用程序添加此新功能。