使用 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
	- 文章首次发布
 


