在您的代码中测试什么、在哪里测试、何时测试以及由谁测试






4.96/5 (15投票s)
在您的代码中测试什么、在哪里测试、何时测试以及由谁测试
引言
“测试什么?在哪里测试?由谁测试?这是一篇关于在实际应用程序中测试领域层、数据访问层和用户界面的实际替代方案的文章。文章根据 DDD 驱动的架构中典型的 ASP.NET MVC 应用程序的层级,提出了不同类型的测试。”"
理论上,单元测试的构建和理解都很简单,因为它们看起来像小型程序,输入/处理/输出都定义得很清楚。但在实践中,“实际应用程序”中的测试实现比理论更复杂。无论是在整个应用程序中使用单元测试,还是只在最关键的部分编写特定测试。总的来说,在理解了理论示例后,**人们常常不知道在哪里应用单元测试?测试什么?最重要的是,如何测试我们自己的源代码?**
本文旨在根据 DDD 概念,通过访问数据库、用户交互的方法,展示 MVC 应用程序各层的一些测试示例。DDD 架构模式提供了一套实践和术语框架,用于做出专注于加速软件项目的设计决策,处理复杂领域(维基百科),并具有预定义的层:领域层、应用程序层、数据访问层和表示层(图 1)。这些层中的每一层都有不同的职责和特性,因此需要不同类型的单元测试。
关于单元测试,它们应该是快速的,因为它们是为了在内存中测试应用程序的 ONE 个功能而构建的,也就是说,**测试 ONE 种逻辑。**它们不应该访问连接到数据库、文件系统、网络服务或任何其他需要外部资源的类的类。单元测试**应该总是通过或失败**,以定义被测试逻辑的结果。它们是独立构建的,**自给自足,并且不能依赖于其他测试。**因此,无论以何种顺序进行测试,它们都应该始终有效。**单元测试是干净的,**一眼就能看出它的目的。测试必须有良好的名称,毫不吝啬词语,符合“良好编程实践”的标准。
无论测试哪个层,一种良好的做法是创建一个单元测试项目,复制要测试的项目相同的文件夹结构,如图 2 所示。这种做法方便了测试或一组测试的定位。因为被测试的类和项目本身将有一个对应的名称,只需添加后缀“.test
”。根据此模式,customer.cs 类(领域类示例)将有一个对应的单元测试类,名为 customer.test.cs。这两个类都将位于“Entities”文件夹中,名称分别为“Name_App.Domain.Core
”和“Name_App.Domain.Core.Tests
”。这可能看起来是多余的,但这种简单的组织方式将极大地阐明测试内容及其预期用途。
根据要测试的项目,创建文件结构和测试项目文件夹。
但是要测试什么?根据本文的指导,**这取决于正在测试的层、类和方法!**每个层都有其职责,并包含具有相同特性的类。初学者的一大误区是他们没有这种眼光,并且想把一切都放到错误的地方去测试。例如,领域服务测试 DbContext
访问,或者测试在某个领域服务中实例化的存储库类是否将记录写入数据库,这不是领域服务的职责。这应该是数据访问层,甚至集成测试的职责。**单元测试的功能是测试一个方法、一个功能或一段代码声称要执行的某个职责。**通常方法名称本身就表明了它的目的,因此也表明了需要测试什么。
单元测试根据其所在的层进行测试,以检查要测试的代码声称的功能。
在单元测试范围内存在一种解释性故障,当一个单元测试项目具有不同的项目引用被测试时。作为经验法则,这两个项目应该具有相同的引用!被测试的项目和执行测试的项目都应该如此。一旦知道了每个层的角色,就知道层与层之间是如何通信的,**那么就知道哪些层引用了其他层**。因此,这两个项目将具有相同的引用,除了某些特定的测试框架,或特定的依赖注入和其他仅用于简化实现工作的引用。
领域模型层的测试
在应用程序开始创建时,通常会开始抽象要使用的实体,并在领域层实现它们的类。这是 OOP 和 DDD 指示的方式。不应该通过创建数据库或系统的“屏幕”来开始应用程序的构建。每个领域实体都实现为一个类,该类具有属性和方法。单元测试的重点是测试每个实体的每个方法(我们不深入讨论 private
方法的测试)。我们应该测试所有方法吗?这取决于方法!但作为一个务实的专业人士,**并非所有方法都需要测试,只有那些复杂度最低的方法才需要。**有些专业人士会对此表示不满,但我们的观点是理性使用资源,包括专业人士宝贵的工作时间。
从我们的角度来看,这一层是最令人愉快的测试编写,因为它仅处理业务逻辑。为领域实体编写单元测试,以暴露系统的功能和未来的需求,从而提供应用程序的完整视图。在 DDD 方法中,领域层不使用外部资源,不使用 ORM,不使用依赖注入框架,不关心任何外部资源。作为经验法则,领域层在其引用中只包含 System
命名空间,这进一步证明了其自身类和方法用于其测试。
Document
类是领域层的一个实体,包含操作其自身字段和其他领域实体的方法。它有一个 public
方法 AddField
和另外两个 private
方法,AddFieldIfNameNotIsNullOrEmpty
和 AddFieldIfNotContainsDocumentField
。需要澄清的是,AddField
方法已被测试,其测试和重构的结果催生了这两个 private
方法。单元测试构建任务之一是为源代码重构提供基础。每个开发人员都有一个工作方法,**在某些情况下,当不确切知道要做什么,或如何做时,测试是一个很好的起点。**在这种情况下,单元测试也有助于确定是应该创建一个新实体,还是只需要一个 string
数组用于 DocumentFields
。
public class Document : IDocument
{
public long Id { get; set; }
public string Name { get; set; }
public virtual ICollection<DocumentField> DocumentFields { get; set; }
public Document()
{
DocumentFields = new List<DocumentField>();
}
public void AddField(DocumentField documentField)
{
if (documentField == null){
throw new ArgumentNullException();
}
else {
AddFieldIfNameNotIsNullOrEmpty(documentField);
}
}
private void AddFieldIfNameNotIsNullOrEmpty(DocumentField documentField)
{
if (string.IsNullOrEmpty(documentField.Name)){
throw new ArgumentException();
}
else {
AddFieldIfNotContainsDocumentField(documentField);
}
}
private void AddFieldIfNotContainsDocumentField(DocumentField documentField)
{
if (!DocumentFields.Contains(documentField)){
DocumentFields.Add(documentField);
}
}
}
示例 1
[TestMethod]
public void AddFieldMethodSavesDocument()
{
var document = new Document();
var documentField = new DocumentField
{
Name = "DOCUMENT FIELD NAME"
};
document.AddField(documentField);
string documentFieldName = document.DocumentFields.FirstOrDefault<DocumentField>().Name;
Assert.AreEqual(document.DocumentFields.Count, 1);
Assert.AreEqual(documentFieldName, "DOCUMENT FIELD NAME");
}
示例 1 是领域层单元测试的典型示例。在此示例中,测试函数正在验证 Document
类中的 AddField
方法是否将项目添加到其集合中。Document
对象中的 AddField
方法有一个内部集合(不公开暴露)用于添加 DocumentField
对象。因此,一个 Document
对象包含一个或多个 DocumentField
。单元测试验证通过向该方法添加 DocumentField
对象的实例,是否真的会持久化该对象。
示例 2
[TestMethod]
public void AddFieldMethodReceivesEmptyValueForNameField()
{
Exception caughtException = null;
var document = new Document();
try
{
var documentFieldFake = new DocumentField
{
Name = ""
};
document.AddField(documentFieldFake);
}
catch (Exception err)
{
caughtException = err;
}
Assert.IsInstanceOfType(caughtException, typeof(ArgumentException));
Assert.AreEqual(document.DocumentFields.Count, 0);
}
在示例 2 中,向 Document
对象中的相同 AddField
方法发送了一个无效值(空字符串)。根据业务规则,Name
字段必须有一个非空值。因此,AddField
方法应向应用程序用户清楚地说明这一点,并应抛出异常,明确表示此类输入无效。**业务规则是领域层的职责,只能在领域层进行测试。**鉴于此,对于其他应用程序层,如果一个 DocumentField
属于 DocumentFields
集合,那么在插入之前,其所有字段都已组成。不需要在另一个层中再次进行此测试。
有一些良好的实践可以改进被测代码,允许进行在正常情况下看不到的验证。开发人员在不知不觉中创建的代码是供一个不犯错误且仅怀有良好意图的完美用户使用的。单元测试提供了对必要验证的更好洞察。需要创建带有**null
值**的单元测试作为测试方法的参数。或者输入无效值,如示例 2 所示,**例如空 string
、超出范围的值、插入特定类型而不是其他类型等等。**当此类型的问题未被代码正确处理时,测试将无法编译,会生成错误,无法执行测试断言。这表明某段代码的某一部分需要异常处理。在这种情况下,通过调试可以找到并修复问题。单元测试的任务之一是为重构源代码提供基础。此外,即使不使用 TDD,单元测试也不应仅检查显而易见的内容,而应尝试找到尚未考虑到的问题。
单元测试不应仅检查显而易见的内容,而应尝试找到尚未考虑到的问题,然后对其进行验证。
示例 2
测试有两个断言。根据“**一次断言一次单元测试**”的说法,这种做法可能被视为一种坏味道。在本帖的情况下,应考虑测试建议的主题(测试名称本身),这暗示了一系列涉及多个语句的操作。对于本例,抛出了一个异常,然后 DocumentFields
集合不得被使用,并且应该是空的。可能还有其他尚未测试的动作(断言),它们也是该主题的一部分,从而增加了断言的数量。
测试名称本身很重要,通常会处理关于代码的主题,该主题可以通过一个或多个断言来处理。
领域层还包含 DDD 架构中的服务。领域服务具有不特定属于领域实体的操作,本质上是活动或操作,而不是领域实体的内部特征。在此实现方法中,领域服务可以调用存储库的方法。领域层包含存储库接口,其实现在数据访问层。
public class DepartmentService : ServiceBase<Department>, IDepartmentService
{
private readonly IDepartmentRepository _DepartmentRepository;
public DepartmentService(IDepartmentRepository departmentRepository)
: base(departmentRepository)
{
_DepartmentRepository = departmentRepository;
}
}
**在领域服务中测试什么?**一个好的测试是验证服务方法能否正确调用与其关联的一个或多个存储库方法。还有其他可能性,例如测试涉及领域业务规则和其他可以与之关联的类。非常重要的是要理解,在这一点上,**服务方法能否从数据库或其他地方获取任何数据并不重要。这不是领域服务方法的职能,而是存储库方法的职能。**这个测试应该稍后通过测试数据访问层来执行,而不是现在。领域服务方法的职能是执行领域实体和其他在该层中可访问对象的*操作。DepartmentService
类有一个 IsASessionOfTheDepartment
方法,该方法包含 long DepartmentID
和 sectionId
类型的参数,接下来将对其进行测试。
public class DepartmentService
{
private readonly IDepartmentRepository _DepartmentRepository;
public DepartmentService(IDepartmentRepository departmentRepository)
{
_DepartmentRepository = departmentRepository;
}
public bool IsASessionOfTheDepartment(long departmentId, long sectionId)
{
// Other Operations.
// Bug here!
var departmentRepository = _DepartmentRepository.GetById(sectionId);
// Other Operations.
}
}
IsASessionOfTheDepartment
方法在内部处理 _DepartmentRepository
的 GetById
方法。故意插入了一个实现错误,将 sectionId
参数而不是 departmentID
传入。这是一种任何开发者在工作中都可能因疏忽而犯的错误。测试的目的之一是验证这种情况。但是,它将测试 IsASessionOfTheDepartment
是否如预期那样正确调用 GetById
(示例 3)。
示例 3
[TestMethod]
public void IsASessionOfTheDepartmentMethodDisplayProblemWithWrongParameters()
{
const long sectionId = 101;
const long departmentId = 12;
long receivedDepartmentId = long.MinValue;
var departmentRepositoryMock = new Mock<IDepartmentRepository>();
departmentRepositoryMock
.Setup(dep => dep.GetById(It.IsAny<long>()))
.Returns(new Department())
.Callback<long>(id => receivedDepartmentId = id);
var departmentService = new DepartmentService( departmentRepositoryMock.Object);
List<Department> listOfDepartmentsResult =
departmentService. IsASessionOfTheDepartment(departmentId, sectionId);
Assert.AreEqual(receivedDepartmentId, departmentId); // Error, receivedDepartmentId returns 101
}
在 示例 3
中,通过为 departmentID
参数输入值 12
来进行测试,应检查在 IsASessionOfTheDepartment
中调用 GetById
时是否使用了相同的值。由于 IsASessionOfTheDepartment
方法的实现错误,receivedDepartmentId
变量被错误地加载。因此,测试将不通过,您可以检查实现错误。
存在一些用于测试的对象类型,以替换在特定时刻无法或不应由单元测试访问的资源。存在 Dummy、Fakes、Stubs 和 Mocks 对象,每个对象都有其独特的特性和目的。在优秀的文章 https://martinfowler.com.cn/bliki/TestDouble.html 和 https://martinfowler.com.cn/articles/mocksArentStubs.html 中对这些对象中的每一个都有详细描述。即使不详细介绍它们每个对象是如何工作的,或者知道在什么情况下必须使用它们。重要的是要知道**它们是用于替换其他对象的对象。**在很多情况下,一个类需要被测试,并且它有一个或多个关联类。**如果一个代码将被测试,并且它不依赖于任何关联的行为,那么它的依赖项就不需要被实例化,可以用这些对象之一来代替。如果需要模拟某些无法访问的行为,或者需要提供无法访问的外部资源,那么您可以使用其中一个对象。**
有些情况下,被测类与第三方类、Web 服务、数据库访问类、文件系统类甚至我们应用程序的一部分类存在依赖关系。在这种情况下,必须确定是否可以使用/不能使用替换功能的其中一个对象。这些对象不仅可以用来替换难以访问的功能,还可以简化单元测试的编写,避免大量与测试目标无关的代码。
**为什么在示例 3 测试中使用 Mock 对象?**在开始学习测试时,这个问题经常被问到。在这种情况下,使用 departmentRepositoryMock
对象有两个原因。第一个是可以通过 Moq 框架轻松实例化该对象。如果正常实例化 DepartmentRepository
对象,那么就需要关注它的依赖项以及创建 DepartmentRepository
关联的其他对象的创建。需要级联创建一系列其他依赖对象。有时,可以插入一个不影响测试结果的 null 值参数,但这并非总是可能。Moq 框架以及其他框架可以处理对象的创建及其依赖项,因为它更好地利用了它。第二个原因是易于**模拟**调用 mock 方法并为该方法**设置**返回值,如示例 4 所示。或者甚至**获取**(使用回调)在某个方法中使用的参数值,如示例 3 所示。
示例 4
[TestMethod]
public void CheckIfDepartmentServicePersistDepartments()
{
Department dep1 = new Department();
Department dep2 = new Department();
Department dep3 = new Department();
var departmentRepositoryMock = new Mock<IDepartmentRepository>();
departmentRepositoryMock
.Setup(dep => dep.GetAll())
.Returns(new List<Department>
{dep1, dep2, dep3 }); // It sets that the GetAll value is a list of DEP1, DEP2 and dep3.
var departmentService = new DepartmentService( departmentRepositoryMock.Object);
var listOfDepartments = departmentService.GetAllDepartments();
Assert.IsNotNull(listOfDepartments);
Assert.AreEqual(listOfDepartments.Count<Department>(), 3);
}
数据持久化层中的单元测试
数据持久化层包含负责数据持久化对象的实现。其作用基本上是获取和存储数据供其他应用程序层使用。在此层中,我们有存储库实现、DbContext
类(在 Entity Framework 的情况下)、数据库的类工厂(如果使用该模式)以及其他辅助类。在 DDD 概念中,此层通过其类与数据库访问技术相关联。无论是通过 .NET API (ADO.NET),还是通过 ORM(Entity Framework、Nhibernate 等)。无论是通过关系数据库,还是 NoSQL 数据库。
在本帖中,将使用一组类(假对象)来模拟 Entity Framework,也可以是其他 ORM,或任何其他数据访问技术。Entity Framework (EF) 是一个 ORM(对象关系映射器),它有一个类能够在运行时提供与数据库的访问会话,即 DbContext
类。通过这个类,EF 使您能够使用应用程序对象(在领域层中称为实体)来查询、插入、更新和删除数据。EF 将领域模型中定义的实体和关系映射到数据库。DbContext
类将 DbSet
对象公开为属性,这些属性代表上下文中指定实体的集合。每个 DbSet
对象仅代表一个领域实体,呈一对一关系。在 http://www.asp.net/entity-framework 上有关于 Entity Framework 的广泛文档。
为什么使用假对象?因为需要一个内存中的对象来满足查询场景(Select
)和非查询(Insert
、Update
、Delete
)的需要,**模拟**对数据库的访问,例如 ORM。需要一个对象,当查询它时返回一个记录组,或者模拟插入一条记录。测试框架(Moq、Rhino Mocks 等)并非总是与某些 ORM 版本兼容,版本不匹配是很常见的。为此,将手动创建假类结构,以模拟数据库响应,满足测试需求。这些假类无论使用哪个测试框架,都可以完全控制每个方法的*操作。
DbSetForTests<TEntity>
类派生自 DbSet<TEntity>
类,将用于在内存中存储与要测试的实体(TEntity
)相关的数据。它还通过同步和异步方法提供了模拟操作的方法,例如在数据库表中**插入**、**更新**和**删除**数据。在这篇文章中,您可以看到这种方法的优缺点,以及它的完整实现。
public class DbSetForTests<TEntity> : DbSet<TEntity>, IQueryable,
IEnumerable<TEntity>, IDbAsyncEnumerable<TEntity> where TEntity : class
{
ObservableCollection<TEntity> _data;
IQueryable _query;
public DbSetForTests()
{
_data = new ObservableCollection<TEntity>();
_query = _data.AsQueryable();
}
public override TEntity Add(TEntity item)
{
_data.Add(item);
return item;
}
// This class contains several other methods to update, delete, queries
// and async methods.
}
还需要创建一个假上下文类的实现,使用 DbSetForTests
作为其 DbSet<Entity>
属性的依赖项。请注意,在 ContextForTests
类构造函数中,每个 DbSet<T>
对象都通过 new DbSetForTests<T>
实例化。
public interface IContextForTests
{
System.Data.Entity.DbSet<Department> CAD_DEPARTMENT { get; set; }
System.Data.Entity.DbSet<ProductType> CAD_DOCUMENT_TYPE { get; set; }
System.Data.Entity.DbSet<Product> CAD_DOCUMENT { get; set; }
}
public class ContextForTests: IContextForTests
{
public ContextForTests()
{
this.CAD_DEPARTMENT = new DbSetForTests<Department>();
this.CAD_DOCUMENT = new DbSetForTests<ProductType>();
this.CAD_DOCUMENT_TYPE = new DbSetForTests<Product>();
}
public virtual DbSet<Department> CAD_DEPARTMENT { get; set; }
public virtual DbSet<ProductType> CAD_DOCUMENT_TYPE { get; set; }
public virtual DbSet<Product> CAD_DOCUMENT { get; set; }
}
按照必要实现的顺序,ContextForTests
类是 DatabaseFactoryForTests
的依赖项之一。DatabaseFactoryForTests
也是一个假对象,专门用于满足测试需求,就像 DbSetForTests
和 ContextForTests
类一样。在此类层次结构中,DatabaseFactoryForTests
类负责管理上下文的创建。这种假类结构并非强制性的,这种方法只是一种处理数据访问测试的方式。对于这些示例,对象是按照类层次结构创建的,并使用 DbContext
(EF),结合工厂模式和泛型存储库。可以是任何实现,只要它在内存中模拟了存储库方法。
public interface IDataBaseFactoryForTests : IDataBaseFactory
{
IContextForTests GetContextForTests();
}
public class DatabaseFactoryForTests : IDataBaseFactoryForTests
{
private IContextForTests _ContextForTests;
public DatabaseFactoryForTests(IContextForTests contextForTests)
{
_ContextForTests = contextForTests;
}
public AppContext GetContext()
{
return null;
}
public IContextForTests GetContextForTests()
{
return _ContextForTests ?? (_ContextForTests = new ContextForTests());
}
}
在创建了假结构后,可以创建 DepartmentRepositoryTests
类,该类旨在测试 DepartmentRepository
类的方法。像 DepartmentRepository
这样的存储库类执行 Department
领域实体的 CRUD 操作。正如前面讨论过的,DepartmentRepositoryTests
类使用 DbSetForTests
对象、ContextForTests
和 DataBaseFactoryForTests
进行测试。这些测试应确保该存储库的*插入*、*更新*、*删除*和*查询*在任何情况下都能正常工作。
[TestClass]
public class DepartmentRepositoryTests
{
IContextForTests _ContextForTests;
IDataBaseFactoryForTests _DataBaseFactoryForTests;
IDepartmentRepository _DepartmentRepository;
[TestInitialize]
public void Initialize()
{
_ContextForTests = new ContextForTests();
_DataBaseFactoryForTests = new DatabaseFactoryForTests( _ContextForTests);
}
// Example 5:
[TestMethod]
public async Task GetAllAsyncMethodRetrievesRecordsInContext()
{
_ContextForTests.CAD_DEPARTMENT.Add(new Department { Id = 2, Name = "Department 2" });
_ContextForTests.CAD_DEPARTMENT.Add(new Department { Id = 3, Name = "Department 3" });
_ContextForTests.CAD_DEPARTMENT.Add(new Department { Id = 1, Name = "Department 1" });
_DepartmentRepository = new DepartmentRepository
( _DataBaseFactoryForTests, _ContextForTests.CAD_DEPARTMENT);
var listOfDepartments = await _DepartmentRepository.GetAllAsync();
Assert.IsNotNull(listOfDepartments);
Assert.AreEqual(listOfDepartments.Count<Department>(), 3);
Assert.AreEqual(listOfDepartments[0].Id, 2);
}
// Example 6:
[TestMethod]
public async Task GetAllAsyncMethodThrowsExceptionWhenDbSetIsNull()
{
Exception caughtException = null;
IList<Department> listOfDepartments = null;
try
{
_DepartmentRepository = new DepartmentRepository(_DataBaseFactoryForTests, null);
listOfDepartments = await _DepartmentRepository.GetAllAsync();
}
catch(Exception err)
{
caughtException = err;
}
Assert.IsNotNull(caughtException);
Assert.AreEqual(caughtException.Message,
"An error occurred while retrieving the list of all entities.");
Assert.AreEqual(listOfDepartments, null);
}
// Example 7:
[TestMethod]
public async Task AddAsyncMethodInsertRecordsInEmptyContext()
{
_DepartmentRepository = new DepartmentRepository( _DataBaseFactoryForTests,
_ContextForTests.CAD_DEPARTMENT);
await _DepartmentRepository.AddAsync(new Department
{
Id = 1,
Name = "Department 1"
});
var listOfDepartments = await _DepartmentRepository.GetAllAsync();
Assert.IsNotNull(listOfDepartments);
Assert.AreEqual(listOfDepartments.Count<Department>(), 1);
Assert.AreEqual(listOfDepartments[0].Id, 1);
Assert.AreEqual(listOfDepartments[0].Name, "Department 1");
}
// Example 8:
[TestMethod]
public async Task AddAsyncMethodInsertRecordsInAFilledContext()
{
_ContextForTests.CAD_DEPARTMENT.Add(new Department { Id = 2, Name = "Department 2" });
_ContextForTests.CAD_DEPARTMENT.Add(new Department { Id = 3, Name = "Department 3" });
_ContextForTests.CAD_DEPARTMENT.Add(new Department { Id = 1, Name = "Department 1" });
_DepartmentRepository = new DepartmentRepository(_DataBaseFactoryForTests,
_ContextForTests.CAD_DEPARTMENT);
await _DepartmentRepository.AddAsync(new Department
{
Id = 4,
Name = "Department 4"
});
var listOfDepartments = await _DepartmentRepository.GetAllAsync();
Assert.IsNotNull(listOfDepartments);
Assert.AreEqual(listOfDepartments.Count<Department>(), 4);
Assert.AreEqual(listOfDepartments[3].Id, 4);
Assert.AreEqual(listOfDepartments[3].Name, "Department 4");
}
// Example 9:
[TestMethod]
public async Task AddAsyncMethodThrowsExceptionWhenDepartmentIsNull()
{
Exception caughtException = null;
try
{
_DepartmentRepository = new DepartmentRepository
(_DataBaseFactoryForTests, _ContextForTests.CAD_DEPARTMENT);
await _DepartmentRepository.AddAsync(null);
}
catch (Exception err)
{
caughtException = err;
}
Assert.IsNotNull(caughtException);
Assert.AreEqual(caughtException.Message, "Not is possible to insert a null Department.");
}
}
此时**没有对真实数据库进行任何*操作。我们如何知道这些相同的存储库方法是否能用于数据库?**这些测试*不*验证我们是否可以打开数据库连接,或者 connectionstring
是否正确,或者数据库中是否存在接收和保存记录的表。**但是,存储库方法能够*做到*它声称*要做*的事情,无论连接到哪个*上下文。也就是说,Update
方法应该能够更新*接收*它的实体,一个查询*必须*获取*请求*的记录,依此类推。**要运行测试,数据将由 DbSetForTests<Department>
存储,该类派生自 DbSet<TEntity>
,而 DbSet<TEntity>
是存储库在任何情况下*访问*的*同一*类。也就是说,如果一个存储库通过*访问* DbSetForTests
的数据*来工作,那么在连接到数据库*上下文*时,它*也*应该*能够*与 DbSet
*一起*工作。
表示层
在表示层,将使用 ASP.NET MVC 项目。MVC 架构模式将应用程序分为三个主要组件:模型、视图和控制器。为了更好地理解 MVC 模式,http://www.asp.net/mvc 门户提供了大量学习文档。
如前所述,理想情况下应该对所有代码执行单元测试,因为它具有一定的复杂程度。代码是用 View、Controller 还是 Model 类(ViewModel
)编写的并不重要。对于 View,建议对 JavaScript 代码和涉及 Ajax 的*渲染*进行单元测试。由于 View 代码基本上是 HTML,因此*不需要*单元测试,但可能存在脚本。
关于前端代码(视图)的一种良好实践是排除任何逻辑代码组件。基本上,视图应该只包含用于显示信息的 HTML 代码。尽管 Razor 允许在视图中编写 C#/VB.NET 脚本,但当涉及逻辑代码时,这*不是*一种好*习惯。使用任何条件结构、状态变量和任何其他逻辑都会阻止*开发*用于*验证*要显示的数据的*测试*。下一个示例*说明*了*在视图中*添加*了*用于*确定*账单*状态*的代码*片段*的情况。
// Code Snippet of View
<div class="row">
<h3>Billing Details</h3>
<br />
// Display other fields…
// Code snippet THAT IS BAD SMELL, involving logical part!
@{
@Html.Label("Status: ");
var days = DateTime.Today.Subtract(@Model.InitialDate).Days;
if (days >= 30){
<strong>@Html.Label("Ended Month!");</strong>
}
<strong>@Html.Label("Month in Progress!");</strong>
}
</div>
解决此问题*的*建议是*将其*封装*在*对象的*模型*组件*中*,*以便*接收*所有*逻辑。*因此,*由*控制器*发送*的* ViewModel 接收*视图*,*其* Status 字段*已*填充*,*准备*显示*属性*值。
// View Model
public class BillingViewModel
{
// Other fields and methods here….
public DateTime InitialDate { get; set; }
public string Status
{
get
{
var days = DateTime.Today.Subtract(InitialDate).Days;
if (days >= 30){
return "Ended Month!";
}
return "Month in Progress!";
}
}
}
相同的视图可以重写为以下代码。并且 Status
属性可以像任何*代码*一样进行*测试。
// Code Snippet of a better View
<div class="row">
<h3>Billing Details</h3>
<br />
// Display other fields…
Status: @Model.Status
</div>
Controller
负责接收应用程序的用户*交互*并*通过* View*显示*数据。Controller*包含* Action Methods,*可以*返回 views。这些*方法*可以*包含*一些*内部*逻辑*,*并且*可能*会*路由*到*任何* View。*在*某些*情况*下*存在*条件*结构*以*转发*到*某些*异常。*因此,*对于*每*种*路由*可能性*,*都有*必要*生成*一个*单元*测试*。*在*下一个*示例*中*,*根据* action method*可以*返回*三个*不同的*视图*,*应该*有*三个*不同的*测试*来*模拟*这*三种*情况。
//Action Method
public ActionResult CreditCardValidation(string CreditCardNumber)
{
If (string.Empty(CreditCardNumber)){
return View("Cancel");
}
bool isACreditCardNumberValid = CreditCardService.VerifyNumber( CreditCardNumber);
if (isACreditCardNumberValid){
return View("Payment");
}
return View("CreditCardInvalid");
}
//Tests:
// Example 10:
[TestMethod]
public void CreditCardValidationActionMethodWithEmptyParameter()
{
var creditCardServiceMock = new Mock<ICreditCardServiceMock>();
var creditCardController = new CreditCardController(creditCardServiceMock);
var viewResult = creditCardController.CreditCardValidation(string.Empty) as ViewResult;
Assert.AreEqual("Cancel", viewResult.ViewName);
}
// Example 11:
[TestMethod]
public void CreditCardValidationActionMethodWithCreditCardNumberInvalid ()
{
var creditCardServiceMock = new Mock<ICreditCardServiceMock>();
creditCardServiceMock
.Setup(serv => serv.VerifyNumber(It.IsAny<long>()))
.Returns(false)
var creditCardController = new CreditCardController(creditCardServiceMock);
var viewResult = creditCardController.CreditCardValidation("1234 1234 1234 XXXX") as ViewResult;
Assert.AreEqual("CreditCardInvalid", viewResult.ViewName);
}
// Example 12:
[TestMethod]
public void CreditCardValidationActionMethodWithCreditCardNumberValid ()
{
var creditCardServiceMock = new Mock<ICreditCardServiceMock>();
creditCardServiceMock
.Setup(serv => serv.VerifyNumber(It.IsAny<long>()))
.Returns(true)
var creditCardController = new CreditCardController(creditCardServiceMock);
var viewResult = creditCardController.CreditCardValidation("1234 1234 1234 1234") as ViewResult;
Assert.AreEqual("Payment", viewResult.ViewName);
}
一些 Controller 的 Action Methods*也*接收 ViewModels 对象*作为*参数。在*将*每个*参数*传递*给*较低*层*之前*,*必须*测试*其*字段*。*验证*用户*输入*非常*重要*,*即使*使用* DataAnnotations*组件*进行*此*验证*。*使用* DataAnnotations*也*可能*存在*许多*超出*其*控制*的*验证*类型。
此链接*包含*一篇*关于*通过* HttpContext*对象*测试*会话*变量*的文章。*此*示例*需要*更改* Controller*的*编写*方式*以*方便*测试*,*这*促使*人们*反思*某些*开发人员*不*测试*所有*代码*的原因。*这*清楚*地*表明*代码*并非*总是*准备*好*进行*测试*,*有时*需要*对其*进行*适应*才能*进行*单元*测试。
关注点
在*考虑*实现*单元*测试*时*,*我们*应该*记住*并非*所有*应用程序*都*准备*好*进行*测试*,*或*容易*进行*测试*。*因此*,*在*已*就位*的*应用程序*中*开始*实现*单元*测试*非常*困难*。*使用* TDD*规定*测试*必须*在*代码*之前*编写*,*已经*考虑*到*符合*测试*的代码*。*根据*应用程序*代码*的*实现*方式*,*访问*其*依赖项*很*困难*,*难以*访问*与*被测*类*相关*的*类*。*使用*构造函数*或*方法*的*依赖*注入*允许*在*测试*中*插入*预定义*对象*。*因此*,*实例化*一个*类*或*调用*一个*方法*时*,*测试*可以*以*可控*的*方式*将*对象*作为*参数*传递*。*本文*的*测试*示例* 10、11*和* 12*演示*了*构造函数*中的*依赖*注入*的*应用*,*例如*将* creditCardServiceMock*(mock*对象)*作为*参数*传递*给* creditCardController*控制器。
在*实现*一个*类*时*,*其*依赖*类*(*关联*类)*应该*允许*通过*其*构造函数*或*其*方法*进行*实例化*,*而不是*在*内部*实例化*它们*。*这*使得*执行*类*代码*下的*测试*更加*容易。
尽管*建议*对*所有*应用程序*层*进行*单元*测试*,*但*我们*不是* 100%*代码*覆盖率*的*宣扬者*。*事实上*,*我们*在*我们*的*应用程序*中*并不*这样做*。*但*当*应用程序*与*单元*测试*并行*创建*时*,*它*会*带来*一种*不同*的*方法*来*获得*更好的*产品*。*然而*,*当*开始*使用*测试*时*,*有很多*初始*工作*会*吓退*初学者*。*我们*经历*过*这样*的*情况*:*每次*执行*复杂*代码*时*,*需要*填写*大量*字段*并*运行*很长时间*,*我们*要*等*几*分钟*才能*查看*执行*结果*。*单元*测试*解决*了*这个*等待*问题*!*它们*改进*了*这个*复杂*代码*!*并且*根据*应用程序*或*代码*部分*,*测试*有利于*生产力*。
这是一篇*入门*文章*,*面向*那些*对*使用*测试*感到*不*安全*的人*。*许多*示例*都很*基础*,*以便*解释*一个*简单的*方法*。*这*比*内容*本身*更有*价值*。*不幸的是*,*无法*执行*本文*建议*的*测试*数量*,*可以*构建*更多*的*示例*和*情况*。*没有*应用程序*层*的*示例*,*其*单元*测试*与*其他*层*中*看到的*相似*。*此外*,*还*缺少*上述*其他*层*的*各种*示例*。*但*我们*相信*,*尽管*主题*被*简要*提及*,*但*“在*您的*代码*中*测试*什么*、*在哪里*测试*、*何时*测试*以及*由*谁*测试”*的任务*已经*完成*。
历史
- 2015 年 2 月 19 日:初始版本