集成测试MySQL商店
#测试 #mysql #githubactions

这是较早的REST API with ASP.NET Core 7 and MySql的延续。在本教程中,我将扩展示例以添加集成测试以验证我们对MySqlMoviesStore的实现。

为什么集成测试

根据Wikipedia集成测试的定义,

是将单个软件模块组合和测试的阶段。

在我们的情况下,这很重要,因为我们使用外部系统来存储我们的数据,并且在我们宣布它已经准备好使用之前,我们需要确保它按预期工作。这也将有助于我们验证是否正确的绘制器映射是否正确,尤其是在使用任何自定义映射的情况下。

我们的选择是

  • 一种方法是运行数据库服务器和我们的API项目,并使用已定义的数据调用Swagger UI,Curl或Postman的端点,然后验证我们的服务是否正确存储和检索数据。每当我们进行更改时
  • 在我们的源代码中添加一组集成测试并每次进行更改时运行,这将确保我们所做的任何更改都没有破坏任何现有的拟合性和场景。重要的是要记住的是,这不是在石头上设置的,随着弹场性的发展,这些功能应更新,新功能将导致添加新的测试用例。

本文的重点是为我们之前实施的MySqlMoviesStore实施自动集成测试。

测试项目

让我们从添加一个新的测试项目开始。

  • 右键单击解决方案名称 - >添加 - >新项目 - >测试 - > Xunit测试项目 New Project
  • 选择目标框架,我选择了.NET 7.0,因为我们在此样本中定位.NET 7.0
  • 命名您的测试项目,我喜欢将其命名为项目我正在测试,然后是.Tests,然后是测试类型.Integration5 ”*单击创建创建test test Project。

设置

首先在Movies.Api.Tests.Integration项目中添加Nuget对我们的项目Movies.Api的引用,然后遵循Nuget软件包

dotnet add pacakge AutoFixture.Xunit2 --version 4.18.0
dotnet add pacakge FluentAssertions --version 6.11.0
dotnet add package Dapper.Contrib --version 2.0.78

为了测试MySqlMoviesStore提供的弹性,我们将需要一种访问数据库的方法,而无需通过我们的商店。这是为了确保例如Create FuncationLity独立于商店的GetById工作。为此,我将在Helper文件夹下添加几个辅助课程。

databaseHelper.cs

using System.Data;
using Dapper;
using Dapper.Contrib.Extensions;
using MySqlConnector;

namespace Movies.Api.Tests.Integration.Helpers;

public class DatabaseHelper<TId, TRecord>
    where TRecord : class
    where TId : notnull
{
    protected readonly string connectionString;
    private readonly string tableName;
    private readonly string idColumnName;
    protected readonly Func<TRecord, TId> idSelector;

    public DatabaseHelper(
        string connectionString,
        string tableName,
        Func<TRecord, TId> idSelector,
        string idColumnName = "Id")
    {
        this.connectionString = connectionString;
        this.tableName = tableName;
        this.idColumnName = idColumnName;
        this.idSelector = idSelector;
    }

    public Dictionary<TId, TRecord> AddedRecords { get; } = new Dictionary<TId, TRecord>();

    public virtual async Task<TRecord> GetRecordAsync(TId id)
    {
        await using var connection = new MySqlConnection(connectionString);
        return await connection.QueryFirstOrDefaultAsync<TRecord>(
            $"SELECT * FROM {tableName} WHERE {idColumnName} = @Id",
            new { Id = id },
            commandType: CommandType.Text);
    }

    public virtual async Task AddRecordAsync(TRecord record)
    {
        this.AddedRecords.Add(idSelector(record), record);
        await using var connection = new MySqlConnection(connectionString);
        await connection.InsertAsync<TRecord>(record);
    }

    public async Task AddRecordsAsync(IEnumerable<TRecord> records)
    {
        foreach (var record in records)
        {
            await AddRecordAsync(record);
        }
    }

    public void TrackId(TId id) => AddedRecords.Add(id, default!);

    public virtual async Task DeleteRecordAsync(TId id)
    {
        await using var connection = new MySqlConnection(connectionString);
        await connection.ExecuteAsync(
            $"DELETE FROM {tableName} WHERE {idColumnName} = @Id",
            new { Id = id },
            commandType: CommandType.Text);
    }

    public async Task CleanTableAsync()
    {
        foreach (var addedRecord in AddedRecords)
        {
            await DeleteRecordAsync(addedRecord.Key);
        }
    }
}

MOVIESDABASEHELPER.CS

using System.Data;
using Dapper;
using Movies.Api.Store;
using MySqlConnector;

namespace Movies.Api.Tests.Integration.Helpers;

public class MoviesDatabaseHelper : DatabaseHelper<Guid, Movie>
{
    public MoviesDatabaseHelper(string connectionString)
        : base(connectionString, "Movies", x => x.Id, "Id")
    { }

    public async override Task AddRecordAsync(Movie record)
    {
        this.AddedRecords.Add(idSelector(record), record);

        var parameters = new
        {
            record.Id,
            record.Title,
            record.Director,
            record.ReleaseDate,
            record.TicketPrice,
            CreatedAt = DateTime.UtcNow,
            UpdatedAt = DateTime.UtcNow,
        };

        var query = @"
            INSERT INTO Movies(
                Id,
                Title,
                Director,
                ReleaseDate,
                TicketPrice,
                CreatedAt,
                UpdatedAt
            )
            VALUES (
                @Id,
                @Title,
                @Director,
                @ReleaseDate,
                @TicketPrice,
                @CreatedAt,
                @UpdatedAt
            )";

        await using var connection = new MySqlConnection(connectionString);
        await connection.ExecuteAsync(query, parameters, commandType: CommandType.Text);
    }

    public async override Task<Movie> GetRecordAsync(Guid id)
    {
        await using var connection = new MySqlConnection(connectionString);
        return await connection.QueryFirstOrDefaultAsync<Movie>(
            $"SELECT Id, Title, Director, TicketPrice, ReleaseDate, CreatedAt, UpdatedAt FROM Movies WHERE Id = @Id",
            new { Id = id },
            commandType: CommandType.Text);
    }
}

下一个添加DatabaseFixture类以在测试类测试数据库之间传递共享数据库上下文,在我们的情况下,我们将在InitializeAsync Method中初始化。

namespace Movies.Api.Tests.Integration;

public class DatabaseFixture : IAsyncLifetime
{
    public string ConnectionString { get; private set; } = default!;

    public async Task InitializeAsync()
    {
        this.ConnectionString = "server=localhost;database=Movies;uid=root;password=Password123;SslMode=None;";
        await Task.CompletedTask;
    }

    public async Task DisposeAsync()
    {
        await Task.CompletedTask;
    }
}

接下来添加一个DatabaseCollection.cs文件,这是为了使我们可以将相同的DatabaseContext传递给多个文件。这是一个例子,因为我们只有一个类要测试。

namespace Movies.Api.Tests.Integration;

[CollectionDefinition("DatabaseCollection")]
public class DatabaseCollection : ICollectionFixture<DatabaseFixture>
{
    // This class has no code, and is never created. Its purpose is simply
    // to be the place to apply [CollectionDefinition] and all the
    // ICollectionFixture<> interfaces.
}

我们还删除默认的UnitTest1.cs文件。

mysqlmoviesstoreTests

我喜欢反映源项目中的结构,但请随时将测试文件放置在您喜欢的地方。我将添加一个Store,然后在该MySql文件夹下,然后创建MySqlMoviesStoreTests.cs文件。

我将首先添加moviesDatabaseHelpersut的实例(正在测试的系统)。在构造函数中,使用DatabaseFixtureConnectionString初始化moviesDatabaseHelper,然后初始化一个内存配置对象,然后将其传递给MySqlMoviesStore

我在DisposeAsync方法中调用CleanTableAsync,该方法将在每个测试后运行以清理测试插入的任何数据。

mysqlmoviesstoretests.cs

using AutoFixture.Xunit2;
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Movies.Api.Store;
using Movies.Api.Store.MySql;
using Movies.Api.Tests.Integration.Helpers;

namespace Movies.Api.Tests.Integration.Store.MySql;

[Collection("DatabaseCollection")]
public class MySqlMoviesStoreTests : IAsyncLifetime
{
    private readonly MoviesDatabaseHelper moviesDatabaseHelper;

    private readonly MySqlMoviesStore sut;

    public MySqlMoviesStoreTests(DatabaseFixture databaseFixture)
    {
        moviesDatabaseHelper = new MoviesDatabaseHelper(databaseFixture.ConnectionString);

        var myConfiguration = new Dictionary<string, string?>
        {
            {"ConnectionStrings:MoviesDb", databaseFixture.ConnectionString},
        };

        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(myConfiguration)
            .Build();

        sut = new MySqlMoviesStore(configuration);
    }

    public Task InitializeAsync() => Task.CompletedTask;

    public async Task DisposeAsync()
    {
        await moviesDatabaseHelper.CleanTableAsync();
    }
}

GetByid测试

是时候添加测试了。请注意,要先运行这些测试,我们需要启动我们的数据库服务器并应用迁移。让我们继续前进,然后再添加我们的测试。

docker-compose -f docker-compose.dev-env.yml up -d

我们的第一个测试非常简单,我喜欢将测试命名为MethodName_GivenCondition_ShouldExpectedResult以遵循模式,我添加了GetById_GivenRecordDoesNotExist_ShouldReturnNull,我将进入Leavarage出色的AutoFixture,以获取新的GUID作为参数。对于此测试,我们不需要安排部分,我们会跳过该法案,然后断言。为了断言,我将使用FluentAssertions。对于此测试,我们需要断言返回的结果为null。

[Theory]
[AutoData]
public async void GetById_GivenRecordDoesNotExist_ShouldReturnNull(Guid id)
{
    // Arrange

    // Act
    var result = await sut.GetById(id);

    // Assert
    result.Should().BeNull();
}

继续进行测试,应该为绿色。

让我们添加第二个测试GetById_GivenRecordExists_ShouldReturnRecord,在此测试中,我们将使用前面添加的助手插入新记录。在断言步骤中,我们将将结果与AutoFixture传递的实例进行比较,包括CreatedAtUpdatedAt属性,因为我们知道这些结果将设置为当前时间。相反,我们将测试这些设置为当前时间的1秒钟。我还排除了ReleasedAt属性,并将在通过价值传递的1秒内匹配它,我认为这是可以接受的,因为这次在插入时间的1秒内对于此用例就足够准确,但是,如果需要更准确性,我们会看起来很准确对于提供该准确性的适当列类型。在DisposeAsync方法的测试后,将清除新插入的记录。

[Theory]
[AutoData]
public async void GetById_GivenRecordExists_ShouldReturnRecord(Movie movie)
{
    // Arrange
    await moviesDatabaseHelper.AddRecordAsync(movie);

    // Act
    var result = await sut.GetById(movie.Id);

    // Assert
    result.Should().NotBeNull();
    result.Should().BeEquivalentTo(
        movie,
        x => x.Excluding(p => p.ReleaseDate).Excluding(p => p.CreatedAt).Excluding(p => p.UpdatedAt));
    result.ReleaseDate.Should().BeCloseTo(movie.ReleaseDate, TimeSpan.FromSeconds(1));
    result.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
    result.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
}

GetAll测试

第一次测试很容易,我们只能测试是否没有记录,GetAll返回空收藏。

[Fact]
public async void GetAll_GivenNoRecords_ShouldReturnEmptyCollection()
{
    // Arrange
    // Act
    var result = await this.sut.GetAll();

    // Assert
    result.Should().BeEmpty();
}

下一个测试是插入一些记录并执行GetAll并比较结果。我选择不像我们在GetById中这样做那样断言DateTime字段值,但是如果此方法在单独的类中,例如。如果我们选择拥有单独的命令和查询类。

[Theory]
[AutoData]
public async void GetAll_GivenRecordsExist_ShouldReturnCollection(List<Movie> movies)
{
    // Arrange
    await moviesDatabaseHelper.AddRecordsAsync(movies);

    // Act
    var result = await this.sut.GetAll();

    // Assert
    result.Should().BeEquivalentTo(movies, x => x.Excluding(p => p.ReleaseDate).Excluding(p => p.CreatedAt).Excluding(p => p.UpdatedAt));
}

创建测试

Create的首次测试是直截了当的,它将致电Create创建记录,然后使用moviesDatabaseHelper加载该记录并将其与传递的参数进行比较。

[Theory]
[AutoData]
public async void Create_GivenRecordDoesNotExist_ShouldCreateRecord(CreateMovieParams createMovieParams)
{
    // Arrange
    // Act
    await sut.Create(createMovieParams);
    moviesDatabaseHelper.TrackId(createMovieParams.Id);

    // Assert
    var createdMovie = await moviesDatabaseHelper.GetRecordAsync(createMovieParams.Id);

    createdMovie.Should().BeEquivalentTo(createMovieParams, x => x.Excluding(p => p.ReleaseDate));
    createdMovie.ReleaseDate.Should().BeCloseTo(createMovieParams.ReleaseDate, TimeSpan.FromSeconds(1));
    createdMovie.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
    createdMovie.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
}

第二个测试是检查方法是否已经存在,方法是否已存在。我们将使用moviesDatabaseHelper首先添加新记录,然后尝试创建新的记录。

[Theory]
[AutoData]
public async void Create_GivenRecordWithIdExists_ShouldThrowDuplicateKeyException(Movie movie)
{
    // Arrange
    await moviesDatabaseHelper.AddRecordAsync(movie);

    var createMovieParams = new CreateMovieParams(movie.Id, movie.Title, movie.Director, movie.TicketPrice, movie.ReleaseDate);

    // Act & Assert
    await Assert.ThrowsAsync<DuplicateKeyException>(async () => await sut.Create(createMovieParams));
}

更新测试

要测试更新,首先我们将创建一个记录,然后调用Update商店的方法以更新记录。更新后,我们将使用moviesDatabaseHelper加载保存的记录并验证保存的记录是否具有预期值。

[Theory]
[AutoData]
public async void Update_GivenRecordExists_ShouldUpdateRecord(Movie movie, UpdateMovieParams updateMovieParams)
{
    // Arrange
    await moviesDatabaseHelper.AddRecordAsync(movie);

    // Act
    await sut.Update(movie.Id, updateMovieParams);

    // Assert
    var saved = await moviesDatabaseHelper.GetRecordAsync(movie.Id);

    saved.Should().BeEquivalentTo(updateMovieParams, x => x.Excluding(p => p.ReleaseDate));
    saved.ReleaseDate.Should().BeCloseTo(updateMovieParams.ReleaseDate, TimeSpan.FromSeconds(1));
    saved.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
}

删除测试

要测试删除,首先,我们将使用moviesDatabaseHelper添加新记录,然后在商店中调用Delete方法。要验证我们将加载记录,然后断言加载值为null

[Theory]
[AutoData]
public async void Delete_GivenRecordExists_ShouldDeleteRecord(Movie movie)
{
    // Arrange
    await moviesDatabaseHelper.AddRecordAsync(movie);

    // Act
    await sut.Delete(movie.Id);

    // Assert
    var loaded = await moviesDatabaseHelper.GetRecordAsync(movie.Id);
    loaded.Should().BeNull();
}

这是整合测试的结论。运行这些测试确实需要我们在运行测试之前启动数据库服务器并在运行测试之前运行迁移。如果数据库未运行,则测试将无法运行。

CI的集成测试

我还添加了2个GitHub Actions工作流程以作为CI的一部分运行这些集成测试。

使用GitHub服务容器设置MySQL的工作流程

在此工作流程中,我们将利用GitHub service containers启动MySQL Server。我们将构建迁移容器并将其作为构建过程的一部分运行,以在运行集成测试之前应用迁移。这是完整的清单。

name: Integration Test MySQL (.NET)

on:
  push:
    branches: [ "main" ]
    paths:
     - 'integration-test-mysql-dotnet/**'

jobs:
  build:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: integration-test-mysql-dotnet

    services:
      movies.db.test:
        image: mysql:5.7
        env:
          MYSQL_DATABASE: defaultdb
          MYSQL_ROOT_PASSWORD: Password123
        options: >-
          --health-cmd="mysqladmin ping"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=3
        ports:
          - 3306:3306

    steps:
      - uses: actions/checkout@v3
      - name: Setup .NET Core SDK
        uses: actions/setup-dotnet@v3
        with:
          dotnet-version: 7.0.x
      - name: Install dependencies
        run: dotnet restore
      - name: Build
        run: dotnet build --configuration Release --no-restore
      - name: Build migratinos Docker image
        run: docker build --file ./db/Dockerfile -t movies.db.migrations ./db
      - name: Run migrations
        run: docker run --add-host=host.docker.internal:host-gateway movies.db.migrations "Host=host.docker.internal;database=defaultdb;uid=root;password=Password123;SslMode=None;"
      - name: Run integration tests
        run: dotnet test --configuration Release --no-restore --no-build --verbosity normal

使用Docker-Compose设置MySQL的工作流程

在此工作流程中,我们将使用docker-compose.dev-env.yml启动MySQL并在检查代码后将迁移作为工作流程的第一步。这是完整的清单。

name: Integration Test MySQL (.NET) with docker-compose

on:
  push:
    branches: [ "main" ]
    paths:
     - 'integration-test-mysql-dotnet/**'

jobs:
  build:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: integration-test-mysql-dotnet

    steps:
      - uses: actions/checkout@v3
      - name: Start container and apply migrations
        run: docker compose -f "docker-compose.dev-env.yml" up -d --build
      - name: Setup .NET Core SDK
        uses: actions/setup-dotnet@v3
        with:
          dotnet-version: 7.0.x
      - name: Install dependencies
        run: dotnet restore
      - name: Build
        run: dotnet build --configuration Release --no-restore        
      - name: Run integration tests
        run: dotnet test --configuration Release --no-restore --no-build --verbosity normal
      - name: Stop containers
        run: docker compose -f "docker-compose.dev-env.yml" down --remove-orphans --rmi all --volumes

来源

演示应用程序的源代码托管在blog-code-samples存储库的GitHub上。

Integration Test MySQL (.NET)工作流的来源在integration-test-mysql-dotnet.yml中。

Integration Test MySQL (.NET) with docker-compose工作流的来源在integration-test-mysql-dotnet-docker-compose.yml中。

参考

没有特定顺序