[翻译-ASP.NET MVC]Contact Manager开发之旅迭代5 - 建立单元测试

本翻译系列为asp.net mvc官方实例教程。在这个系列中,Stephen Walther将演示如何通过ASP.NET MVC framework结合单元测试、TDD、Ajax、软件设计原则及设计模式创建一个完整的Contact Manager应用。本系列共七个章节,也是七次迭代过程。本人将陆续对其进行翻译并发布出来,希望能对学习ASP.NET MVC 的各位有所帮助。由于本人也是个MVC菜鸟,且E文水平亦是平平,文中如有疏漏敬请见谅。
注:为保证可读性,文中Controller、View、Model、Route、Action等ASP.NET MVC核心单词均未翻译。

ContactManager开发之旅-索引页

ContactManager开发之旅 迭代1 - 创建应用程序

ContactManager开发之旅 迭代2 - 修改样式,美化应用

ContactManager开发之旅 迭代3 - 验证表单

ContactManager开发之旅 迭代4 - 利用设计模式松散耦合

迭代5 建立单元测试

本次迭代

在上一次对Contact Manager的迭代中,我们通过使用一些设计模式对程序进行了重构,松散了类之间的耦合。我们将controller、service和repository层分别独立出来。每层都基于接口与其他层进行交互。

通过重构,应用程序变得更以维护和修改。假如某天你需要使用其他的数据存储技术,那么只要简单的替换repository层即可,并不需要去碰controller或service层中的代码。

但当我们需要向Contact Manager中添加新功能或修正bug时呢?残酷的事实告诉我们,每当我们改动代码的时候,也必定要承担新bug出现的风险。

例如某天,你的头需要你向Contact Manager中添加一项新功能。她要求程序支持对联系人信息进行分组,她要求用户可以自定义一些如“朋友”,“同事”租入此类的分组从而组织他们的联系人信息。

为了实现这项功能,你需要修改Contact Manager中全部三层之中的代码。你需要向controller、service、repository层中添加一系列的新函数。从你开始修改代码的那一刻开始,你就必须承担有可能破坏原本正常工作的那部分功能的风险。

上次我们将应用程序重构并将其分散到若干独立的层中,这使得我们可以在不触碰其他代码的情况下对某层做出改变。然而,当你希望这个具体的层中的代码更易维护和修改时,你应当为代码建立单元测试。

你可以通过单元测试针对特定的代码单元进行测试。这些代码单元要比整个层小得多。例如你测试代码中的某个特定的方法,确认其是否达到预期的功能及表现,这就是一个经典的单元测试场景。如你想要对ContactManagerService类公开的CreateContact()方法进行单元测试。

单元测试对于整个应用程序的开发过程而言更像一个安全网络。当你想修改应用程序中的代码时,你可以进行一系列的单元测试来检测是否你的新代码对原有功能产生了破坏。单元测试为你的代码修改工作提供了更高的安全系数,同时单元测试也使得应用程序中的代码变更场景更具弹性。

在这次迭代中,我们向Contact Manager添加单元测试。得益于此,在下一次迭代中我们可以为其添加新的联系人分组功能,同时又无需顾虑这些改变是否会对原有功能产生影响。

那么就从这里开始

在完美的世界中,应用程序中的所有代码都是经过单元测试的。在完美的世界中,你拥有一个完美的安全网络体系,你可以修改应用程序总的任意一行代码并且立即通过单元测试得知这些改动是否会对原有的功能产生影响。

然而这个世界在大部分的情况下还不够完美。在实践中,当我们进行单元测试时,你需要集中精力针对业务逻辑进行测验(例如,验证逻辑)。在特殊情况下,你无需对数据访问逻辑或视图逻辑进行单元测试。

单元测试必须是可以快速执行的。你将很轻易的为应用程序积累成百上千的单元测试。如过运行某些单元测试十分耗时,则你应当避免执行它们。换句话说,耗时的单元测试对日常的编码工作并无益处。

因此,你无需对与数据库实际交互的代码进行单元测试。运行几百个针对数据库的单元测试将十分缓慢。你应当对你的数据库进行mock,然后编写代码与mock的数据库进行交互。(下面我们将讨论如何对数据库进行mock)

相似的,你无需针对view进行单元测试。要想对view进行测试,你就不得不搭建web服务器。因为搭建web服务器相对来说也很耗时,这里并不推荐针对view进行单元测试。

如果你的view包含大量复杂的逻辑,则你应当考虑将这些逻辑转移到Helper方法中。你可以针对Helper方法编写单元测试且无需搭建web服务器。

虽然针对数据存储逻辑或试图的测试并不被推荐,但这些测试可能在集成测试或功能测试时十分有用。

ASP.NET MVC默认使用Web Form试图引擎。该引擎依赖于web服务器,其他的试图引擎或许不尽如此。

使用Mock Object框架

一般来说,你需要通过使用某些Mock Object框架来构建单元测试。Mock Object框架会使你的mock工作更加便利。

Visual Studio中并未包含任何Mock Object框架。不过我们还有很多的针对.NETframework的商业或开源Mock Object框架可供选择:

  1. Moq – http://code.google.com/p/moq/.
  2. Rhino Mocks – http://ayende.com/projects/rhino-mocks.aspx.
  3. Typemock Isolator –  http://www.typemock.com/.  (这个是商业性质的)

我打算在本系列文章中使用Moq.当然,你也可以使用Rhino Mocks 或 Typemock Isolator对Contact Manager进行mock。

我们需要通过以下的步骤来使Moq正常工作:

  1. 解压文件
  2. 在项目中添加引用(图1)

image

图1

 

针对Service层进行单元测试

现在,我们将针对Contact Manager应用程序的service层进行一系列的单元测试。我们将使用如下的步骤以确认我们的验证逻辑。

在ContactManager.Tests项目中新建Models文件夹。接着右击该文件夹,选择添加->新建测试。此时,添加新测试对话框会出现,如下图2所示:

image

图2

选择单元测试模板,添加一个命名为ContactManagerServiceTest.cs的新测试。点击确定将其添加到测试项目中。

一般来说,你在测试项目中的文件组织结构应当与ASP.NET MVC项目中一致。如将controller测试放在Controllers文件夹中,针对model的测试放在Models文件夹中等。

首先,我们要测试ContactManagerService 类中公开的CreateContact()方法。则需要进行以下五个测试步骤:

  • CreateContact() –  测试当一个有效的Contact传递到CreateContact()方法中时,其返回值是否为true。
  • CreateContactRequiredFirstName() –  测试当传递到CreateContact()方法中的contact参数中缺少first name时的情况。
  • CreateContactRequredLastName() – 测试当传递到CreateContact()方法中的contact参数中缺少last name时的情况。
  • CreateContactInvalidPhone() – 测试当传递到CreateContact()方法中的contact参数中phone number无效时的情况。
  • CreateContactInvalidEmail() - 测试当传递到CreateContact()方法中的contact参数中email address无效时的情况。

从第一个测试开始分别针对每种验证规则进行测试。

这些测试相关的代码在如下代码清单1中

代码清单 1 – Models\ContactManagerServiceTest.cs

using System;
using System.Text;
using System.Collections.Generic;
using System.Linq;
using System.Web.Mvc;
using ContactManager.Models;
using ContactManager.Models.Validation;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;

namespace ContactManager.Tests.Models
{
    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 Entity Framework的引用,即System.Data.Entity程序集。

代码清单1中包含一个命名为Initialize()的方法,它被[TestInitialize]特性所标记。这个方法将在每个单元测试运行时自动调用(代码清单1的情况下,它被调用5次)。Initialize()方法中通过下面的代码对repository进行mock:

_mockRepository = new Mock<IContactManagerRepository>();

这行代码使用Moq框架通过IContactManagerRepository接口生成mock repository。这个repository用来代替真正的EntityContactManagerRepository从而避免每次运行单元测试时与数据库直接交互。它实现了IContactManagerRepository接口中规定的方法,虽然这些方法实质上什么都不做。

在使用Moq framework时应注意,_mockRepository和_mockRepository.Object具有不同的意义。前一个是指Mock<IContactManagerRepository>类中的包含的方法,用以指定mock repository的表现。而后一个是代表真正的实现了IContactManagerRepository的mock repository。

Mock repository在Initialize()方法中被实例化,所有的单元测试都将使用它。

代码清单1中包含了五个方法,他们与单元测试一一对应的。每一个方法都被[TestMethod]特性修饰。当你运行单元测试时,所有被该特性修饰的方法将被调用。也就是说,所有被[TestMethod]特性修饰的方法都是一个单元测试。

在第一个叫做CreateContact()的单元测试中,当我们将一个有效的Contact类的实例传递到方法中时它将返回true。该测试创建了一个Contact类的实例,然后调用CreateContact()方法,最后验证CreateContact()返回的值是否为true。

其余的测试则会验证当CreateContact()方法被调用且传递无效的Contact,方法返回false并且将验证错误信息添加到model state中的情景。例如,CreateContactRequiredFirstName()这个测试创建了一个FirstName属性为空字符串的Contact实例。接着,CreateContact()方法被调用并传递这个无效的Contact。最后,测试将验证CreateContact()的返回值(是否为false)及期望的错误验证信息“First name is required”是否被添加到model state中。

你可以通过选择测试->运行->解决方案中的所有测试(或者直接按Ctrl+R,A)来运行代码清单1中的测试。测试的结果会在测试结果窗口中显示(图3)

image

图3

为Controller创建单元测试

ASP.NET MVC应用控制用户交互的流动。当针对controller进行测试时,你需要测试controller是否返回正确的action result和view data。你还可能要测试控制器是否按照期望与模型进行交互。

例如代码清单2中包含两个针对Contact controller中Create()方法的单元测试。第一个测试需要确认当一个有效的联系人信息(Contact)传递到Create()方法中后,其是否跳转到Index action,即返回一个RedirectToRouteResult到Index这个action。

我们并不希望在测试controller层的同时对service层进行测试,因此我们需要对service层进行mock。同样是在Initialize方法中输入如下代码:

_service = new Mock<IContactManagerService>();

在CreateValidContact()测试中,我们使用如下这行代码针对调用service层中CreateContact()方法的行为进行mock:

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

这行代码用来确认CreateContact()方法被调用时的返回值是否为true。通过对service层的mock,我们得以在不执行任何service层中的代码的前提下针对controller的行为进行测试。

第二个测试要确认当一个无效的联系人信息传递到Create()方法中后返回的Create view。我们使用下面一行代码让service层的CreateContact()方法返回false:

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

如果Create()方法如我们预期那样工作,它应当在service层返回false时返回Create view。从而,controller可以在Create view中显示验证错误信息,并未用户提供了更改无效Contact属性的机会。

如果Create()方法如我们预期那样工作,它应当在service层返回false时返回Create view。从而,controller可以在Create view中显示验证错误信息,并未用户提供了更改无效Contact属性的机会。

如果你打算针对controller建立单元测试,你需要让controller返回一个具体的view名称,不要犯这样的错误:

return View();

你应该这样:

return 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应用建立了单元测试。我们可以在任何时候运行这些单元测试以确认应用程序是否依然可以达到预期表现。单元测试就像一张安全网络一样保证我们可以安全的修改应用程序。

我们共建立了两组单元测试。首先我们测试了service层中的验证逻辑。接着,我们同样为controller建立了单元测试。我们通过对repository层进行mock实现对service层的测试。同理,在我们测试controller层的时候,我们对service层进行了mock。

在下一次迭代中,我们将引入“测试驱动开发(TDD)”软件设计过程为Contact Manager增加联系人分组的功能。

posted @ 2009-05-05 17:14  紫色永恒  阅读(4481)  评论(4编辑  收藏  举报