使用 xUnit、Entity Framework、Effort 和 ASP.NET Boilerplate 进行 C# 单元测试






4.95/5 (38投票s)
使用 xUnit、Entity Framework、Effort 和 Shouldly 在 ASP.NET Boilerplate 框架上实现了单元测试和集成测试。
- 从 Github 仓库 获取源代码。
目录
引言
在本文中,我将展示如何为 ASP.NET Boilerplate 项目创建单元测试。我将使用 本文 中开发的应用程序(*使用 AngularJs、ASP.NET MVC、Web API 和 EntityFramework 构建 NLayered 单页 Web 应用程序*),而不是创建一个新的待测试应用程序。解决方案结构如下:
我们将测试项目的 Application Services
。它包含 SimpleTaskSystem.Core
、SimpleTaskSystem.Application
和 SimpleTaskSystem.EntityFramework
项目。您可以阅读 这篇文章 来了解如何构建此应用程序。这里,我将专注于测试。
创建测试项目
我创建了一个名为 SimpleTaskSystem.Test
的新类库项目,并添加了以下 nuget 包:
Abp.TestBase
:为 ABP 项目提供一些基础类,使测试更轻松。Abp.EntityFramework
:我们使用 EntityFramework 6.x 作为 ORM。Effort.EF6
:使创建易于使用的 EF 内存数据库成为可能。xunit
:我们将使用的测试框架。此外,还添加了xunit.runner.visualstudio
包以在 Visual Studio 中运行测试。编写本文时,此包仍为预发布版本。因此,我在 nuget 包管理器对话框中选择了“包含预发布版本”。Shouldly
:该库使编写断言变得容易。
添加这些包时,它们的依赖项也将自动添加。最后,我们应该引用 SimpleTaskSystem.Application
、SimpleTaskSystem.Core
和 SimpleTaskSystem.EntityFramework
程序集,因为我们将测试这些项目。
准备一个基础测试类
为了更轻松地创建测试类,我将创建一个准备一个假数据库连接的基础类。
/// <summary>
/// This is base class for all our test classes.
/// It prepares ABP system, modules and a fake, in-memory database.
/// Seeds database with initial data (<see cref="SimpleTaskSystemInitialDataBuilder"/>).
/// Provides methods to easily work with DbContext.
/// </summary>
public abstract class SimpleTaskSystemTestBase : AbpIntegratedTestBase<SimpleTaskSystemTestModule>
{
protected SimpleTaskSystemTestBase()
{
//Seed initial data
UsingDbContext(context => new SimpleTaskSystemInitialDataBuilder().Build(context));
}
protected override void PreInitialize()
{
//Fake DbConnection using Effort!
LocalIocManager.IocContainer.Register(
Component.For<DbConnection>()
.UsingFactoryMethod(Effort.DbConnectionFactory.CreateTransient)
.LifestyleSingleton()
);
base.PreInitialize();
}
public void UsingDbContext(Action<SimpleTaskSystemDbContext> action)
{
using (var context = LocalIocManager.Resolve<SimpleTaskSystemDbContext>())
{
context.DisableAllFilters();
action(context);
context.SaveChanges();
}
}
public T UsingDbContext<T>(Func<SimpleTaskSystemDbContext, T> func)
{
T result;
using (var context = LocalIocManager.Resolve<SimpleTaskSystemDbContext>())
{
context.DisableAllFilters();
result = func(context);
context.SaveChanges();
}
return result;
}
}
这个基础类继承自 AbpIntegratedTestBase
。这是一个初始化 ABP 系统的基类。它定义了 LocalIocContainer
属性,这是一个 IIocManager
对象。每个测试将使用其专用的 IIocManager
。因此,测试将彼此隔离。
我们应该创建一个专门用于测试的模块。这里是 SimpleTaskSystemTestModule
。
[DependsOn(
typeof(SimpleTaskSystemDataModule),
typeof(SimpleTaskSystemApplicationModule),
typeof(AbpTestBaseModule)
)]
public class SimpleTaskSystemTestModule : AbpModule
{
}
该模块仅定义了要测试的依赖模块和 AbpTestBaseModule
。
在 SimpleTaskSystemTestBase
的 PreInitialize
方法中,我们使用 Effort
将 DbConnection
注册到依赖注入系统(PreInitialize
方法用于在 ABP 初始化之前运行一些代码)。我们将它注册为 Singleton
(针对 LocalIocContainer
)。因此,即使我们在同一个测试中创建多个 DbContext
,同一个数据库(和连接)也将在此测试中使用。SimpleTaskSystemDbContext
必须有一个接受 DbConnection
的构造函数才能使用这个内存数据库。因此,我添加了接受 DbConnection
的构造函数:
public class SimpleTaskSystemDbContext : AbpDbContext
{
public virtual IDbSet<Task> Tasks { get; set; }
public virtual IDbSet<Person> People { get; set; }
public SimpleTaskSystemDbContext()
: base("Default")
{
}
public SimpleTaskSystemDbContext(string nameOrConnectionString)
: base(nameOrConnectionString)
{
}
//This constructor is used in tests
public SimpleTaskSystemDbContext(DbConnection connection)
: base(connection, true)
{
}
}
在 SimpleTaskSystemTestBase
的构造函数中,我们还在数据库中创建了初始数据
。这很重要,因为一些测试需要数据库中存在的数据。SimpleTaskSystemInitialDataBuilder
类按如下方式填充数据库:
public class SimpleTaskSystemInitialDataBuilder
{
public void Build(SimpleTaskSystemDbContext context)
{
//Add some people
context.People.AddOrUpdate(
p => p.Name,
new Person {Name = "Isaac Asimov"},
new Person {Name = "Thomas More"},
new Person {Name = "George Orwell"},
new Person {Name = "Douglas Adams"}
);
context.SaveChanges();
//Add some tasks
context.Tasks.AddOrUpdate(
t => t.Description,
new Task
{
Description = "my initial task 1"
},
new Task
{
Description = "my initial task 2",
State = TaskState.Completed
},
new Task
{
Description = "my initial task 3",
AssignedPerson = context.People.Single(p => p.Name == "Douglas Adams")
},
new Task
{
Description = "my initial task 4",
AssignedPerson = context.People.Single(p => p.Name == "Isaac Asimov"),
State = TaskState.Completed
});
context.SaveChanges();
}
}
SimpleTaskSystemTestBase
的 UsingDbContext
方法在我们直接使用 DbContext
来操作数据库时,使其更容易创建 DbContext
。我们在构造函数中使用了它。我们还将在测试中看到如何使用它。
我们所有的测试类都将继承自 SimpleTaskSystemTestBase
。因此,所有测试都将通过初始化 ABP、使用带有初始数据的假数据库来启动。我们也可以在这个基类中添加通用的辅助方法,以使测试更容易。
创建第一个测试
我们将创建第一个单元测试来测试 TaskAppService
类的 CreateTask
方法。TaskAppService
类和 CreateTask
方法定义如下:
public class TaskAppService : ApplicationService, ITaskAppService
{
private readonly ITaskRepository _taskRepository;
private readonly IRepository<Person> _personRepository;
public TaskAppService(ITaskRepository taskRepository, IRepository<Person> personRepository)
{
_taskRepository = taskRepository;
_personRepository = personRepository;
}
public void CreateTask(CreateTaskInput input)
{
Logger.Info("Creating a task for input: " + input);
var task = new Task { Description = input.Description };
if (input.AssignedPersonId.HasValue)
{
task.AssignedPerson = _personRepository.Load(input.AssignedPersonId.Value);
}
_taskRepository.Insert(task);
}
//...other methods
}
在单元测试中,通常会模拟测试类的依赖项(通过使用 Moq 和 NSubstitute 等模拟框架创建假的实现)。这使得单元测试变得更加困难,尤其是当依赖项增加时。
我们不会这样做,因为我们正在使用依赖注入。所有依赖项都将通过依赖注入自动填充真实的实现,而不是假的。唯一假的只是数据库
。实际上,这是一个集成测试
,因为它不仅测试 TaskAppService
,还测试了仓储。甚至,我们还在测试它与 ASP.NET Boilerplate 的验证、工作单元和其他基础设施的集成。这非常有价值,因为我们正在更真实地测试应用程序。
那么,让我们创建第一个测试 CreateTask
方法。
public class TaskAppService_Tests : SimpleTaskSystemTestBase
{
private readonly ITaskAppService _taskAppService;
public TaskAppService_Tests()
{
//Creating the class which is tested (SUT - Software Under Test)
_taskAppService = LocalIocManager.Resolve<ITaskAppService>();
}
[Fact]
public void Should_Create_New_Tasks()
{
//Prepare for test
var initialTaskCount = UsingDbContext(context => context.Tasks.Count());
var thomasMore = GetPerson("Thomas More");
//Run SUT
_taskAppService.CreateTask(
new CreateTaskInput
{
Description = "my test task 1"
});
_taskAppService.CreateTask(
new CreateTaskInput
{
Description = "my test task 2",
AssignedPersonId = thomasMore.Id
});
//Check results
UsingDbContext(context =>
{
context.Tasks.Count().ShouldBe(initialTaskCount + 2);
context.Tasks.FirstOrDefault(t => t.AssignedPersonId == null &&
t.Description == "my test task 1").ShouldNotBe(null);
var task2 = context.Tasks.FirstOrDefault(t => t.Description == "my test task 2");
task2.ShouldNotBe(null);
task2.AssignedPersonId.ShouldBe(thomasMore.Id);
});
}
private Person GetPerson(string name)
{
return UsingDbContext(context => context.People.Single(p => p.Name == name));
}
}
正如前面所述,我们继承自 SimpleTaskSystemTestBase
。在单元测试中,我们应该创建将被测试的对象。在构造函数中,我使用了 LocalIocManager
(依赖注入管理器)来创建 ITaskAppService
(它会创建 TaskAppService
,因为它实现了 ITaskAppService
)。这样,我就不必创建依赖项的模拟实现了。
Should_Create_New_Tasks
是测试方法。它带有 xUnit 的 Fact
属性。因此,xUnit
会识别这是一个测试方法,并运行它。
在测试方法中,我们通常遵循AAA模式,该模式包含三个步骤:
- Arrange(准备):为测试做准备。
- Act(执行):运行 SUT(被测软件 - 实际的测试代码)。
- Assert(断言):检查和验证结果。
在 Should_Create_New_Tasks
方法中,我们将创建两个任务,一个将分配给 Thomas More。因此,我们的三个步骤是:
Arrange
:我们从数据库获取(Thomas More)这个人,以获得他的 ID 和数据库中的当前任务计数(另外,我们在构造函数中创建了TaskAppService
)。Act
:我们使用TaskAppService.CreateTask
方法创建两个任务。Assert
:我们检查任务计数是否增加了2
。我们还尝试从数据库获取创建的任务,以查看它们是否已正确插入到数据库中。
在这里,UsingDbContext
方法在直接处理 DbContext
时提供了帮助。如果此测试成功,我们就知道 CreateTask
方法可以在我们提供有效输入时创建 Tasks
。另外,仓储也在工作,因为它将 Tasks
插入了数据库。
要运行测试,我们通过选择TEST\Windows\Test Explorer 来打开 Visual Studio Test Explorer
。
然后,我们在 Test Explorer 中单击“Run All
”链接。它会查找并运行解决方案中的所有测试。
如上所示,我们的第一个单元测试已通过。恭喜!如果测试或测试代码不正确,测试将失败。假设我们忘记将创建的任务分配给给定的人(为了测试这一点,请注释掉 TaskAppService.CreateTask
方法中的相关行)。当我们运行测试时,它将失败。
Shouldly
库使失败消息更清晰。它还使编写断言变得容易。比较 xUnit 的 Assert.Equal
和 Shouldly 的 ShouldBe
扩展方法:
Assert.Equal(thomasMore.Id, task2.AssignedPersonId); //Using xunit's Assert
task2.AssignedPersonId.ShouldBe(thomasMore.Id); //Using Shouldly
我认为第二个更简单、更自然地编写和阅读。Shouldly 还有许多其他扩展方法,可以使我们的生活更轻松。请参阅其文档。
测试异常
我想为 CreateTask
方法创建第二个测试。但这次是使用无效输入
。
[Fact]
public void Should_Not_Create_Task_Without_Description()
{
//Description is not set
Assert.Throws<AbpValidationException>(() => _taskAppService.CreateTask(new CreateTaskInput()));
}
我预计如果我不为创建任务设置 Description
,CreateTask
方法会抛出 AbpValidationException
。因为在 CreateTaskInput
DTO 类中,Description
属性被标记为 Required
(请参阅源代码)。此测试成功,如果 CreateTask
抛出异常,否则失败。请注意:输入验证和异常抛出是由 ASP.NET Boilerplate 基础设施完成的。
在测试中使用仓储
我将测试将任务从一个人分配给另一个人。
//Trying to assign a task of Isaac Asimov to Thomas More
[Fact]
public void Should_Change_Assigned_People()
{
//We can work with repositories instead of DbContext
var taskRepository = LocalIocManager.Resolve<ITaskRepository>();
//Obtain test data
var isaacAsimov = GetPerson("Isaac Asimov");
var thomasMore = GetPerson("Thomas More");
var targetTask = taskRepository.FirstOrDefault(t => t.AssignedPersonId == isaacAsimov.Id);
targetTask.ShouldNotBe(null);
//Run SUT
_taskAppService.UpdateTask(
new UpdateTaskInput
{
TaskId = targetTask.Id,
AssignedPersonId = thomasMore.Id
});
//Check result
taskRepository.Get(targetTask.Id).AssignedPersonId.ShouldBe(thomasMore.Id);
}
在此测试中,我使用了 ITaskRepository
来执行数据库操作,而不是直接操作 DbContext
。您可以使用其中一种方法或混合使用。
测试异步方法
我们也可以使用 xUnit 来测试异步
方法。请看为测试 PersonAppService
的 GetAllPeople
方法而编写的方法。GetAllPeople
方法是异步
的,所以测试方法也应该是异步
的。
[Fact]
public async Task Should_Get_All_People()
{
var output = await _personAppService.GetAllPeople();
output.People.Count.ShouldBe(4);
}
源代码
您可以在 此处 获取最新的源代码。
摘要
在本文中,我想简单地展示如何在 ASP.NET Boilerplate 应用程序框架之上开发的应用程序中进行测试。ASP.NET Boilerplate 提供了一个良好的基础设施来实现测试驱动开发,或者简单地为您的应用程序创建一些单元/集成测试。
Effort 库提供了一个与 EntityFramework 配合良好的假数据库。只要您使用 EntityFramework 和 LINQ 来执行数据库操作,它就能正常工作。如果您有存储过程并想对其进行测试,Effort 将不起作用。在这种情况下,我建议使用 LocalDB
。
有关 ASP.NET Boilerplate 的更多信息,请使用以下链接:
- 官方网站和文档:aspnetboilerplate.com
- Github 仓库: github.com/aspnetboilerplate
- 在 Twitter 上关注: @aspboilerplate
文章历史
- 2018-02-22
- 已升级到 ABP v3.4
- 2017-06-28
- 已将源代码升级到 ABP v2.1.3
- 2016-07-19
- 已针对 ABP v0.10 版本升级文章和源代码
- 2016-01-07
- 已将解决方案升级到 .NET Framework 4.5.2
- 已将 Abp 升级到 v0.7.7.1
- 2015-06-15
- 已根据最新的 ABP 版本更新示例项目和文章
- 2015-02-02
- 文章首次发布