且构网

分享程序员开发的那些事...
且构网 - 分享程序员编程开发的那些事

如何为实际使用数据库上下文的ASP.NET Core控制器编写单元测试?

更新时间:2023-02-25 16:46:20

我的系统目前似乎运行良好,所以我想与他人共享一下,看看它是否对某人没有帮助否则.实体框架文档中有真正有用的文章,指明方向.但是,这是我将其合并到实际工作应用程序中的方式.

I've got a system that seems to be working pretty well right now, so I thought I'd share it and see if it doesn't help someone else out. There's a really useful article in the Entity Framework documentation that points the way. But here's how I incorporated it into an actual working application.

这里有大量精彩的文章可以帮助您入门. 文档用于基本设置和脚手架是很有帮助.为此,您需要使用单个用户帐户创建一个Web应用程序,以便将ApplicationDbContext设置为自动与EntityFramework一起使用.

There are tons of great articles out there to help you get started. The documentation for basic setup and scaffolding is very helpful. For this purpose, you'll want to create a web app with Individual User Accounts so that your ApplicationDbContext is setup to work with EntityFramework automatically.

使用文档中包含的信息来创建具有基本CRUD操作的简单控制器.

Use the information included in the documentation to create a simple controller with basic CRUD actions.

在您的解决方案中,创建一个新的.NET Core库并引用您新创建的Web应用程序.在我的示例中,我正在使用的模型称为Company,它使用的是CompaniesController.

In your solution, create a new .NET Core Library and reference your newly created web app. In my example, the model I'm using is called Company, and it uses the CompaniesController.

对于这个项目,我使用 xUnit 作为测试运行者, FluentAssertions 做出更有意义的断言.使用NuGet软件包管理器和/或控制台将这三个库添加到您的项目中.您可能需要选中Show Prerelease复选框来搜索它们.

For this project, I use xUnit as my test runner, Moq for mocking objects, and FluentAssertions to make more meaningful assertions. Add those three libraries to your project using NuGet Package Manager and/or Console. You may need to search for them with the Show Prerelease checkbox selected.

您还需要几个软件包才能使用EntityFramework的新Sqlite-InMemory数据库选项. 这是秘密调味料.下面是NuGet上的软件包名称列表:

You will also need a couple of packages to use EntityFramework's new Sqlite-InMemory database option. This is the secret sauce. Below are a list of the package names on NuGet:

  • Microsoft.Data.Sqlite
  • Microsoft.EntityFramework 核心.InMemory[已添加重点]
  • Microsoft.EntityFramework 核心.Sqlite[已添加重点]
  • Microsoft.Data.Sqlite
  • Microsoft.EntityFrameworkCore.InMemory [emphasis added]
  • Microsoft.EntityFrameworkCore.Sqlite [emphasis added]

根据我之前提到的文章,是将Sqlite设置为可在其上运行测试的内存中关系数据库的简单,美观的方法.

Per the article I mentioned earlier, there is a simple, beautiful way to set up Sqlite to work as an in-memory, relational database which you can run your tests against.

您将要编写单元测试方法,以使每个方法都具有一个全新的数据库干净副本.上面的文章向您展示了如何一次性完成该工作.这是我将装置设置为尽可能 DRY 的方式.

You'll want to write your unit test methods so that each method has a new, clean copy of the database. The article above shows you how to do that on a one-off basis. Here's how I set up my fixture to be as DRY as possible.

我编写了以下方法,该方法允许我使用Arrange/Act/Assert模型编写测试,每个阶段均作为测试中的参数.下面是该方法及其引用的TestFixture中相关类属性的代码,最后是一个示例代码调用示例.

I've written the following method that allows me to write tests using the Arrange/Act/Assert model, with each stage acting as a parameter in my test. Below is the code for the method and the relevant class properties in the TestFixture that it references, and finally an example of what it looks like to call the code.

public class TestFixture {
    public SqliteConnection ConnectionFactory() => new SqliteConnection("DataSource=:memory:");

    public DbContextOptions<ApplicationDbContext> DbOptionsFactory(SqliteConnection connection) =>
        new DbContextOptionsBuilder<ApplicationDbContext>()
        .UseSqlite(connection)
        .Options;

    public Company CompanyFactory() => new Company {Name = Guid.NewGuid().ToString()};

    public void RunWithDatabase(
        Action<ApplicationDbContext> arrange,
        Func<ApplicationDbContext, IActionResult> act,
        Action<IActionResult> assert)
    {
        var connection = ConnectionFactory();
        connection.Open();

        try
        {
            var options = DbOptionsFactory(connection);

            using (var context = new ApplicationDbContext(options))
            {
                context.Database.EnsureCreated();
                // Arrange
                arrange?.Invoke(context);
            }

            using (var context = new ApplicationDbContext(options))
            {
                // Act (and pass result into assert)
                var result = act.Invoke(context);
                // Assert
                assert.Invoke(result);
            }
        }
        finally
        {
            connection.Close();
        }
    }
    ...
}

这里是调用代码以测试CompaniesController上的Create方法的样子(我使用参数名称来帮助我使表达式保持笔直,但您并非严格需要它们):

Here's what it looks like to call the code to test the Create method on the CompaniesController (I use parameter names to help me keep my expressions straight, but you don't strictly need them):

    [Fact]
    public void Get_ReturnsAViewResult()
    {
        _fixture.RunWithDatabase(
            arrange: null,
            act: context => new CompaniesController(context, _logger).Create(), 
            assert: result => result.Should().BeOfType<ViewResult>()
        );
    }

我的CompaniesController类需要一个记录器,我用Moq模拟并将其存储为TestFixture中的变量.

My CompaniesController class requires a logger, that I mock up with Moq and store as a variable in my TestFixture.

当然,许多内置的ASP.NET Core操作是异步的.为了将这种结构用于这些结构,我编写了以下方法:

Of course, many of the built-in ASP.NET Core actions are asynchronous. To use this structure with those, I've written the method below:

public class TestFixture {
    ...
    public async Task RunWithDatabaseAsync(
        Func<ApplicationDbContext, Task> arrange,
        Func<ApplicationDbContext, Task<IActionResult>> act,
        Action<IActionResult> assert)
    {
        var connection = ConnectionFactory();
        await connection.OpenAsync();

        try
        {
            var options = DbOptionsFactory(connection);

            using (var context = new ApplicationDbContext(options))
            {
                await context.Database.EnsureCreatedAsync();
                if (arrange != null) await arrange.Invoke(context);
            }

            using (var context = new ApplicationDbContext(options))
            {
                var result = await act.Invoke(context);
                assert.Invoke(result);
            }
        }
        finally
        {
            connection.Close();
        }
    }
}

几乎完全一样,只是使用异步方法和等待者进行设置.下面是调用这些方法的示例:

It's almost exactly the same, just setup with async methods and awaiters. Below, an example of calling these methods:

    [Fact]
    public async Task Post_WhenViewModelDoesNotMatchId_ReturnsNotFound()
    {
        await _fixture.RunWithDatabaseAsync(
            arrange: async context =>
            {
                context.Company.Add(CompanyFactory());
                await context.SaveChangesAsync();
            },
            act: async context => await new CompaniesController(context, _logger).Edit(1, CompanyFactory()),
            assert: result => result.Should().BeOfType<NotFoundResult>()
        );
    }

3c.带有数据的异步操作

当然,有时您必须在测试阶段之间来回传递数据.这是我编写的允许您执行此操作的方法:

3c. Async Actions with Data

Of course, sometimes you'll have to pass data back-and-forth between the stages of testing. Here's a method I wrote that allows you to do that:

public class TestFixture {
    ...
    public async Task RunWithDatabaseAsync(
        Func<ApplicationDbContext, Task<dynamic>> arrange,
        Func<ApplicationDbContext, dynamic, Task<IActionResult>> act,
        Action<IActionResult, dynamic> assert)
    {
        var connection = ConnectionFactory();
        await connection.OpenAsync();

        try
        {
            object data;
            var options = DbOptionsFactory(connection);

            using (var context = new ApplicationDbContext(options))
            {
                await context.Database.EnsureCreatedAsync();
                data = arrange != null 
                    ? await arrange?.Invoke(context) 
                    : null;
            }

            using (var context = new ApplicationDbContext(options))
            {
                var result = await act.Invoke(context, data);
                assert.Invoke(result, data);
            }
        }
        finally
        {
            connection.Close();
        }
    }
}

当然还有我如何使用此代码的示例:

And, of course, an example of how I use this code:

    [Fact]
    public async Task Post_WithInvalidModel_ReturnsModelErrors()
    {
        await _fixture.RunWithDatabaseAsync(
            arrange: async context =>
            {
                var data = new
                {
                    Key = "Name",
                    Message = "Name cannot be null",
                    Company = CompanyFactory()
                };
                context.Company.Add(data.Company);
                await context.SaveChangesAsync();
                return data;
            },
            act: async (context, data) =>
            {
                var ctrl = new CompaniesController(context, _logger);
                ctrl.ModelState.AddModelError(data.Key, data.Message);
                return await ctrl.Edit(1, data.Company);
            },
            assert: (result, data) => result.As<ViewResult>()
                .ViewData.ModelState.Keys.Should().Contain((string) data.Key)
        );
    }

结论

我真的希望这可以帮助某些人熟悉C#和ASP.NET Core中令人敬畏的新功能.如果您有任何疑问,批评或建议,请告诉我!我对此还是很陌生,因此任何建设性的反馈意见对我来说都是无价之宝!

Conclusion

I really hope this helps somebody getting on their feet with C# and the awesome new stuff in ASP.NET Core. If you have any questions, criticisms, or suggestions, please let me know! I'm still new at this, too, so any constructive feedback is invaluable to me!