通过


可测试性和 Entity Framework 4.0

斯科特·艾伦

发布时间: 2010 年 5 月

介绍

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

什么是可测试代码?

使用自动化单元测试验证软件的功能提供了许多理想的优势。 每个人都知道,良好的测试将减少应用程序中的软件缺陷数量并增加应用程序的质量,但建立单元测试远远超出了发现错误的范围。

良好的单元测试套件允许开发团队节省时间并控制他们创建的软件。 团队可以更改现有代码、重构、重新设计和重构软件以满足新要求,并在了解测试套件的同时将新组件添加到应用程序中,同时知道测试套件可以验证应用程序的行为。 单元测试是快速反馈周期的一部分,有助于更改,并在复杂性增加时保留软件的可维护性。

但是,单元测试是有代价的。 团队必须投入时间来创建和维护单元测试。 创建这些测试所需的工作量与基础软件的 可测试性 直接相关。 软件测试的简单程度如何? 设计具有可测试性的软件的团队将比使用不可测试软件的团队更快地创建有效的测试。

Microsoft设计了 ADO.NET Entity Framework 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 服务器。

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

努力编写可测试代码的软件开发人员通常会努力在编写的代码中保持关注点分离。 上述方法应侧重于业务计算,并将数据库和电子邮件实现详细信息委托给其他组件。 罗伯特·马丁称之为单一责任原则。 对象应封装单一、明确的责任,例如计算政策的价值。 所有其他数据库和通知工作都应由其他一些对象负责。 以这种方式编写的代码更易于隔离,因为它侧重于单个任务。

在 .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 数据库中。 不同的实现(我们在测试时使用的)可能由内存中的员工对象列表作为支持。 该接口有助于在代码中实现隔离。

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

工作单位模式

Fowler说,一个工作单元将“维护受业务事务影响的对象列表,并协调更改的写出和并发问题的解决”。 工作单元负责跟踪我们从存储库实例化的对象的更改,并在我们指示工作单元提交更改时持久化我们对对象所做的任何更改。 这也是工作单元的责任,即将我们添加到所有存储库中的新对象插入到数据库,并且管理对象的删除。

如果已对 ADO.NET DataSet 进行任何工作,则已熟悉工作单元模式。 ADO.NET DataSets 能够跟踪 DataRow 对象的更新、删除和插入,并且可以(借助 TableAdapter)协调对数据库的所有更改。 但是,DataSet 对象为基础数据库的断开连接子集建模。 工作单元模式表现出相同的行为,但与独立于数据访问代码的业务对象和域对象一起工作,并且这些对象无法感知数据库。

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

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

通过公开来自工作单元的仓库引用,我们可以确保单个工作单元对象能够追踪业务事务期间生成的所有实体。 实际工作单元的 Commit 方法实现是将内存中的修改与数据库进行对接的关键所在。 

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

    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 来检索时间卡信息。 代码假定需要时会显示时间卡信息。 延迟加载涉及一些魔术,通常涉及方法调用的运行时拦截。 拦截代码负责与数据库交互并检索时间卡信息,同时使业务逻辑不受干扰并专注于其核心功能。 这种延迟加载机制使业务代码能够将自身与数据检索操作隔离,并产生更具可测试性的代码。

延迟加载的缺点是,当应用程序 确实 需要时间卡信息时,代码将执行其他查询。 对许多应用程序来说,这不是一个问题,但对于性能敏感的应用程序或者在循环中访问多个员工对象并执行查询以在每次循环迭代中检索时间卡的应用程序(这个问题通常称为 N+1 查询问题),延迟加载是一种低效的做法。 在这些情况下,应用程序可能希望以最有效的方式急切地加载时间卡信息。

幸运的是,随着我们进入下一部分并实现这些模式,EF4 如何同时支持隐式延迟加载和高效的提前加载。

使用实体框架实现模式

好消息是,我们在最后一节中描述的所有设计模式都非常简单地使用 EF4 实现。 为了演示我们将使用简单的 ASP.NET MVC 应用程序来编辑和显示员工及其关联的时间卡信息。 我们将首先使用以下“常用的 CLR 对象”(POCO,即简单的旧 CLR 对象)。 

    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 的不同方法和功能,这些类定义将略有变化,但目的是尽可能将这些类保留为持久性无知(Persistence Ignorant, 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)。 有多个 POCO 模板可用于 EF。 有关使用模板的详细信息,请参阅“ 演练:实体框架的 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 而失败。 如果测试失败,我们可以确定问题出在控制器逻辑中,而不是在某个较低级别的系统组件中。

为了实现隔离,我们需要一些抽象,例如我们前面为存储库和工作单元提供的接口。 请记住,存储库模式旨在在域对象和数据映射层之间进行调解。 在此方案中,EF4 数据映射层,并且已提供一个名为 IObjectSet<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 支持)和测试双工作单位或“假”工作单元(执行内存中操作)之间切换。 执行此类切换的常见方法是不让 MVC 控制器实例化工作单元,而是将工作单元作为构造函数参数传递到控制器中。

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

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

可用于测试的虚假工作单元实现可能如下所示。

    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;
        }
    }

请注意,假工作单元公开了 Commited 属性。 有时,向有助于测试的假类添加功能会很有用。 在这种情况下,通过检查 Committed 属性,很容易观察代码是否提交了一个工作单元。

我们还需要一个假的 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 控制器中所有操作的所有测试。 可以使用我们构建的内存中的假象编写这些测试或任何类型的单元测试。 但是,在本文中,我们将避免整体测试类方法,而是将测试分组为专注于特定功能部分。  例如,“创建新员工”可能是我们想要测试的功能,因此我们将使用单个测试类来验证负责创建新员工的单个控制器操作。

所有这些细粒度测试类都需要一些常见的设置代码。 例如,我们始终需要创建内存中存储库和假工作单元。 我们还需要一个员工控制器实例,其中注入了虚假的工作单元。 我们将使用基类跨测试类共享此常见设置代码。

    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 请求期间使用的“创建”操作(显示用于创建员工的视图),另一个固定装置将侧重于 HTTP POST 请求中使用的“创建”操作(以用户提交的信息来创建员工)。 每个派生类仅负责其特定上下文中所需的设置,并提供验证其特定测试上下文的结果所需的断言。

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)
        };
    }

在这些测试中,基类执行大部分设置工作。 请记住,基类构造函数创建内存中存储库、一个假的工作单元和 EmployeeController 类的实例。 测试类派生自此基类,重点介绍 Create 方法测试的具体内容。 在这种情况下,具体内容归结为“安排、操作和断言”步骤,你将在任何单元测试过程中看到:

  • 创建一个新的Employee 对象来模拟传入数据。
  • 调用 EmployeeController 的 Create 操作并传入 newEmployee。
  • 验证“创建”操作是否生成预期结果(员工显示在存储库中)。

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

    [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)));
        }
        // ...
    }

我们使用内存中模拟对象创建的测试旨在测试软件的状态。 例如,在测试“创建”操作时,我们希望在创建操作执行后检查存储库的状态 - 存储库是否保留新员工?

    [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 运算符对内存中的模拟数据的适用性与对实际工作单元同样有效。

    [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# 编译器生成的表达式树转换为单个高效的 T-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<T> 接口,但 IObjectSet<T> 不定义 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> 的扩展方法。 这使我们能够将方法与更广泛的可能类型结合使用,包括 IQueryable<T、IObjectSet>T<、ObjectQuery>T<> 和 ObjectSet<T>。 如果基础序列不是真正的 EF4 ObjectQuery<T>,则不会造成任何损害,Include 运算符不会发生作用。 如果基础序列 ObjectQuery T<(或派生自 ObjectQuery><T>),则 EF4 将看到我们对其他数据的要求并制定正确的 SQL 查询。

有了这个新操作符,我们可以明确要求从存储库中急切加载时间卡信息。

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

针对实际 ObjectContext 运行时,代码将生成以下单个查询。 该查询在一次访问中从数据库收集足够的信息,以具体化员工对象并完全填充其 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

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

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

延迟加载

很容易想象一个场景,即我们不知道业务逻辑需要哪些数据。 我们可能知道逻辑需要员工对象,但我们可以分支到不同的执行路径,其中一些路径需要员工的时间卡信息,有些路径不需要。 这种场景非常适合隐式延迟加载,因为数据会神奇地根据需要出现。

延迟加载(也称为推迟加载)确实对我们的实体对象施加了一些要求。 完全持久化无知的 POCO 对持久化层没有任何要求,但真正的持久化无知几乎不可能实现。  相反,我们以相对程度度量持久性无知。 如果我们需要从持久性导向的基类继承或者使用专用集合来在 POCO 中实现延迟加载,这将是很不幸的。 幸运的是,EF4 具有不太侵入性的解决方案。

几乎无法检测到

使用 POCO 对象时,EF4 可以动态生成实体的运行时代理。 这些代理无形中包装了具体化的 POCO,通过拦截每个属性的读取和设置操作来执行额外的工作,从而提供附加服务。 其中一项服务是我们正在寻找的延迟加载功能。 另一个服务是一种有效的更改跟踪机制,它可以记录程序更改实体的属性值时。 在 SaveChanges 方法期间,ObjectContext 使用更改列表来使用 UPDATE 命令保留任何修改的实体。

但是,为了使这些代理正常工作,他们需要一种方法来挂钩到属性中获取和设置对实体的操作,并且代理通过重写虚拟成员来实现此目标。 因此,如果想要隐式延迟加载和高效的更改跟踪,则需要返回到 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; }
    }

我们仍然可以说员工实体基本上对持久性不敏感。 唯一的要求是使用虚拟成员,这不会影响代码的可测试性。 我们不需要从任何特殊基类派生,甚至不需要使用专用于延迟加载的特殊集合。 如代码所示,实现 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) {
        // ...
    }

延迟加载使得应用程序代码更容易编写,并且通过代理的“魔力”,代码依然可以完全测试。 在测试期间,单位工作的内存假体可以简单地根据需要预先加载带有相关数据的伪实体。

此时,我们将把注意力从使用 IObjectSet<T> 构建存储库转移到并考虑使用抽象来隐藏持久性框架的所有痕迹。

自定义存储库

当我们第一次演示本文中的工作设计模式时,我们提供了一些示例代码来说明工作单元的外观。 让我们使用我们一直在处理的员工和员工时间卡方案来重述此原始想法。

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

本工作单元和我们在上一节中创建的工作单元的主要区别在于,此工作单元不使用 EF4 框架中的任何抽象(没有 IObjectSet<T>)。 IObjectSet<T> 适用于存储库接口,但它公开的 API 可能与应用程序的需求不完全一致。 在此即将推出的方法中,我们将使用自定义 IRepository<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> 的虚假实现,就像我们在上一节中所做的一样。 但是,某些开发人员更喜欢使用模拟对象和模拟对象框架,而不是生成假对象。 我们将了解如何使用模拟来测试我们的实现,并在下一部分中讨论模拟和仿冒之间的差异。

使用模拟进行测试

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

我们构建的测试替身具有真实的实际功能实现。 在后台,每个对象存储一个具体的对象集合,在测试期间操作存储库时,它们将添加和删除此集合中的对象。 一些开发人员喜欢以这种方式构建他们的测试替身,使用实际代码和可行的实现。  这些测试双打是我们所谓的 假货。 它们有可运行的实现,但不足以用于生产环境。 假存储库实际上不会写入数据库。 假 SMTP 服务器实际上不会通过网络发送电子邮件。

模拟与假货

有另一种类型的测试替身,称为 模拟。 虽然虚拟对象具有实现,但模拟不包含实现。 借助模拟对象框架,我们在运行时构造这些模拟对象,并将其用作测试替身。 在本部分中,我们将使用开源模拟框架 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<Employee> 的实现,并动态生成。 可以通过访问 Mock<T> 对象的 Object 属性来访问实现 IRepository<Employee> 的对象。 这是我们可以传入控制器的内部对象,它们不会知道这是一个测试替身还是真正的存储库。 我们可以在对象上调用方法,就像在具有实际实现的对象上调用方法一样。

在您调用 Add 方法时,您一定想知道模拟存储库会做什么。 由于 mock 对象后面没有实现,因此 Add 不执行任何操作。 我们写的那些伪造品背后有具体的收集过程,而这里没有,所以员工被弃用了。 FindById 的返回值怎么样? 在这种情况下,mock 对象执行唯一可以执行的操作,即返回默认值。 由于我们返回引用类型(Employee),因此返回值为 null 值。

模拟对象可能看起来毫无价值;然而,还有两个特性我们还没有讨论。 首先,Moq 框架记录对模拟对象所做的所有调用。 稍后在代码中,我们可以询问 Moq 是否有人调用 Add 方法,或者是否有人调用 FindById 方法。 稍后我们将了解如何在测试中使用此“黑盒”录制功能。

第二个出色的功能是如何使用 Moq 为模拟对象编程以设定期望值。 期望告诉模拟对象如何响应任何给定的交互。 例如,我们可以将期望编程到模拟中,并在有人调用 FindById 时告诉它返回员工对象。 Moq 框架使用安装 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 动态生成存储库,然后用期望对存储库进行编程。 当有人调用传递值 5 的 FindById 方法时,预期会告知模拟对象返回 ID 值为 5 的新员工对象。 此测试通过,我们不需要生成一个完整的实现来伪造 IRepository<T>。

让我们重新审视之前编写的测试,并将其重写以使用模拟而非伪对象。 就像以前一样,我们将使用基类来设置所有控制器测试所需的常见基础结构部分。

    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 来构造模拟对象,而不是使用假对象。 当代码调用 Employees 属性时,基类会安排模拟工作单元以返回模拟存储库。 模拟设置的其余部分将在专用于每个特定方案的测试装置内进行。 例如,索引操作的测试装置将设置模拟存储库,以便在操作调用模拟存储库的 FindAll 方法时返回员工列表。

    [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
    }

除了预期之外,我们的测试看起来类似于以前测试。 但是,借助模拟框架的录制功能,我们可以从不同的角度进行测试。 下一部分将介绍这一新视角。

状态与交互测试

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

  • 在“创建”执行后,验证存储库是否包含新的员工对象。
  • 验证模型在执行索引后保存所有员工的列表。
  • 验证存储库在执行 Delete 后不包含给定员工。

另一种使用模拟对象的方法是验证交互。 当基于状态的测试对对象状态进行断言时,基于交互的测试会提供有关对象交互方式的断言。 例如:

  • 验证控制器在执行 Create 时调用存储库的 Add 方法。
  • 验证控制器在执行索引时调用存储库的 FindAll 方法。
  • 验证控制器是否调用工作单元的 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 将记录与模拟存储库的交互。 使用 Moq 的 Verify API,我们可以询问 Moq 控制器是否使用正确的 ID 值调用 FindById。 如果控制器未调用该方法,或者调用具有意外参数值的方法,则 Verify 方法将引发异常,并且测试将失败。

下面是验证“创建”操作对当前工作单元调用“提交”的另一个示例。

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

交互测试的一个危险是过度指定交互。 模拟对象记录和验证与模拟对象的每个交互的能力并不意味着测试应尝试验证每个交互。 某些交互是实现细节,您应仅验证当前测试所需的交互。

模拟或假货之间的选择在很大程度上取决于你正在测试的系统以及你的个人(或团队)首选项。 模拟对象可以大幅减少实现测试替身所需的代码量,但并不是每个人都对设置期望和验证交互感到自如。

结论

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

其他资源

传记

斯科特·艾伦是 Pluralsight 的技术人员和 OdeToCode.com 的创始人。 在 15 年的商业软件开发中,Scott 从事的解决方案涵盖了从 8 位嵌入式设备到高度可扩展的 ASP.NET Web 应用程序等各种技术。 你可以在 OdeToCode 或 Twitter https://twitter.com/OdeToCode上的博客上联系斯科特。