可测试性和实体框架 4.0

Scott Allen

发布日期:2021 年 5 月

介绍

本白皮书介绍并演示了如何使用 ADO.NET 实体框架 4.0 和 Visual Studio 2010 编写可测试代码。 本文不会尝试专注于特定的测试方法,例如测试驱动设计 (TDD) 或行为驱动设计 (BDD)。 相反,本文将重点介绍如何编写使用 ADO.NET 实体框架的代码,但仍可以轻松地按自动化的方式进行隔离和测试。 我们将探讨有助于在数据访问方案中进行测试的常见设计模式,并了解在使用框架时如何应用这些模式。 我们还将探讨框架的特定功能,以了解这些功能在可测试代码中的工作方式。

什么是可测试代码?

使用自动化单元测试来验证软件的功能提供了许多理想的好处。 大家都知道,良好的测试会减少应用程序中的软件缺陷数量,并提高应用程序的质量,但有了单元测试远远不只是找到 bug。

优秀的单元测试套件使开发团队能够节省时间,并仍可控制其创建的软件。 团队可以对现有代码进行更改、重构、重新设计和重构软件以满足新需求,并在知道测试套件可以验证应用程序行为的同时将新组件添加到应用程序中。 单元测试是快速反馈周期的一部分,用于在复杂性增加的情况中方便更改和保留软件的可维护性。

不过,单元测试有代价。 团队必须投入时间来创建和维护单元测试。 创建这些测试所需的工作量与基础软件的可测试性直接相关。 测试软件有多容易? 在设计软件时考虑到可测试性的团队将比使用无测试软件的团队更快地创建有效的测试。

Microsoft 设计了 ADO.NET 实体框架 4.0 (EF4) 时考虑到了可测试性。 这并不意味着开发人员将针对框架代码本身编写单元测试。 相反,EF4 的可测试性目标使得创建在框架之上构建的可测试代码变得简单。 在查看具体的示例之前,有必要了解可测试代码的质量。

可测试代码的质量

易于测试的代码总是表现出至少两个特征。 首先,可测试代码易于理解。 给定一组输入,应该很容易观察代码的输出。 例如,测试以下方法非常简单,因为该方法直接返回计算的结果。

    public int Add(int x, int y) {
        return x + y;
    }

如果方法将计算的值写入网络套接字、数据库表或像下面代码那样的文件中,则测试方法很难。 测试必须执行其他工作来检索值。

    public void AddAndSaveToFile(int x, int y) {
         var results = string.Format("The answer is {0}", x + y);
         File.WriteAllText("results.txt", results);
    }

其次,可测试代码易于隔离。 让我们使用以下伪代码作为可测试代码的错误示例。

    public int ComputePolicyValue(InsurancePolicy policy) {
        using (var connection = new SqlConnection("dbConnection"))
        using (var command = new SqlCommand(query, connection)) {

            // business calculations omitted ...               

            if (totalValue > notificationThreshold) {
                var message = new MailMessage();
                message.Subject = "Warning!";
                var client = new SmtpClient();
                client.Send(message);
            }
        }
        return totalValue;
    }

此方法很容易观察 - 我们可以传入保险单,并验证返回值是否与预期结果相符。 但是,若要测试方法,我们需要使用正确的架构安装一个数据库,并在该方法尝试发送电子邮件时配置 SMTP 服务器。

单元测试只需验证方法内部的计算逻辑,但测试可能会失败,因为电子邮件服务器处于脱机状态,或数据库服务器已移动。 这两个故障与测试要验证的行为无关。 此行为难以隔离。

努力编写可测试代码的软件开发者通常会在其编写的代码中保持关注点的分离。 上述方法应侧重于业务计算,并将数据库和电子邮件实现细节委托给其他组件。 Robert C. Martin 称之为单一责任原则。 对象应封装单一、狭窄的责任,如计算策略的值。 所有其他数据库和通知工作都应由其他对象负责。 以这种方式编写的代码更易于隔离,因为它侧重于单个任务。

在 .NET 中,我们有了需要遵循单一责任原则和实现隔离的抽象。 我们可以使用接口定义,并强制代码使用接口抽象,而不是具体类型。 在本文的后面部分,我们将了解类似于上面所示的错误示例方法如何使用将与数据库进行通信的接口。 但是在测试时,我们可以替换不与数据库通信而是在内存中保存数据的虚拟实现。 此虚拟实现将代码与数据访问代码或数据库配置中不相关的问题隔离开来。

隔离还有其他优势。 最后一种方法中的业务计算应该只需几毫秒就可以执行,但测试本身可能会运行几秒钟,因为代码会在网络中跳跃并与各种服务器通信。 单元测试应快速运行以辅助小型更改。 单元测试也应该可重复,并且不会因为与测试无关的组件存在问题而失败。 编写易于观察和隔离的代码意味着开发者可以更轻松地编写代码测试,花更少的时间等待测试执行,更重要的是,花更少的时间来跟踪不存在的 bug。

希望你可以体会到测试的好处,并理解可测试代码展示的质量。 我们将介绍如何编写适用于 EF4 的代码,以便将数据保存到数据库中,同时保留可观察性和易于隔离的代码,但首先我们将集中讨论数据访问的可测试设计。

数据持久性的设计模式

前面介绍的两个错误示例都具有太多的责任。 第一个错误示例必须执行计算并写入文件。 第二个错误示例必须从数据库中读取数据,执行业务计算并发送电子邮件。 通过设计可分隔问题并将责任委托给其他组件的较小方法,你将在编写可测试代码方面取得巨大进步。 目标是组合小型重点抽象的操作来构建功能。

当涉及数据持久性时,我们所要寻找的小型重点抽象非常常见。 Martin Fowler 的《企业应用架构模式》一书是第一本描述这些模式的书。 我们将在下面几节中对这些模式进行简要描述,然后展示这些 ADO.NET 实体框架如何实现并适用于这些模式。

存储库模式

Fowler 说,存储库“使用类似于集合的接口来访问域对象,从而在域和数据映射层之间进行协调”。 存储库模式的目标是将代码与数据访问的细枝末节隔离开来,正如我们之前看到的,隔离是可测试性一个必需特性。

隔离的关键是存储库如何使用类似集合的接口公开对象。 你编写的使用存储库的逻辑并不知道存储库将如何具体化你请求的对象。 存储库可能会与数据库进行通信,也可能只是从内存中集合返回对象。 你的所有代码只需要知道存储库似乎在维护集合,并且你可以从集合中检索、添加和删除对象。

在现有的 .NET 应用程序中,一个具体的存储库通常继承自如下所示的泛型接口:

    public interface IRepository<T> {       
        IEnumerable<T> FindAll();
        IEnumerable<T> FindBy(Expression<Func\<T, bool>> predicate);
        T FindById(int id);
        void Add(T newEntity);
        void Remove(T entity);
    }

当我们为 EF4 提供实现时,我们将对接口定义进行一些更改,但基本概念仍保持不变。 代码可以使用实现此接口的具体存储库,通过主键值检索实体,根据谓词的计算结果检索实体集合,或者只检索所有可用的实体。 代码还可以通过存储库接口添加和删除实体。

对于 Employee 对象的 IRepository,代码可以执行以下操作。

    var employeesNamedScott =
        repository
            .FindBy(e => e.Name == "Scott")
            .OrderBy(e => e.HireDate);
    var firstEmployee = repository.FindById(1);
    var newEmployee = new Employee() {/*... */};
    repository.Add(newEmployee);

由于代码使用一个接口(Employee 的 IRepository),我们可以为代码提供不同的接口实现。 一种实现可能是 EF4 支持的实现,并将对象保留到 Microsoft SQL Server 数据库中。 另一种实现(在测试过程中使用的)可能由内存中 Employee 对象的 List 支持。 此接口将帮助在代码中实现隔离。

请注意,IRepository<T> 接口不会公开 Save 操作。 如何更新现有对象? 你可能会遇到包含 Save 操作的 IRepository 定义,而这些存储库的实现需要立即将对象保留到数据库中。 但是,在许多应用程序中,我们不希望单独保留对象。 相反,我们想让对象活动起来(可能来自不同的存储库),并将这些对象修改为业务活动的一部分,然后将所有对象作为单个原子操作的一部分进行保留。 幸运的是,有一种模式可用于实现此类型的行为。

工作单元模式

Fowler 说,一个工作单元将“维护受业务事务影响的对象列表,并协调变化的写入和并发问题的解决方法”。 工作单元的责任是跟踪从存储库中使用的对象的更改,并在我们告知工作单元提交更改时保留对对象所做的任何更改。 工作单元还要负责获取我们添加到所有存储库的新对象,并将对象插入数据库,还负责管理删除。

如果你曾经使用过 ADO.NET 数据集完成任何工作,则你已经熟悉工作单元模式。 ADO.NET 数据集能够跟踪我们对 DataRow 对象的更新、删除和插入操作,并可以(借助 TableAdapter)协调对数据库所做的所有更改。 但是,DataSet 对象是对基础数据库的一个断开连接的子集进行建模。 工作单元模式展示了相同的行为,但它适用于与数据访问代码隔离且不知道数据库的业务对象和域对象。

对 .NET 代码中建模工作单元的抽象可能如下所示:

    public interface IUnitOfWork {
        IRepository<Employee> Employees { get; }
        IRepository<Order> Orders { get; }
        IRepository<Customer> Customers { get; }
        void Commit();
    }

通过从工作单元公开存储库引用,可以确保单个工作单元对象能够跟踪在业务事务中具体化的所有实体。 在实际工作单元中实现 Commit 方法是指运行所有 magic 以协调内存中更改与数据库。 

给定 IUnitOfWork 引用后,代码可以更改从一个或多个存储库中检索的业务对象,然后使用原子 Commit 操作保存所有更改。

    var firstEmployee = unitofWork.Employees.FindById(1);
    var firstCustomer = unitofWork.Customers.FindById(1);
    firstEmployee.Name = "Alex";
    firstCustomer.Name = "Christopher";
    unitofWork.Commit();

延迟加载模式

Fowler 使用延迟加载这一名称来描述“一个对象,该对象不包含你所需的全部数据,但知道如何获取它”。 透明延迟加载是编写可测试业务代码以及与关系数据库一起使用时的重要功能。 例如,考虑下面的代码。

    var employee = repository.FindById(id);
    // ... and later ...
    foreach(var timeCard in employee.TimeCards) {
        // .. manipulate the timeCard
    }

如何填充 TimeCards 集合? 有两种可能的答案。 一个答案是,当系统要求员工库提取员工时,员工存储库会发出一个查询来检索该员工以及该员工的相关考勤卡信息。 在关系数据库中,这通常需要一个包含 JOIN 子句的查询,并且可能会导致检索的信息超过应用程序需求。 如果应用程序从未需要接触 TimeCards 属性,该做什么?

第二个答案是“按需”加载 TimeCards 属性。 这种延迟加载对业务逻辑而言是隐式且透明的,因为代码不会调用特殊 API 来检索考勤卡信息。 该代码假定需要时提供考勤卡信息。 延迟加载包含一些 magic,通常涉及方法调用的运行时拦截。 拦截代码负责与数据库通信并检索考勤卡信息,同时使业务逻辑自由成为业务逻辑。 这种延迟加载 magic 允许业务代码将自身与数据检索操作隔离开来,并生成更具可测试性的代码。

延迟加载的缺点是,当应用程序确实需要考勤卡信息时,代码将执行其他查询。 对于许多应用程序来说,这不是一个问题,但对于性能敏感型应用程序或循环访问多个 Employee 对象并在循环的每个迭代期间执行查询以检索考勤卡(这一问题通常称为 N+1 查询问题)的应用程序,延迟加载是一种阻力。 在这些情况下,应用程序可能希望以尽可能高效的方式积极加载考勤卡信息。

幸运的是,当我们进入下一节并实现这些模式时,我们将看到 EF4 如何同时支持隐式延迟加载和高效预先加载。

使用实体框架实现模式

好消息是,我们在上一节中介绍的所有设计模式都直接使用 EF4 实现。 为了演示,我们将使用简单的 ASP.NET MVC 应用程序编辑和显示 Employee 及其关联的考勤卡信息。 我们将首先使用以下“普通旧 CLR 对象”(POCO)。 

    public class Employee {
        public int Id { get; set; }
        public string Name { get; set; }
        public DateTime HireDate { get; set; }
        public ICollection<TimeCard> TimeCards { get; set; }
    }

    public class TimeCard {
        public int Id { get; set; }
        public int Hours { get; set; }
        public DateTime EffectiveDate { get; set; }
    }

当我们了解 EF4 的不同方法和功能,这些类定义会略有不同,但目的是使这些类尽可能保持持久性无感知 (PI)。 PI 对象不知道它保存的状态如何存在于数据库中,甚至不知道它是否存在于数据库中。 PI 和 POCO 与可测试软件紧密相连。 使用 POCO 方法的对象受到的限制更少,更灵活且更易于测试,因为它们可以在没有数据库的情况下运行。

有了POCO,我们可以在 Visual Studio 中创建一个实体数据模型 (EDM)(见图 1)。 我们不会使用 EDM 为实体生成代码。 相反,我们想要使用我们精心创建的实体。 我们将仅使用 EDM 生成数据库架构,并提供将对象映射到数据库所需的元数据 EF4。

ef test_01

图 1

注意:如果要首先开发 EDM 模型,可以从 EDM 生成干净 POCO 代码。 可以使用数据可编程性团队提供的 Visual Studio 2010 扩展来实现这一目标。 若要下载扩展,请从 Visual Studio 中的“工具”菜单中启动“扩展管理器”,并搜索“POCO”的在线模板库(见图 2)。 EF 提供了多个 POCO 模板。 有关使用该模板的信息,请参阅“演练:适用于实体框架的 POCO 模板”。

ef test_02

图 2

从此 POCO 起点开始,我们将介绍两种不同的可测试代码方法。 第一种方法称为 EF 方法,因为它利用来自 实体框架 API 的抽象来实现工作单元和存储库。 第二种方法中,我们将创建自己的自定义存储库抽象,然后查看每种方法的优点和缺点。 首先,我们将介绍 EF 方法。  

以 EF 为中心的实现

请考虑以下 ASP.NET MVC 项目中的控制器操作。 该操作检索 Employee 对象并返回结果以显示该员工的详细视图。

    public ViewResult Details(int id) {
        var employee = _unitOfWork.Employees
                                  .Single(e => e.Id == id);
        return View(employee);
    }

代码是否可测试? 至少需要两个测试来验证操作的行为。 首先,我们希望验证操作是否返回正确的视图 - 这是一个简单的测试。 我们还想要编写一个测试来验证操作是否检索到正确的员工,并且我们希望在不执行代码来查询数据库的情况下执行该操作。 请记住,我们想要隔离测试中的代码。 隔离将确保测试不会因数据访问代码或数据库配置中的 bug 而失败。 如果测试失败,我们将知道控制器逻辑中存在 bug,而不是在某些较低级别的系统组件中。

若要实现隔离,我们需要一些抽象,如之前介绍的存储库和工作单元的接口。 请记住,存储库模式旨在协调域对象和数据映射层。 在此场景中,EF4 是数据映射层,并且已经提供了一个类似资源库的抽象,名为ObjectSet<T>(来自 System.Data.Objects 命名空间)。 接口定义如下所示。

    public interface IObjectSet<TEntity> :
                     IQueryable<TEntity>,
                     IEnumerable<TEntity>,
                     IQueryable,
                     IEnumerable
                     where TEntity : class
    {
        void AddObject(TEntity entity);
        void Attach(TEntity entity);
        void DeleteObject(TEntity entity);
        void Detach(TEntity entity);
    }

IObjectSet<T> 符合存储库的要求,因为它类似于一个对象的集合(通过 IEnumerable<T>),并提供了从模拟集合中添加和删除对象的方法。 Attach 和 Detach 方法公开 EF4 API 的其他功能。 若要使用 IObjectSet<T> 作为存储库的接口,我们需要一个工作单元抽象来将存储库绑定在一起。

    public interface IUnitOfWork {
        IObjectSet<Employee> Employees { get; }
        IObjectSet<TimeCard> TimeCards { get; }
        void Commit();
    }

此接口的一个具体实现将与 SQL Server 通信,并可使用 EF4 中的 ObjectContext 类轻松创建。 ObjectContext 类是 EF4 API 中的实际工作单元。

    public class SqlUnitOfWork : IUnitOfWork {
        public SqlUnitOfWork() {
            var connectionString =
                ConfigurationManager
                    .ConnectionStrings[ConnectionStringName]
                    .ConnectionString;
            _context = new ObjectContext(connectionString);
        }

        public IObjectSet<Employee> Employees {
            get { return _context.CreateObjectSet<Employee>(); }
        }

        public IObjectSet<TimeCard> TimeCards {
            get { return _context.CreateObjectSet<TimeCard>(); }
        }

        public void Commit() {
            _context.SaveChanges();
        }

        readonly ObjectContext _context;
        const string ConnectionStringName = "EmployeeDataModelContainer";
    }

使 IObjectSet<T> 活动起来就像调用 ObjectContext 对象的 CreateObjectSet 方法一样容易。 在后台,框架将使用我们在 EDM 中提供的元数据生成具体的 ObjectSet<T>。 我们将继续返回 IObjectSet<T> 接口,因为这将有助于保持客户端代码的可测试性。

此具体实现在生产中非常有用,但我们需要重点关注如何使用 IUnitOfWork 抽象来辅助测试。

测试替身

若要隔离控制器操作,我们需要能够在实际工作单元(由 ObjectContext 支持)和测试替身或“fake”工作单元(执行内存中操作)之间进行切换。 执行此类切换的常见方法是,不让 MVC 控制器实例化工作单元,而是将工作单元作为构造函数参数传递到控制器中。

    class EmployeeController : Controller {
      publicEmployeeController(IUnitOfWork unitOfWork)  {
          _unitOfWork = unitOfWork;
      }
      ...
    }

上述代码是依赖项注入的示例。 我们不允许控制器创建其依赖项(工作单元),而是将依赖项注入控制器中。 在 MVC 项目中,通常将自定义控制器工厂与控制反转 (IoC) 容器结合使用,以自动执行依赖项注入。 这些主题超出了本文的范围,但可以阅读本文末尾的参考文献来了解更多内容。

我们可用于测试的 fake 工作单元实现可能如下所示。

    public class InMemoryUnitOfWork : IUnitOfWork {
        public InMemoryUnitOfWork() {
            Committed = false;
        }
        public IObjectSet<Employee> Employees {
            get;
            set;
        }

        public IObjectSet<TimeCard> TimeCards {
            get;
            set;
        }

        public bool Committed { get; set; }
        public void Commit() {
            Committed = true;
        }
    }

请注意,fake 工作单元公开了 Commited 属性。 有时,将功能添加到有助于测试的 fake 类很有用。 在这种情况下,可检查 Commited 属性,轻松观察代码是否提交了工作单元。

我们还需要一个 fake IObjectSet<T> 来保存内存中的 Employee 和 TimeCard 对象。 可使用泛型提供单个实现。

    public class InMemoryObjectSet<T> : IObjectSet<T> where T : class
        public InMemoryObjectSet()
            : this(Enumerable.Empty<T>()) {
        }
        public InMemoryObjectSet(IEnumerable<T> entities) {
            _set = new HashSet<T>();
            foreach (var entity in entities) {
                _set.Add(entity);
            }
            _queryableSet = _set.AsQueryable();
        }
        public void AddObject(T entity) {
            _set.Add(entity);
        }
        public void Attach(T entity) {
            _set.Add(entity);
        }
        public void DeleteObject(T entity) {
            _set.Remove(entity);
        }
        public void Detach(T entity) {
            _set.Remove(entity);
        }
        public Type ElementType {
            get { return _queryableSet.ElementType; }
        }
        public Expression Expression {
            get { return _queryableSet.Expression; }
        }
        public IQueryProvider Provider {
            get { return _queryableSet.Provider; }
        }
        public IEnumerator<T> GetEnumerator() {
            return _set.GetEnumerator();
        }
        IEnumerator IEnumerable.GetEnumerator() {
            return GetEnumerator();
        }

        readonly HashSet<T> _set;
        readonly IQueryable<T> _queryableSet;
    }

此测试替身将其大部分工作委托基础 HashSet<T> 对象。 请注意,IObjectSet<T> 需要一个将 T 强制执行为类(一个引用类型)的泛型约束,并强制实现 IQueryable<T>。 使用标准 LINQ 运算符 AsQueryable,可轻松使内存中集合显示为 IQueryable<T>。

测试

传统单元测试将使用单个测试类保存单个 MVC 控制器中所有操作的所有测试。 可以使用我们构建的内存中 fakes 编写这些测试或任何类型的单元测试。 但是,在本文中,我们将避免使用整体式测试类方法,而是将测试分组以专注于特定功能部分。  例如“创建新员工”可能是我们想要测试的功能,因此我们将使用单个测试类来验证负责创建新员工的单个控制器操作。

所有这些精细测试类都需要一些常见的安装代码。 例如,我们始终需要创建内存中存储库和 fake 工作单元。 我们还需要一个注入了 fake 工作单元的员工控制器实例。 我们将使用基类在测试类之间共享此通用安装代码。

    public class EmployeeControllerTestBase {
        public EmployeeControllerTestBase() {
            _employeeData = EmployeeObjectMother.CreateEmployees()
                                                .ToList();
            _repository = new InMemoryObjectSet<Employee>(_employeeData);
            _unitOfWork = new InMemoryUnitOfWork();
            _unitOfWork.Employees = _repository;
            _controller = new EmployeeController(_unitOfWork);
        }

        protected IList<Employee> _employeeData;
        protected EmployeeController _controller;
        protected InMemoryObjectSet<Employee> _repository;
        protected InMemoryUnitOfWork _unitOfWork;
    }

我们在基类中使用的“对象母体”是创建测试数据的一种常见模式。 对象母体包含实例化测试实体以在多个测试装置上使用的工厂方法。

    public static class EmployeeObjectMother {
        public static IEnumerable<Employee> CreateEmployees() {
            yield return new Employee() {
                Id = 1, Name = "Scott", HireDate=new DateTime(2002, 1, 1)
            };
            yield return new Employee() {
                Id = 2, Name = "Poonam", HireDate=new DateTime(2001, 1, 1)
            };
            yield return new Employee() {
                Id = 3, Name = "Simon", HireDate=new DateTime(2008, 1, 1)
            };
        }
        // ... more fake data for different scenarios
    }

可以使用 EmployeeControllerTestBase 作为许多测试固定例程的基类(见图 3)。 每个测试固定例程将测试特定的控制器操作。 例如,一个测试固定例程将侧重于测试 HTTP GET 请求期间使用的 Create 操作(以显示用于创建员工的视图),另一个固定例程侧重于 HTTP POST 请求中使用的 Create 操作(以获取用户提交的信息来创建员工)。 每个派生类仅负责其特定上下文中所需的设置,并提供验证其特定测试上下文的结果所需的断言。

ef test_03

图 3

此处介绍的命名约定和测试样式不是可测试代码所必需的 - 它只是一种方法。 图 4 显示了在适用于 Visual Studio 2010 的 Jet Brains Resharper 测试运行程序插件中运行的测试。

ef test_04

图 4

使用基类来处理共享的安装代码,每个控制器操作的单元测试都很小,并且易于编写。 这些测试将快速执行(因为我们正在执行内存中操作),并且不会因为无关的基础结构或环境问题而失败(因为我们已隔离了测试中的单元)。

    [TestClass]
    public class EmployeeControllerCreateActionPostTests
               : EmployeeControllerTestBase {
        [TestMethod]
        public void ShouldAddNewEmployeeToRepository() {
            _controller.Create(_newEmployee);
            Assert.IsTrue(_repository.Contains(_newEmployee));
        }
        [TestMethod]
        public void ShouldCommitUnitOfWork() {
            _controller.Create(_newEmployee);
            Assert.IsTrue(_unitOfWork.Committed);
        }
        // ... more tests

        Employee _newEmployee = new Employee() {
            Name = "NEW EMPLOYEE",
            HireDate = new System.DateTime(2010, 1, 1)
        };
    }

在这些测试中,基类执行大部分的设置工作。 请记住,基类构造函数会创建内存中存储库、fake 工作单元和 EmployeeController 类的实例。 该测试类从此基类派生,侧重于测试 Create 方法的具体信息。 在此情况下,具体信息可归结伪“安排、行动和断言”步骤,你会在任意单元测试过程中看到:

  • 创建用于模拟传入数据的 newEmployee 对象。
  • 调用 EmployeeController 的 Create 操作并传入 newEmployee。
  • 验证 Create 操作是否产生预期的结果(员工出现在存储库中)。

我们生成的内容允许测试任何 EmployeeController 操作。 例如,当我们为 Employee 控制器的 Index 操作编写测试时,可以从测试基类继承,为测试建立相同的基本设置。 同样,基类会创建内存中存储库、fake 工作单元和 EmployeeController 的实例。 针对 Index 操作的测试只需侧重于调用 Index 操作并测试操作返回的模型的质量。

    [TestClass]
    public class EmployeeControllerIndexActionTests
               : EmployeeControllerTestBase {
        [TestMethod]
        public void ShouldBuildModelWithAllEmployees() {
            var result = _controller.Index();
            var model = result.ViewData.Model
                          as IEnumerable<Employee>;
            Assert.IsTrue(model.Count() == _employeeData.Count);
        }
        [TestMethod]
        public void ShouldOrderModelByHiredateAscending() {
            var result = _controller.Index();
            var model = result.ViewData.Model
                         as IEnumerable<Employee>;
            Assert.IsTrue(model.SequenceEqual(
                           _employeeData.OrderBy(e => e.HireDate)));
        }
        // ...
    }

用内存中 fakes 创建的测试面向测试软件的状态。 例如,在测试 Create 操作时,我们想要检查执行 Create 操作后存储库的状态 - 存储库是否保留新员工?

    [TestMethod]
    public void ShouldAddNewEmployeeToRepository() {
        _controller.Create(_newEmployee);
        Assert.IsTrue(_repository.Contains(_newEmployee));
    }

稍后我们将探讨基于交互的测试。 基于交互的测试会询问所测试的代码是否在对象上调用了正确的方法,并传递了正确的参数。 现在,我们将继续介绍另一种设计模式 - 延迟加载。

预先加载和延迟加载

在使用 ASP.NET MVC Web 应用的某个时候,我们可能希望显示员工的信息,并包括员工的相关考勤卡。 例如,我们可能会有一个考勤卡摘要显示,其中显示了雇员的姓名和系统中考勤卡的总数。 可以采用几种方法实现此功能。

投影

创建摘要的一种简单方法是构造专用于要在视图中显示的信息的模型。 在这种情况下,该模型可能如下所示。

    public class EmployeeSummaryViewModel {
        public string Name { get; set; }
        public int TotalTimeCards { get; set; }
    }

请注意,EmployeeSummaryViewModel 不是一个实体,换言之,我们不会在数据库中保留它。 我们只会使用此类以强类型方式将数据随机排列到视图中。 视图模型类似于数据传输对象 (DTO),因为它不包含任何行为(任何方法)- 只包含属性。 这些属性将保留需要移动的数据。 使用 LINQ 的标准投影运算符(Select 运算符),可以轻松地实例化此视图模型。

    public ViewResult Summary(int id) {
        var model = _unitOfWork.Employees
                               .Where(e => e.Id == id)
                               .Select(e => new EmployeeSummaryViewModel
                                  {
                                    Name = e.Name,
                                    TotalTimeCards = e.TimeCards.Count()
                                  })
                               .Single();
        return View(model);
    }

上述代码有两个值得注意的功能。 首先,代码易于测试,因为它仍易于观察和隔离。 Select 运算符在处理内存中 fakes 时和在处理实际工作单元时一样有效。

    [TestClass]
    public class EmployeeControllerSummaryActionTests
               : EmployeeControllerTestBase {
        [TestMethod]
        public void ShouldBuildModelWithCorrectEmployeeSummary() {
            var id = 1;
            var result = _controller.Summary(id);
            var model = result.ViewData.Model as EmployeeSummaryViewModel;
            Assert.IsTrue(model.TotalTimeCards == 3);
        }
        // ...
    }

第二个值得注意的功能是代码如何允许 EF4 生成单一且高效的查询,以将员工和考勤卡信息组合在一起。 我们已将员工信息和考勤卡信息加载到同一个对象中,而无需使用任何特殊的 API。 此代码仅使用标准 LINQ 运算符(适用于内存中数据源以及远程数据源)来表示所需的信息。 EF4 能够将 LINQ 查询和 C# 编译器生成的表达式树转换为单一且高效的 SQL 查询。

    SELECT
    [Limit1].[Id] AS [Id],
    [Limit1].[Name] AS [Name],
    [Limit1].[C1] AS [C1]
    FROM (SELECT TOP (2)
      [Project1].[Id] AS [Id],
      [Project1].[Name] AS [Name],
      [Project1].[C1] AS [C1]
      FROM (SELECT
        [Extent1].[Id] AS [Id],
        [Extent1].[Name] AS [Name],
        (SELECT COUNT(1) AS [A1]
         FROM [dbo].[TimeCards] AS [Extent2]
         WHERE [Extent1].[Id] =
               [Extent2].[EmployeeTimeCard_TimeCard_Id]) AS [C1]
              FROM [dbo].[Employees] AS [Extent1]
               WHERE [Extent1].[Id] = @p__linq__0
         )  AS [Project1]
    )  AS [Limit1]

在其他一些情况下,我们不想使用视图模型或 DTO 对象,而是使用实际实体。 当我们知道我们需要员工和员工的考勤卡时,可以积极以不引人注目和高效的方式预先加载相关数据

显式预先加载

当我们想要预先加载相关实体信息时,我们需要一些机制来实现业务逻辑(或在此方案中为控制器操作逻辑),以向存储库表达其需求。 EF4 ObjectQuery<T> 类定义了一个 Include 方法,以指定查询期间要检索的相关对象。 请记住,EF4 ObjectContext 通过具体的 ObjectSet<T> 类公开实体,该类继承于 ObjectQuery<T>。  如果我们在控制器操作中使用 ObjectSet<T> 引用,则可以编写以下代码为每个员工指定考勤卡信息的预先加载。

    _employees.Include("TimeCards")
              .Where(e => e.HireDate.Year > 2009);

不过,由于我们试图保持代码的可测试性,因此我们不会从实际工作单元类公开 ObjectSet<T>。 相反,我们依赖于 IObjectSet<> 接口,该接口更易于虚设,但 IObjectSet<> 未定义 Include 方法。 LINQ 的魅力在于我们可以创建自己的 Include 运算符。

    public static class QueryableExtensions {
        public static IQueryable<T> Include<T>
                (this IQueryable<T> sequence, string path) {
            var objectQuery = sequence as ObjectQuery<T>;
            if(objectQuery != null)
            {
                return objectQuery.Include(path);
            }
            return sequence;
        }
    }

请注意,此 Include 运算符定义为 IQueryable<T>而不是 IObjectSet t 的扩展方法<T>。 这使我们能够将方法用于范围更广的可能类型,包括 IQueryable<T>、IObjectSet<T>、ObjectQuery<T> 和 ObjectSet<T>。 如果基础序列不是真正的 EF4 ObjectQuery<T>,则不会有任何损害,而且 Include 运算符为 no-op(不执行任何操作)。 如果基础序列是 ObjectQuery<T>(或派生自 ObjectQuery<T>),则 EF4 将看到我们对其他数据的要求,并制定适当的 SQL 查询

使用这个新的运算符,我们可以从存储库显式请求预先加载考勤卡信息。

    public ViewResult Index() {
        var model = _unitOfWork.Employees
                               .Include("TimeCards")
                               .OrderBy(e => e.HireDate);
        return View(model);
    }

针对实际 ObjectContext 运行时,代码将生成以下单一查询。 该查询一次就从数据库中收集了足够多的信息,以具体化 Employee 对象并完全填充其 TimeCards 属性。

    SELECT
    [Project1].[Id] AS [Id],
    [Project1].[Name] AS [Name],
    [Project1].[HireDate] AS [HireDate],
    [Project1].[C1] AS [C1],
    [Project1].[Id1] AS [Id1],
    [Project1].[Hours] AS [Hours],
    [Project1].[EffectiveDate] AS [EffectiveDate],
    [Project1].[EmployeeTimeCard_TimeCard_Id] AS [EmployeeTimeCard_TimeCard_Id]
    FROM ( SELECT
         [Extent1].[Id] AS [Id],
         [Extent1].[Name] AS [Name],
         [Extent1].[HireDate] AS [HireDate],
         [Extent2].[Id] AS [Id1],
         [Extent2].[Hours] AS [Hours],
         [Extent2].[EffectiveDate] AS [EffectiveDate],
         [Extent2].[EmployeeTimeCard_TimeCard_Id] AS
                    [EmployeeTimeCard_TimeCard_Id],
         CASE WHEN ([Extent2].[Id] IS NULL) THEN CAST(NULL AS int)
         ELSE 1 END AS [C1]
         FROM  [dbo].[Employees] AS [Extent1]
         LEFT OUTER JOIN [dbo].[TimeCards] AS [Extent2] ON [Extent1].[Id] = [Extent2].[EmployeeTimeCard_TimeCard_Id]
    )  AS [Project1]
    ORDER BY [Project1].[HireDate] ASC,
             [Project1].[Id] ASC, [Project1].[C1] ASC

好消息是,操作方法中的代码仍然完全可测试。 我们不需要为 fakes 提供任何其他功能来支持 Include 运算符。 坏消息是,我们必须在要保持持久性无感知的代码内使用 Include 运算符。 这是生成可测试代码时需要评估的权衡类型的一个典型示例。 有时,你需要让持久性问题泄露到存储库抽象之外以满足性能目标。

预先加载的替代方法是延迟加载。 延迟加载意味着我们不需要业务代码来显式声明对关联数据的要求。 相反,我们在应用程序中使用实体,如果需要其他数据,实体框架将按需加载数据。

延迟加载

这种情况很容易想象到:我们不知道业务逻辑的一部分需要哪些数据。 我们可能知道逻辑需要一个 对象,但我们可能会将其分支到不同的执行路径,其中某些路径需要员工的考勤卡信息,而另一些则不需要。 这种情况非常适合隐式延迟加载,因为数据神奇会在需要的时候神奇地出现。

延迟加载(也称为推迟加载)确实对实体对象有一些要求。 具有真正持久性无感知的 POCO 不会面临来自持久性层的任何要求,但真正的持久性无感知根本无法实现。  相反,我们会以相对程度来衡量持久性无感知。 如果需要从面向持久性的基类继承,或使用专用集合在 POCO 中实现延迟加载,则会很遗憾。 幸运的是,EF4 有一个较少干扰的解决方案。

几乎检测不到

使用 POCO 对象时,EF4 可以为实体动态生成运行时代理。 这些代理以不可见的方式包装具体化的 POCO,并拦截每个属性 get 和 set 操作来执行其他工作,从而提供其他服务。 其中一项服务是我们要查找的延迟加载功能。 另一项服务是一种有效的更改跟踪机制,可以在程序更改实体的属性值时进行记录。 在 SaveChanges 方法期间 ObjectContext 使用更改列表,以使用 UPDATE 命令来保留所有已修改的实体。

但是,若要使这些代理正常工作,它们需要有一种方法连接到实体上的属性 get 和 set 操作,并且代理通过重写虚拟成员来实现此目标。 因此,如果想拥有隐式延迟加载和高效的更改跟踪,则需要返回到 POCO 类定义并将属性标记为虚拟。

    public class Employee {
        public virtual int Id { get; set; }
        public virtual string Name { get; set; }
        public virtual DateTime HireDate { get; set; }
        public virtual ICollection<TimeCard> TimeCards { get; set; }
    }

我们仍然可以说,Employee 实体主要是持久性无感知。 唯一的要求是使用虚拟成员,这不会影响代码的可测试性。 不需要从任何特殊基类派生,甚至不需要使用专用于延迟加载的特殊集合。 如代码所示,任何实现 ICollection<T> 的类都可用于保存相关实体。

还需要在我们的工作单元内进行一个小的更改。 当直接使用 ObjectContext 对象时,延迟加载默认情况下处于关闭状态。 可以在 ContextOptions 属性上设置一个属性来启用延迟加载,如果我们想要在任何位置启用延迟加载,则可以在实际工作单元内设置此属性。

    public class SqlUnitOfWork : IUnitOfWork {
         public SqlUnitOfWork() {
             // ...
             _context = new ObjectContext(connectionString);
             _context.ContextOptions.LazyLoadingEnabled = true;
         }    
         // ...
     }

启用隐式延迟加载后,应用程序代码可以使用员工和员工的相关考勤卡,同时丝毫不知道 EF 加载额外数据所需的工作。

    var employee = _unitOfWork.Employees
                              .Single(e => e.Id == id);
    foreach (var card in employee.TimeCards) {
        // ...
    }

延迟加载使得应用程序代码更易于编写,并且借助代理 magic,代码仍然完全可测试。 工作单元的内存中 fakes 只需在测试过程中需要时仅预加载带有关联数据的 fake 实体。

此时,我们将把注意力从使用 IObjectSet<T> 构建存储库转移到以隐藏持久性框架的所有标志的抽象上。

自定义存储库

当我们在本文中第一次介绍工作单元设计模式时提供了一些示例代码,说明工作单元的外观。 让我们使用一直在使用的员工和员工考勤卡方案来重新展示这一原始思路。

    public interface IUnitOfWork {
        IRepository<Employee> Employees { get; }
        IRepository<TimeCard> TimeCards { get;  }
        void Commit();
    }

此工作单元和我们在上一节中创建的工作单元之间的主要区别在于,此工作单元不使用 EF4 框架中的任何抽象(没有 IObjectSet<T>)。 IObjectSet<T> 作为存储库接口运行良好,但它公开的 API 可能并不完全符合应用程序的需求。 在这个即将推出的方法中,我们将使用自定义 IRepository T<T> 抽象来表示存储库。

许多遵循测试驱动设计、行为驱动设计和领域驱动方法设计的开发者出于多种原因倾向于使用 IRepository<T> 方法。 首先,IRepository<T> 接口代表了一个“防损坏”层。 如 Eric Evans 在他的《领域驱动设计》一书中所述,防损坏层使域代码与基础结构 API(如持久性 API)保持距离。 其次,开发者可以将满足应用程序确切需求的方法(在编写测试时发现)构建到存储库中。 例如,我们可能需要经常使用 ID 值查找单个实体,因此可以将 FindById 方法添加到存储库接口。  IRepository<T> 定义将如下所示。

    public interface IRepository<T>
                    where T : class, IEntity {
        IQueryable<T> FindAll();
        IQueryable<T> FindWhere(Expression<Func\<T, bool>> predicate);
        T FindById(int id);       
        void Add(T newEntity);
        void Remove(T entity);
    }

注意,我们将回到使用 IQueryable<T> 接口来公开实体集合。 IQueryable<T> 允许 LINQ 表达式树流入 EF4 提供程序,为提供程序提供一个整体的查询视图。 第二个选项是返回 IEnumerable<T>,这意味着 EF4 LINQ 提供程序将只看到在存储库中构建的表达式。 在存储库之外执行的任何分组、排序和投影都不会组合发送到数据库的 SQL 命令中,这可能会损害性能。 另一方面,仅返回 IEnumerable<T> 结果的存储库永远不会让你惊讶于新的 SQL 命令。 这两种方法都可行,并且两种方法都保持可测试状态。

使用泛型和 EF4 ObjectContext API 提供 IRepository<T> 接口的单一实现非常简单。

    public class SqlRepository<T> : IRepository<T>
                                    where T : class, IEntity {
        public SqlRepository(ObjectContext context) {
            _objectSet = context.CreateObjectSet<T>();
        }
        public IQueryable<T> FindAll() {
            return _objectSet;
        }
        public IQueryable<T> FindWhere(
                               Expression<Func\<T, bool>> predicate) {
            return _objectSet.Where(predicate);
        }
        public T FindById(int id) {
            return _objectSet.Single(o => o.Id == id);
        }
        public void Add(T newEntity) {
            _objectSet.AddObject(newEntity);
        }
        public void Remove(T entity) {
            _objectSet.DeleteObject(entity);
        }
        protected ObjectSet<T> _objectSet;
    }

IRepository<T> 方法为我们提供了对查询的一些附加控制,因为客户端必须调用方法来访问实体。 在此方法中,我们可以提供其他检查和 LINQ 运算符来强制实施应用程序约束。 请注意,该接口对泛型类型参数有两个约束。 第一个约束是 ObjectSet<T> 所需的类常量,第二个约束强制实体实现 IEntity - 为应用程序创建的抽象。 IEntity 接口强制实体具有可读的 Id 属性,然后我们可以在 FindById 方法中使用此属性。 IEntity 通过以下代码定义。

    public interface IEntity {
        int Id { get; }
    }

IEntity 可能被视为对持久性无感知的一个小冲突,因为我们的实体需要实现此接口。 请记住,持久性无感知是一种权衡,对于许多人来说,FindById 功能将超过接口施加的约束。 该接口对可测试性没有影响。

实例化实时 IRepository<T> 需要 EF4 ObjectContext,因此具体的工作实现单元应管理实例化。

    public class SqlUnitOfWork : IUnitOfWork {
        public SqlUnitOfWork() {
            var connectionString =
                ConfigurationManager
                    .ConnectionStrings[ConnectionStringName]
                    .ConnectionString;

            _context = new ObjectContext(connectionString);
            _context.ContextOptions.LazyLoadingEnabled = true;
        }

        public IRepository<Employee> Employees {
            get {
                if (_employees == null) {
                    _employees = new SqlRepository<Employee>(_context);
                }
                return _employees;
            }
        }

        public IRepository<TimeCard> TimeCards {
            get {
                if (_timeCards == null) {
                    _timeCards = new SqlRepository<TimeCard>(_context);
                }
                return _timeCards;
            }
        }

        public void Commit() {
            _context.SaveChanges();
        }

        SqlRepository<Employee> _employees = null;
        SqlRepository<TimeCard> _timeCards = null;
        readonly ObjectContext _context;
        const string ConnectionStringName = "EmployeeDataModelContainer";
    }

使用自定义存储库

使用自定义存储库与使用基于 IObjectSet<T> 的存储库没有明显区别。 我们首先需要调用存储库的某个方法来获取 IQueryable<T> 引用,而不是将 LINQ 运算符直接应用于属性。

    public ViewResult Index() {
        var model = _repository.FindAll()
                               .Include("TimeCards")
                               .OrderBy(e => e.HireDate);
        return View(model);
    }

请注意,我们之前实现的自定义 Include 运算符无需更改即可正常工作。 存储库的 FindById 方法从尝试检索单个实体的操作中删除重复的逻辑。

    public ViewResult Details(int id) {
        var model = _repository.FindById(id);
        return View(model);
    }

我们检查的两种方法的可测试性没有显著差异。 我们可以生成 HashSet<Employee> 支持的具体类来提供 IRepository<T> 的 fake 实现,就像我们在上一节中所做的那样。 但是,某些开发者更喜欢使用 mock 对象和 mock 对象框架,而不是生成 fakes。 我们将在下一节介绍使用 mock 测试实现,并讨论 mock 和 fakes 之间的差异。

使用 Mocks 进行测试

有不同的方法来构建 Martin Fowler 称之为“测试替身”的方法。 测试替身(像电影特技替身一样)是在测试期间构建为“代替”真实生产对象的对象。 我们创建的内存中存储库是与 SQL Server 通信的存储库的测试替身。 我们已了解如何在单元测试期间使用这些测试替身来隔离代码并保持测试快速运行。

我们构建的测试替身具有真实有效的实现。 在后台,每一个都存储一个具体的对象集合,当我们在测试期间操作存储库时,它们将从此集合中添加和删除对象。 一些开发者喜欢用这种方式来构建他们的测试替身 - 使用实际代码和工作实现。  这些测试替身就是我们所说的 fakes。 它们具有可正常工作的实现,但不足以用于生产。 fake 存储库实际上不会写入数据库。 fake SMTP 服务器实际上不会通过网络发送电子邮件。

Mocks 与 Fakes

还有一种类型的测试被称为 mock。 虽然 fakes 具有可正常工作的实现,但 mock 没有实现。 在模拟对象框架的帮助下,我们运行时构造这些模拟对象,并将他们用作测试替身。 在本节中,我们将使用开放源代码模拟框架 Moq。 下面是使用 Moq 为员工存储库动态创建测试替身的简单示例。

    Mock<IRepository<Employee>> mock =
        new Mock<IRepository<Employee>>();
    IRepository<Employee> repository = mock.Object;
    repository.Add(new Employee());
    var employee = repository.FindById(1);

我们要求 Moq 提供 IRepository<> 实现,它就动态生成一个。 我们可以访问 Mock<T> 对象的 Object 属性来获得实现 IRepository<Employee> 的对象。 我们可以将这个内部对象传递给控制器,它们不知道这是测试替身存储库还是真正的存储库。 我们可以调用对象上的方法,就像在具有实际实现的对象上调用方法一样。

你一定想知道我们调用 Add 方法时,模拟存储库将执行什么操作。 由于 mock 对象后面没有实现,因此 Add 不执行任何操作。 后台没有像我们所编写的 fakes 那样的具体集合,因此该员工被丢弃。 FindById 的返回值如何? 在这种情况下,mock 对象只执行它可以执行的唯一操作,即返回默认值。 由于我们要返回引用类型 (Employee),因此返回值为 NULL 值。

Mocks 可能听起来毫无价值;但是,我们尚未讨论 mocks 的另外两个功能。 首先,Moq 框架记录对 mock 对象进行的所有调用。 稍后在代码中,我们可以询问 Moq 是否有人调用了 Add 方法,或者是否有人调用了 FindById 方法。 稍后我们将了解如何在测试中使用此“黑盒”记录功能。

第二个出色的功能是如何使用 Moq 对具有预期的 mock 对象进行编程。 预期告知 mock 对象如何响应任何给定的交互。 例如,我们可以将预期编程到 mock 中,并告知它在有人调用 FindById 时返回一个 Employee 对象。 Moq 框架使用 Setup API 和 Lambda 表达式来对这些预期进行编程。

    [TestMethod]
    public void MockSample() {
        Mock<IRepository<Employee>> mock =
            new Mock<IRepository<Employee>>();
        mock.Setup(m => m.FindById(5))
            .Returns(new Employee {Id = 5});
        IRepository<Employee> repository = mock.Object;
        var employee = repository.FindById(5);
        Assert.IsTrue(employee.Id == 5);
    }

在此示例中,我们要求 Moq 动态构建存储库,然后根据预期对存储库进行编程。 当有人调用 FindById 方法传递值 5 时,预期会告知 mock 对象返回 ID 值为 5 的新 Employee 对象。 此测试通过了,我们不需要构建一个完整实现来生成 fake IRepository<T>。

让我们重新访问之前编写的测试,并修改它们以使用 mocks 而不是 fakes。 与之前一样,我们将使用基类来设置所有控制器测试所需的常见基础结构部分。

    public class EmployeeControllerTestBase {
        public EmployeeControllerTestBase() {
            _employeeData = EmployeeObjectMother.CreateEmployees()
                                                .AsQueryable();
            _repository = new Mock<IRepository<Employee>>();
            _unitOfWork = new Mock<IUnitOfWork>();
            _unitOfWork.Setup(u => u.Employees)
                       .Returns(_repository.Object);
            _controller = new EmployeeController(_unitOfWork.Object);
        }

        protected IQueryable<Employee> _employeeData;
        protected Mock<IUnitOfWork> _unitOfWork;
        protected EmployeeController _controller;
        protected Mock<IRepository<Employee>> _repository;
    }

安装代码大部分保持不变。 我们将使用 Moq 来构造 mock 对象,而不是使用 fakes。 当代码调用 Employees 属性时,基类将安排 mock 工作单元返回 mock 存储库。 mock 设置的其余部分将在专用于每个特定方案的测试固定例程内进行。 例如,当操作调用 mock 存储库的 FindAll 方法时,Index 操作的测试固定例程将设置 mock 存储库以返回员工列表。

    [TestClass]
    public class EmployeeControllerIndexActionTests
               : EmployeeControllerTestBase {
        public EmployeeControllerIndexActionTests() {
            _repository.Setup(r => r.FindAll())
                        .Returns(_employeeData);
        }
        // .. tests
        [TestMethod]
        public void ShouldBuildModelWithAllEmployees() {
            var result = _controller.Index();
            var model = result.ViewData.Model
                          as IEnumerable<Employee>;
            Assert.IsTrue(model.Count() == _employeeData.Count());
        }
        // .. and more tests
    }

除了预期之外,我们的测试看起来与之前的测试类似。 但是,借助 mock 框架的记录功能,我们可以从不同的角度进行测试。 我们将在下一节介绍这一新角度。

状态与交互测试

可以使用不同的技术通过 mock 对象测试软件。 一种方法是使用基于状态的测试,这是我们到目前为止在本文中所做的。 基于状态的测试对软件的状态进行断言。 在上一个测试中,我们在控制器上调用了操作方法,并针对它应构建的模型进行了断言。 下面是其他一些测试状态示例:

  • 验证 Create 执行后存储库是否包含新 Employee 对象。
  • 验证 Index 执行后模型是否保留所有员工的列表。
  • 验证 Delete 执行后存储库是否不包含给定的员工。

使用 mock 对象的另一种方法是验证交互。 虽然基于状态的测试对对象的状态进行断言,但基于交互的测试会对对象的交互方式进行断言。 例如:

  • 验证 Create 执行时控制器是否调用存储库的 Add 方法。
  • 验证 Index 执行时控制器是否调用存储库的 FindAll 方法。
  • 验证 Edit 执行时控制器是否调用工作单元的 Commit 方法以保存更改。

交互测试通常需要较少的测试数据,因为我们不会在集合内部进行探查并验证计数。 例如,如果我们知道 Details 操作使用正确的值调用存储库的 FindById 方法,则操作可能正常运行。 我们可以验证此行为,而无需设置任何测试数据以从 FindById 返回。

    [TestClass]
    public class EmployeeControllerDetailsActionTests
               : EmployeeControllerTestBase {
         // ...
        [TestMethod]
        public void ShouldInvokeRepositoryToFindEmployee() {
            var result = _controller.Details(_detailsId);
            _repository.Verify(r => r.FindById(_detailsId));
        }
        int _detailsId = 1;
    }

上述测试固定例程中所需的唯一设置是基类提供的设置。 调用控制器操作时,Moq 将记录与 mock 存储库的交互。 使用 Moq 的 Verify API,我们可以询问 Moq 控制器是否使用正确的 ID 值调用了 FindById。 如果控制器未调用此方法,或者使用意外的参数值调用了此方法,则 Verify 方法将引发异常,测试将失败。

下面是验证 Create 操作在当前工作单元上调用 Commit 的另一个示例。

    [TestMethod]
    public void ShouldCommitUnitOfWork() {
        _controller.Create(_newEmployee);
        _unitOfWork.Verify(u => u.Commit());
    }

交互测试的一个危险是倾向于过度指定交互。 mock 对象能够记录和验证与 mock 对象的每一个交互并不意味着测试应尝试验证每一个交互。 某些交互是实现详细信息,应仅验证满足当前测试所需的交互

mocks 或 fakes 之间的选择主要取决于要测试的系统,以及你的个人(或团队)的偏好。 Mock 对象可以大大减少实现测试替身所需的代码量,但并非每个人都熟悉编程预期和验证交互。

结论

本文演示了几种方法,可在创建可测试代码的同时使用 ADO.NET 实体框架实现数据持久性。 我们可以利用内置的抽象,如 IObjectSet<T>,或者创建自己的抽象,如 IRepository<T>。  在这两种情况下,ADO.NET 实体框架 4.0 中的 POCO 支持都允许这些抽象的使用者保持持久性无感知且高度可测试。 隐式延迟加载等其他 EF4 功能允许业务和应用程序服务代码正常工作,而无需担心关系数据存储的详细信息。 最后,我们创建的抽象很容易在单元测试中进行模拟或虚设,我们可以使用这些测试替身来实现快速运行、高度隔离和可靠的测试。

其他资源

传记

Scott Allen 是 Pluralsight 的一名技术人员,也是 OdeToCode.com 的创始人。 在 15 年的商业软件开发生涯中,Scott 致力于开发从 8 位嵌入式设备到高度可缩放的 ASP.NET Web 应用的各种解决方案。 可以通过他在 OdeToCode 上的博客或在 Twitter https://twitter.com/OdeToCode 上联系 Scott。