65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.95/5 (38投票s)

2015年2月2日

CPOL

7分钟阅读

viewsIcon

161375

downloadIcon

1119

使用 xUnit、Entity Framework、Effort 和 Shouldly 在 ASP.NET Boilerplate 框架上实现了单元测试和集成测试。

目录

引言

在本文中,我将展示如何为 ASP.NET Boilerplate 项目创建单元测试。我将使用 本文 中开发的应用程序(*使用 AngularJs、ASP.NET MVC、Web API 和 EntityFramework 构建 NLayered 单页 Web 应用程序*),而不是创建一个新的待测试应用程序。解决方案结构如下:

Solution structrure

我们将测试项目的 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.ApplicationSimpleTaskSystem.CoreSimpleTaskSystem.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

SimpleTaskSystemTestBasePreInitialize 方法中,我们使用 EffortDbConnection 注册到依赖注入系统(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();
    }
}

SimpleTaskSystemTestBaseUsingDbContext 方法在我们直接使用 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模式,该模式包含三个步骤:

  1. Arrange(准备):为测试做准备。
  2. Act(执行):运行 SUT(被测软件 - 实际的测试代码)。
  3. Assert(断言):检查和验证结果。

Should_Create_New_Tasks 方法中,我们将创建两个任务,一个将分配给 Thomas More。因此,我们的三个步骤是:

  1. Arrange:我们从数据库获取(Thomas More)这个人,以获得他的 ID 和数据库中的当前任务计数(另外,我们在构造函数中创建了 TaskAppService)。
  2. Act:我们使用 TaskAppService.CreateTask 方法创建两个任务。
  3. Assert:我们检查任务计数是否增加了 2。我们还尝试从数据库获取创建的任务,以查看它们是否已正确插入到数据库中。

在这里,UsingDbContext 方法在直接处理 DbContext 时提供了帮助。如果此测试成功,我们就知道 CreateTask 方法可以在我们提供有效输入时创建 Tasks。另外,仓储也在工作,因为它将 Tasks 插入了数据库。

要运行测试,我们通过选择TEST\Windows\Test Explorer 来打开 Visual Studio Test Explorer

Open Visual Studio Test Explorer

然后,我们在 Test Explorer 中单击“Run All”链接。它会查找并运行解决方案中的所有测试。

Running first unit test using Visual Studio Test Explorer

如上所示,我们的第一个单元测试已通过。恭喜!如果测试或测试代码不正确,测试将失败。假设我们忘记将创建的任务分配给给定的人(为了测试这一点,请注释掉 TaskAppService.CreateTask 方法中的相关行)。当我们运行测试时,它将失败。

Failing test

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

我预计如果我不为创建任务设置 DescriptionCreateTask 方法会抛出 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 来测试异步方法。请看为测试 PersonAppServiceGetAllPeople 方法而编写的方法。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 的更多信息,请使用以下链接:

文章历史

  • 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
    • 文章首次发布
© . All rights reserved.