启用自动单元测试

Microsoft

下载 PDF

这是免费 “NerdDinner”应用程序教程 的第 12 步,该教程介绍了如何使用 ASP.NET MVC 1 生成小型但完整的 Web 应用程序。

步骤 12 演示如何开发一套自动化单元测试来验证我们的 NerdDinner 功能,这将让我们有信心在将来对应用程序进行更改和改进。

如果使用 ASP.NET MVC 3,建议遵循入门与 MVC 3MVC 音乐商店教程。

NerdDinner 步骤 12:单元测试

让我们开发一套自动化单元测试,用于验证 NerdDinner 功能,让我们有信心在将来对应用程序进行更改和改进。

为什么选择单元测试?

一天早上,在开车上班时,你突然闪现出关于你正在处理的应用程序的灵感。 你意识到可以实施一项更改,该更改将使应用程序显著提高。 它可能是用于清理代码、添加新功能或修复 bug 的重构。

当你到达计算机时,你面临的问题是 - “进行这种改进有多安全?”如果更改有副作用或中断某些内容,该怎么办? 更改可能很简单,只需几分钟时间即可实现,但如果手动测试所有应用程序方案需要数小时,该怎么办? 如果忘记报道方案,损坏的应用程序进入生产环境,该怎么办? 做出这种改进真的值得付出所有的努力吗?

自动化单元测试可以提供一个安全网,使你能够不断增强应用程序,并避免害怕正在处理的代码。 使用可快速验证功能的自动测试可让你自信地编写代码,并使你能够做出你可能不自在的改进。 它们还有助于创建更易于维护且生命周期更长的解决方案,从而获得更高的投资回报。

ASP.NET MVC 框架使单元测试应用程序功能变得简单而自然。 它还支持测试驱动开发 (TDD) 工作流,实现基于测试优先的开发。

NerdDinner.Tests 项目

在本教程开头创建 NerdDinner 应用程序时,系统提示我们显示一个对话框,询问我们是否要创建一个单元测试项目,以便与应用程序项目一起执行:

“创建单元测试项目”对话框的屏幕截图。是,已选择“创建单元测试项目”。Nerd Dinner 点测试编写为测试项目名称。

我们一直选择“是,创建单元测试项目”单选按钮,这导致将“NerdDinner.Tests”项目添加到我们的解决方案:

解决方案资源管理器导航树的屏幕截图。已选择“书呆子晚餐点测试”。

NerdDinner.Tests 项目引用 NerdDinner 应用程序项目程序集,并使我们能够轻松地向其添加自动测试以验证应用程序功能。

为 Dinner 模型类创建单元测试

让我们向 NerdDinner.Tests 项目添加一些测试,以验证我们在生成模型层时创建的 Dinner 类。

首先,我们将在测试项目中创建一个名为“Models”的新文件夹,在其中放置与模型相关的测试。 然后,右键单击文件夹并选择 “添加新>测试 ”菜单命令。 此时会显示“添加新测试”对话框。

我们将选择创建“单元测试”并将其命名为“DinnerTest.cs”:

“添加新测试”对话框的屏幕截图。突出显示了单元测试。Dinner Test dot c s 编写为测试名称。

单击“确定”按钮时,Visual Studio 将添加 (并打开) 项目DinnerTest.cs文件:

Visual Studio 中 Dinner Test dot c s 文件的屏幕截图。

默认的 Visual Studio 单元测试模板包含一堆锅炉板代码,我觉得有点混乱。 让我们将其清理为仅包含以下代码:

using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using NerdDinner.Models;

namespace NerdDinner.Tests.Models {
 
    [TestClass]
    public class DinnerTest {

    }
}

上述 DinnerTest 类上的 [TestClass] 属性将其标识为将包含测试以及可选测试初始化和拆解代码的类。 可以通过添加具有 [TestMethod] 属性的公共方法来定义测试。

下面是我们将添加的两个测试中的第一个测试,练习我们的晚餐类。 第一个测试验证如果在未正确设置所有属性的情况下创建新的 Dinner,则我们的 Dinner 是否无效。 第二个测试验证当 Dinner 具有使用有效值设置的所有属性时,我们的 Dinner 是否有效:

[TestClass]
public class DinnerTest {

    [TestMethod]
    public void Dinner_Should_Not_Be_Valid_When_Some_Properties_Incorrect() {

        //Arrange
        Dinner dinner = new Dinner() {
            Title = "Test title",
            Country = "USA",
            ContactPhone = "BOGUS"
        };

        // Act
        bool isValid = dinner.IsValid;

        //Assert
        Assert.IsFalse(isValid);
    }

    [TestMethod]
    public void Dinner_Should_Be_Valid_When_All_Properties_Correct() {
        
        //Arrange
        Dinner dinner = new Dinner {
            Title = "Test title",
            Description = "Some description",
            EventDate = DateTime.Now,
            HostedBy = "ScottGu",
            Address = "One Microsoft Way",
            Country = "USA",
            ContactPhone = "425-703-8072",
            Latitude = 93,
            Longitude = -92,
        };

        // Act
        bool isValid = dinner.IsValid;

        //Assert
        Assert.IsTrue(isValid);
    }
}

上面你会注意到,我们的测试名称 (非常明确,) 有些详细。 之所以这样做,是因为最终可能会创建数百或数千个小测试,我们希望能够轻松地快速确定每个测试 (的意图和行为,尤其是在我们查看测试运行程序) 失败列表时。 测试名称应以测试的功能命名。 上面我们使用的是“Noun_Should_Verb”命名模式。

我们正在使用“AAA”测试模式来构建测试 ,该模式代表“排列、行动、断言”:

  • 排列:设置要测试的单元
  • 操作:练习受测单元并捕获结果
  • 断言:验证行为

编写测试时,我们希望避免单个测试执行太多操作。 相反,每个测试应只验证一个概念 (这样可以更轻松地查明故障原因) 。 一个很好的准则是尝试并为每个测试只提供一个断言语句。 如果测试方法中有多个断言语句,请确保它们都用于测试同一概念。 如果不确定,请进行另一次测试。

正在运行测试

Visual Studio 2008 专业版 (及更高版本) 包含一个内置测试运行程序,可用于在 IDE 中运行 Visual Studio 单元测试项目。 我们可以选择“解决方案”菜单中的“ 测试->运行->所有测试 ”命令 (或键入 Ctrl R,A) 来运行所有单元测试。 或者,我们可以将光标定位在特定测试类或测试方法中,并使用“当前上下文中的测试>>”菜单命令 (或键入 Ctrl R,T) 运行单元测试的子集。

让我们将光标置于 DinnerTest 类中,并键入“Ctrl R, T”以运行我们刚刚定义的两个测试。 执行此操作时,Visual Studio 中将显示一个“测试结果”窗口,其中列出了测试运行的结果:

Visual Studio 中“测试结果”窗口的屏幕截图。其中列出了测试运行的结果。

注意:默认情况下,VS 测试结果窗口不显示“类名”列。 可以通过在“测试结果”窗口中右键单击并使用“添加/删除列”菜单命令来添加此项。

我们的两个测试只花了一小部分时间运行 - 正如你所看到的,它们都通过了。 现在,我们可以通过创建验证特定规则验证的其他测试,以及涵盖添加到 Dinner 类的两个帮助程序方法(IsUserHost () 和 IsUserRegistered () )来扩展它们。 为 Dinner 类设置所有这些测试将使将来向其添加新的业务规则和验证变得更加容易和更安全。 我们可以将新规则逻辑添加到 Dinner,然后在几秒钟内验证它是否未破坏任何以前的逻辑功能。

请注意,使用描述性测试名称如何便于快速了解每个测试要验证的内容。 建议使用“工具>选项”菜单命令,打开“测试工具测试>执行”配置屏幕,然后选中“双击失败或不确定的单元测试结果显示测试中的失败点”复选框。 这样,就可以在测试结果窗口中双击失败,并立即跳转到断言失败。

创建 DinnersController 单元测试

现在,让我们创建一些单元测试来验证 DinnersController 功能。 首先,右键单击测试项目中的“控制器”文件夹,然后选择 “添加新>测试 ”菜单命令。 我们将创建一个“单元测试”并将其命名为“DinnersControllerTest.cs”。

我们将创建两个测试方法,用于验证 DinnersController 上的详细信息 () 操作方法。 第一个 将验证在请求现有 Dinner 时是否返回视图。 第二个将验证在请求不存在的 Dinner 时是否返回“NotFound”视图:

[TestClass]
public class DinnersControllerTest {

    [TestMethod]
    public void DetailsAction_Should_Return_View_For_ExistingDinner() {

        // Arrange
        var controller = new DinnersController();

        // Act
        var result = controller.Details(1) as ViewResult;

        // Assert
        Assert.IsNotNull(result, "Expected View");
    }

    [TestMethod]
    public void DetailsAction_Should_Return_NotFoundView_For_BogusDinner() {

        // Arrange
        var controller = new DinnersController();

        // Act
        var result = controller.Details(999) as ViewResult;

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

上述代码编译干净。 但是,当我们运行测试时,它们都失败:

代码的屏幕截图。两个测试都失败。

如果查看错误消息,就会发现测试失败的原因是 DinnersRepository 类无法连接到数据库。 我们的 NerdDinner 应用程序使用本地SQL Server Express文件的连接字符串,该文件位于 NerdDinner 应用程序项目的 \App_Data 目录下。 由于 NerdDinner.Tests 项目在应用程序项目的不同目录中编译和运行,因此连接字符串的相对路径位置不正确。

可以通过将 SQL Express 数据库文件复制到测试项目,然后在测试项目的 App.config 中添加相应的测试连接字符串来解决此问题。 这将取消阻止上述测试并运行。

不过,使用真实数据库的单元测试代码会带来许多挑战。 具体而言:

  • 它会显著减慢单元测试的执行时间。 运行测试所需的时间越长,执行测试的可能性就越小。 理想情况下,你希望单元测试能够在数秒内运行,并让它像编译项目一样自然地完成。
  • 它使测试中的设置和清理逻辑复杂化。 你希望每个单元测试都隔离并独立于其他 (,) 没有副作用或依赖项。 处理真实数据库时,必须注意状态,并在测试之间重置状态。

让我们看一下名为“依赖项注入”的设计模式,它可以帮助我们解决这些问题,并避免在测试中使用真实数据库。

依赖关系注入

现在,DinnersController 与 DinnerRepository 类紧密地“耦合”。 “耦合”是指类显式依赖于另一个类才能工作的情况:

public class DinnersController : Controller {

    DinnerRepository dinnerRepository = new DinnerRepository();

    //
    // GET: /Dinners/Details/5

    public ActionResult Details(int id) {

        Dinner dinner = dinnerRepository.FindDinner(id);

        if (dinner == null)
            return View("NotFound");

        return View(dinner);
    }

由于 DinnerRepository 类需要访问数据库,因此 DinnersController 类对 DinnerRepository 的紧密耦合依赖项最终要求我们有一个数据库,以便测试 DinnersController 操作方法。

我们可以通过使用名为“依赖项注入”的设计模式来解决此问题,即不再在使用依赖项的类中隐式创建依赖项 (,例如提供数据访问) 的存储库类。 相反,可以使用构造函数参数将依赖项显式传递给使用它们的类。 如果使用接口定义依赖项,则可以灵活地为单元测试方案传递“假”依赖项实现。 这使我们能够创建实际上不需要访问数据库的特定于测试的依赖项实现。

为了了解这一点,让我们使用 DinnersController 实现依赖项注入。

提取 IDinnerRepository 接口

第一步是创建新的 IDinnerRepository 接口,该接口封装控制器检索和更新 Dinners 所需的存储库协定。

我们可以通过右键单击 \Models 文件夹,然后选择“ 添加新>项 ”菜单命令并创建名为 IDinnerRepository.cs 的新接口来手动定义此接口协定。

或者,我们可以使用内置于 Visual Studio Professional (及更高版本的重构工具) ,从现有的 DinnerRepository 类自动提取和创建接口。 若要使用 VS 提取此接口,只需将光标置于 DinnerRepository 类的文本编辑器中,然后右键单击并选择“ 重构->提取接口 ”菜单命令:

显示“重构”子菜单中选择“提取接口”的屏幕截图。

这将启动“提取接口”对话框,并提示我们输入要创建的接口的名称。 它将默认为 IDinnerRepository,并自动选择现有 DinnerRepository 类上的所有公共方法以添加到接口:

Visual Studio 中“测试结果”窗口的屏幕截图。

单击“确定”按钮时,Visual Studio 将向应用程序添加新的 IDinnerRepository 接口:

public interface IDinnerRepository {

    IQueryable<Dinner> FindAllDinners();
    IQueryable<Dinner> FindByLocation(float latitude, float longitude);
    IQueryable<Dinner> FindUpcomingDinners();
    Dinner             GetDinner(int id);

    void Add(Dinner dinner);
    void Delete(Dinner dinner);
    
    void Save();
}

现有的 DinnerRepository 类将更新,使其实现 接口:

public class DinnerRepository : IDinnerRepository {
   ...
}

更新 DinnersController 以支持构造函数注入

现在,我们将更新 DinnersController 类以使用新接口。

目前,DinnersController 是硬编码的,因此其“dinnerRepository”字段始终为 DinnerRepository 类:

public class DinnersController : Controller {

    DinnerRepository dinnerRepository = new DinnerRepository();

    ...
}

我们将对其进行更改,使“dinnerRepository”字段的类型为 IDinnerRepository 而不是 DinnerRepository。 然后,我们将添加两个公共 DinnersController 构造函数。 其中一个构造函数允许将 IDinnerRepository 作为参数传递。 另一个是使用现有 DinnerRepository 实现的默认构造函数:

public class DinnersController : Controller {

    IDinnerRepository dinnerRepository;

    public DinnersController()
        : this(new DinnerRepository()) {
    }

    public DinnersController(IDinnerRepository repository) {
        dinnerRepository = repository;
    }
    ...
}

由于 ASP.NET MVC 默认使用默认构造函数创建控制器类,因此运行时的 DinnersController 将继续使用 DinnerRepository 类执行数据访问。

不过,我们现在可以更新单元测试,以使用参数构造函数传递“假”晚餐存储库实现。 此“假”晚餐存储库不需要访问真实数据库,而是使用内存中示例数据。

创建 FakeDinnerRepository 类

让我们创建一个 FakeDinnerRepository 类。

首先,我们将在 NerdDinner.Tests 项目中创建一个“Fakes”目录,然后向其添加新的 FakeDinnerRepository 类 (右键单击文件夹并选择“ 添加新>类 ”) :

“添加新类”菜单项的屏幕截图。“添加新项”突出显示。

我们将更新代码,以便 FakeDinnerRepository 类实现 IDinnerRepository 接口。 然后,我们可以右键单击它并选择“实现接口 IDinnerRepository”上下文菜单命令:

实现接口 I 晚餐存储库上下文菜单命令的屏幕截图。

这将导致 Visual Studio 使用默认的“存根”实现自动将所有 IDinnerRepository 接口成员添加到 FakeDinnerRepository 类:

public class FakeDinnerRepository : IDinnerRepository {

    public IQueryable<Dinner> FindAllDinners() {
        throw new NotImplementedException();
    }

    public IQueryable<Dinner> FindByLocation(float lat, float long){
        throw new NotImplementedException();
    }

    public IQueryable<Dinner> FindUpcomingDinners() {
        throw new NotImplementedException();
    }

    public Dinner GetDinner(int id) {
        throw new NotImplementedException();
    }

    public void Add(Dinner dinner) {
        throw new NotImplementedException();
    }

    public void Delete(Dinner dinner) {
        throw new NotImplementedException();
    }

    public void Save() {
        throw new NotImplementedException();
    }
}

然后,我们可以更新 FakeDinnerRepository 实现,以处理作为构造函数参数传递给它的内存中 List<Dinner> 集合:

public class FakeDinnerRepository : IDinnerRepository {

    private List<Dinner> dinnerList;

    public FakeDinnerRepository(List<Dinner> dinners) {
        dinnerList = dinners;
    }

    public IQueryable<Dinner> FindAllDinners() {
        return dinnerList.AsQueryable();
    }

    public IQueryable<Dinner> FindUpcomingDinners() {
        return (from dinner in dinnerList
                where dinner.EventDate > DateTime.Now
                select dinner).AsQueryable();
    }

    public IQueryable<Dinner> FindByLocation(float lat, float lon) {
        return (from dinner in dinnerList
                where dinner.Latitude == lat && dinner.Longitude == lon
                select dinner).AsQueryable();
    }

    public Dinner GetDinner(int id) {
        return dinnerList.SingleOrDefault(d => d.DinnerID == id);
    }

    public void Add(Dinner dinner) {
        dinnerList.Add(dinner);
    }

    public void Delete(Dinner dinner) {
        dinnerList.Remove(dinner);
    }

    public void Save() {
        foreach (Dinner dinner in dinnerList) {
            if (!dinner.IsValid)
                throw new ApplicationException("Rule violations");
        }
    }
}

我们现在有一个虚假的 IDinnerRepository 实现,它不需要数据库,而是可以处理 Dinner 对象的内存中列表。

将 FakeDinnerRepository 与单元测试配合使用

让我们返回到之前由于数据库不可用而失败的 DinnersController 单元测试。 我们可以使用以下代码更新测试方法,以使用填充了示例内存中 Dinner 数据的 FakeDinnerRepository 到 DinnersController:

[TestClass]
public class DinnersControllerTest {

    List<Dinner> CreateTestDinners() {

        List<Dinner> dinners = new List<Dinner>();

        for (int i = 0; i < 101; i++) {

            Dinner sampleDinner = new Dinner() {
                DinnerID = i,
                Title = "Sample Dinner",
                HostedBy = "SomeUser",
                Address = "Some Address",
                Country = "USA",
                ContactPhone = "425-555-1212",
                Description = "Some description",
                EventDate = DateTime.Now.AddDays(i),
                Latitude = 99,
                Longitude = -99
            };
            
            dinners.Add(sampleDinner);
        }
        
        return dinners;
    }

    DinnersController CreateDinnersController() {
        var repository = new FakeDinnerRepository(CreateTestDinners());
        return new DinnersController(repository);
    }

    [TestMethod]
    public void DetailsAction_Should_Return_View_For_Dinner() {

        // Arrange
        var controller = CreateDinnersController();

        // Act
        var result = controller.Details(1);

        // Assert
        Assert.IsInstanceOfType(result, typeof(ViewResult));
    }

    [TestMethod]
    public void DetailsAction_Should_Return_NotFoundView_For_BogusDinner() {

        // Arrange
        var controller = CreateDinnersController();

        // Act
        var result = controller.Details(999) as ViewResult;

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

现在,当我们运行这些测试时,它们都通过了:

单元测试的屏幕截图,两个测试都已通过。

最重要的是,它们只需一小部分时间即可运行,不需要任何复杂的设置/清理逻辑。 现在,我们可以单元测试所有 DinnersController 操作方法代码, (包括列出、分页、详细信息、创建、更新和删除) ,而无需连接到真实数据库。

附带主题:依赖项注入框架
执行手动依赖项注入 (如上述) 工作正常,但随着应用程序中依赖项和组件数量的增加,确实变得更加难以维护。 .NET 存在多个依赖项注入框架,可帮助提供更多依赖项管理灵活性。 这些框架(有时也称为“控制反转”) (IoC) 容器)提供了一些机制,支持在运行时指定依赖项并将其传递给对象, (最常使用构造函数注入) 。 .NET 中一些更常用的 OSS 依赖关系注入/IOC 框架包括:AutoFac、Ninject、Spring.NET、StructureMap 和 Windsor。 ASP.NET MVC 公开扩展性 API,使开发人员能够参与控制器的解析和实例化,并使依赖项注入/IoC 框架完全集成到此过程中。 使用 DI/IOC 框架还可以让我们从 DinnersController 中删除默认构造函数, 这将完全删除它与 DinnerRepository 之间的耦合。 我们不会将依赖项注入/IOC 框架与 NerdDinner 应用程序一起使用。 但是,如果 NerdDinner 代码库和功能有所增长,我们以后可以考虑这一点。

创建编辑操作单元测试

现在,让我们创建一些单元测试来验证 DinnersController 的“编辑”功能。 首先,我们将测试编辑操作的 HTTP-GET 版本:

//
// GET: /Dinners/Edit/5

[Authorize]
public ActionResult Edit(int id) {

    Dinner dinner = dinnerRepository.GetDinner(id);

    if (!dinner.IsHostedBy(User.Identity.Name))
        return View("InvalidOwner");

    return View(new DinnerFormViewModel(dinner));
}

我们将创建一个测试,用于验证在请求有效晚餐时,由 DinnerFormViewModel 对象支持的视图是否呈现回来:

[TestMethod]
public void EditAction_Should_Return_View_For_ValidDinner() {

    // Arrange
    var controller = CreateDinnersController();

    // Act
    var result = controller.Edit(1) as ViewResult;

    // Assert
    Assert.IsInstanceOfType(result.ViewData.Model, typeof(DinnerFormViewModel));
}

但是,当我们运行测试时,我们会发现它失败,因为当 Edit 方法访问 User.Identity.Name 属性以执行 Dinner.IsHostedBy () 检查 时,会引发 null 引用异常。

Controller 基类上的 User 对象封装有关已登录用户的详细信息,并在运行时创建控制器时由 ASP.NET MVC 填充。 由于我们在 Web 服务器环境外部测试 DinnersController,因此未设置 User 对象 (因此 null 引用异常) 。

模拟 User.Identity.Name 属性

模拟框架允许我们动态创建支持测试的依赖对象的假版本,从而简化测试。 例如,我们可以在编辑操作测试中使用模拟框架来动态创建一个 User 对象,DinnersController 可以使用该对象查找模拟用户名。 这将避免在运行测试时引发 null 引用。

有许多 .NET 模拟框架可用于 ASP.NET MVC (可在此处查看它们的列表: http://www.mockframeworks.com/) 。

下载后,我们将在 NerdDinner.Tests 项目中将引用添加到 Moq.dll 程序集:

Nerd Dinner 导航树的屏幕截图。突出显示了 Moq。

然后,我们将向测试类添加“CreateDinnersControllerAs (username) ”帮助程序方法,该方法将用户名作为参数,然后“模拟”DinnersController 实例上的 User.Identity.Name 属性:

DinnersController CreateDinnersControllerAs(string userName) {

    var mock = new Mock<ControllerContext>();
    mock.SetupGet(p => p.HttpContext.User.Identity.Name).Returns(userName);
    mock.SetupGet(p => p.HttpContext.Request.IsAuthenticated).Returns(true);

    var controller = CreateDinnersController();
    controller.ControllerContext = mock.Object;

    return controller;
}

在上面,我们将使用 Moq 创建一个 Mock 对象,该对象伪造 ControllerContext 对象 (这是 ASP.NET MVC 传递给控制器类来公开运行时对象(如 User、Request、Response 和 Session) )。 我们在 Mock 上调用“SetupGet”方法,以指示 ControllerContext 上的 HttpContext.User.Identity.Name 属性应返回传递给帮助程序方法的用户名字符串。

我们可以模拟任意数量的 ControllerContext 属性和方法。 为了说明这一点,我还为 Request.IsAuthenticated 属性 (添加了 SetupGet () 调用,该调用实际上不需要用于以下测试 ,但有助于说明如何模拟请求属性) 。 完成后,我们会将 ControllerContext 模拟的实例分配给 DinnersController,帮助程序方法将返回。

现在,我们可以编写单元测试,使用这些帮助程序方法来测试涉及不同用户的编辑方案:

[TestMethod]
public void EditAction_Should_Return_EditView_When_ValidOwner() {

    // Arrange
    var controller = CreateDinnersControllerAs("SomeUser");

    // Act
    var result = controller.Edit(1) as ViewResult;

    // Assert
    Assert.IsInstanceOfType(result.ViewData.Model, typeof(DinnerFormViewModel));
}

[TestMethod]
public void EditAction_Should_Return_InvalidOwnerView_When_InvalidOwner() {

    // Arrange
    var controller = CreateDinnersControllerAs("NotOwnerUser");

    // Act
    var result = controller.Edit(1) as ViewResult;

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

现在,当我们运行测试时,它们通过:

使用帮助程序方法的单元测试的屏幕截图。测试已通过。

测试 UpdateModel () 方案

我们创建了涵盖“编辑”操作的 HTTP-GET 版本的测试。 现在,让我们创建一些测试来验证“编辑”操作的 HTTP-POST 版本:

//
// POST: /Dinners/Edit/5

[AcceptVerbs(HttpVerbs.Post), Authorize]
public ActionResult Edit (int id, FormCollection collection) {

    Dinner dinner = dinnerRepository.GetDinner(id);

    if (!dinner.IsHostedBy(User.Identity.Name))
        return View("InvalidOwner");

    try {
        UpdateModel(dinner);

        dinnerRepository.Save();

        return RedirectToAction("Details", new { id=dinner.DinnerID });
    }
    catch {
        ModelState.AddModelErrors(dinner.GetRuleViolations());
        
        return View(new DinnerFormViewModel(dinner));
    }
}

我们支持此操作方法的有趣新测试方案是在控制器基类上使用 UpdateModel () 帮助程序方法。 我们将使用此帮助程序方法将表单后值绑定到 Dinner 对象实例。

以下两个测试演示了如何为 UpdateModel () 帮助程序方法提供表单发布值。 为此,我们将创建并填充 FormCollection 对象,然后将其分配给 Controller 上的“ValueProvider”属性。

第一次测试验证成功保存后,浏览器是否重定向到详细信息操作。 第二个测试验证是否在发布无效输入时,操作会再次重新显示编辑视图并显示错误消息。

[TestMethod]
public void EditAction_Should_Redirect_When_Update_Successful() {

    // Arrange      
    var controller = CreateDinnersControllerAs("SomeUser");

    var formValues = new FormCollection() {
        { "Title", "Another value" },
        { "Description", "Another description" }
    };

    controller.ValueProvider = formValues.ToValueProvider();
    
    // Act
    var result = controller.Edit(1, formValues) as RedirectToRouteResult;

    // Assert
    Assert.AreEqual("Details", result.RouteValues["Action"]);
}

[TestMethod]
public void EditAction_Should_Redisplay_With_Errors_When_Update_Fails() {

    // Arrange
    var controller = CreateDinnersControllerAs("SomeUser");

    var formValues = new FormCollection() {
        { "EventDate", "Bogus date value!!!"}
    };

    controller.ValueProvider = formValues.ToValueProvider();

    // Act
    var result = controller.Edit(1, formValues) as ViewResult;

    // Assert
    Assert.IsNotNull(result, "Expected redisplay of view");
    Assert.IsTrue(result.ViewData.ModelState.Count > 0, "Expected errors");
}

测试 Wrap-Up

我们介绍了单元测试控制器类中涉及的核心概念。 我们可以使用这些技术轻松创建数百个用于验证应用程序行为的简单测试。

由于控制器和模型测试不需要真正的数据库,因此它们非常快速且易于运行。 我们将能够在几秒钟内执行数百个自动测试,并立即获得有关我们所做的更改是否损坏的反馈。 这将有助于我们有信心不断改进、重构和优化应用程序。

我们将测试介绍为本章中的最后一个主题,但并不是因为测试是你应该在开发过程结束时执行的操作! 相反,应在开发过程中尽早编写自动测试。 这样,便可以在开发时立即获得反馈,帮助你深思熟虑地考虑应用程序的用例方案,并指导你在设计应用程序时考虑到干净的分层和耦合。

本书后面的章节将讨论 TDD) (体验驱动开发,以及如何将其与 ASP.NET MVC 配合使用。 TDD 是一种迭代编码做法,首先编写生成的代码将满足的测试。 使用 TDD,可以通过创建一个测试来验证要实现的功能,从而开始每个功能。 首先编写单元测试有助于确保清楚地了解该功能及其工作原理。 只有在测试编写 (并且已验证测试失败) 才能实现测试验证的实际功能。 由于你已花时间思考功能应如何工作的用例,因此你将更好地了解要求以及如何最好地实现这些要求。 完成实现后,可以重新运行测试,并立即获得有关该功能是否正常工作的反馈。 我们将在第 10 章中详细介绍 TDD。

下一步

一些最终的总结评论。