Testcontainers、ASP.Net Core Web API、EF Core整合測試

ASP.NET Core

為何要使用Testcontainers?

筆者先前在實務上運行在整合測試的時候,先前都是用localDB來進行做整合測試,再來整合測試部分建置環境的要求會比較多,必須要建置資料庫和安裝所需的工具。

所以這時候才開始嘗試接觸用Testcontainers部分,這一次先實驗用EFCore來實驗看看,在看未來抽空寫Dapper部分,因為在企業很多運行的系統移轉多數都是從ADO.net DataTable慢慢演進使用Dapper來做資料存取方案,這一次先嘗試用EF Core的做法來,未來當做是一個使用的評估選擇。

所使用環境實驗

  1. Windows 10 Home 19045.4894
  2. Docker
  3. ASP.net Core
  4. Visual Studio 2022

快速建立API

建立空白的ASP.net Core WebAPI並在專案編輯檔案,查看一下並設置False…

為何要設置False筆者在建置EF Core有遇到 System.Globalization.CultureNotFoundException: Only the invariant culture is supported in globalization-invariant mode異常。

  1. <InternalsVisibleTo Include="CategoryAPI" />   
  2. <InvariantGlobalization>false</InvariantGlobalization>
<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <InvariantGlobalization>false</InvariantGlobalization>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter" Version="8.0.8" />
    <PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.8" />
    <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.8" />
    <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.8" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.8" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.8">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
    <PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="8.0.5" />
    <PackageReference Include="Swashbuckle.AspNetCore" Version="6.7.0" />
	<InternalsVisibleTo Include="CategoryAPI" />   
  </ItemGroup>

</Project>

 

接者WebApi需要套件

  1. Microsoft.EntityFrameworkCore
  2. Microsoft.EntityFrameworkCore.SqlServer
  3. Microsoft.EntityFrameworkCore.Tools

開始建立Model/Category.cs

 public class Category
 {
     public int Id { get; set; }
     public string Name { get; set; }
     public string Description { get; set; }
 }

建置CategoryDbContext

  public class CategoryDbContext:DbContext
  {
      public CategoryDbContext(DbContextOptions<CategoryDbContext> options):base(options)
      {
     
      }

      public DbSet<Category> Categories { get; set; }
  }

在方案底下的Controller底下用滑鼠右鍵加入API控制器並使用Entity Framework執行動作API控制器,這樣可以快速建立骨架程式碼。

註記:這個在vs code也是有快速建置程式碼的CRUD骨架,可以由開發者自己微調。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using CategoryAPI.Model;

namespace CategoryAPI.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class CategoriesController : ControllerBase
    {
        private readonly CategoryDbContext _context;

        public CategoriesController(CategoryDbContext context)
        {
            _context = context;
        }

        // GET: api/Categories
        [HttpGet]
        public async Task<ActionResult<IEnumerable<Category>>> GetCategories()
        {
            return await _context.Categories.ToListAsync();
        }

        // GET: api/Categories/5
        [HttpGet("{id}")]
        public async Task<ActionResult<Category>> GetCategory(int id)
        {
            var category = await _context.Categories.FindAsync(id);

            if (category == null)
            {
                return NotFound();
            }

            return category;
        }

        // PUT: api/Categories/5
        // To protect from overposting attacks, see https://go.microsoft.com/fwlink/?linkid=2123754
        [HttpPut("{id}")]
        public async Task<IActionResult> PutCategory(int id, Category category)
        {
            if (id != category.Id)
            {
                return BadRequest();
            }

            _context.Entry(category).State = EntityState.Modified;

            try
            {
                await _context.SaveChangesAsync();
            }
            catch (DbUpdateConcurrencyException)
            {
                if (!CategoryExists(id))
                {
                    return NotFound();
                }
                else
                {
                    throw;
                }
            }

            return NoContent();
        }

        // POST: api/Categories
        // To protect from overposting attacks, see https://go.microsoft.com/fwlink/?linkid=2123754
        [HttpPost]
        public async Task<ActionResult<Category>> PostCategory(Category category)
        {
            _context.Categories.Add(category);
            await _context.SaveChangesAsync();

            return CreatedAtAction("GetCategory", new { id = category.Id }, category);
        }

        // DELETE: api/Categories/5
        [HttpDelete("{id}")]
        public async Task<IActionResult> DeleteCategory(int id)
        {
            var category = await _context.Categories.FindAsync(id);
            if (category == null)
            {
                return NotFound();
            }

            _context.Categories.Remove(category);
            await _context.SaveChangesAsync();

            return NoContent();
        }

        private bool CategoryExists(int id)
        {
            return _context.Categories.Any(e => e.Id == id);
        }
    }
}
//Program.cs
public partial class Program { } // 為了 WebApplicationFactory 以便在測試專案當中能夠使用Program

接者建立所需的測試專案要的套件

  1. FluentAssertions
  2. Microsoft.AspNetCore.Mvc.Testing
  3. Microsoft.Extensions.DependencyInjection
  4. Testcontainers.MsSql

並加入專案參考CategoryApi

使用XUnit並用TestContainers來做整合測試

依據測試容器開始寫程式,建立一個CategoryApiApplicationFactory  類別來實作我們的WebApplicationFactory<T>類別所在的類別,在建構子當中設置SQL SERVER容器設定。

 public class CategoryApiApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime 
 {
     private const string Database = "master";
     private const string Username = "sa";
     private const string Password = "yourStrong(!)Password";
     private const ushort MsSqlPort = 1433;

     private readonly IContainer _mssqlContainer;
     public CategoryApiApplicationFactory()
     {
         // Initialize the SQL Server container
         _mssqlContainer = new ContainerBuilder()
             .WithImage("mcr.microsoft.com/mssql/server:2019-latest")
             .WithPortBinding(MsSqlPort, true)
             .WithEnvironment("ACCEPT_EULA", "Y")
             .WithEnvironment("MSSQL_SA_PASSWORD", Password)
             .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(MsSqlPort))
             .Build();
     }
 }

最後再補上資料庫的路徑與連線路徑和建立資料庫,確保讓整合測試可以跑。

    public class CategoryApiApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime 
    {
        private const string Database = "master";
        private const string Username = "sa";
        private const string Password = "yourStrong(!)Password";
        private const ushort MsSqlPort = 1433;

        private readonly IContainer _mssqlContainer;
        public CategoryApiApplicationFactory()
        {
            // Initialize the SQL Server container
            _mssqlContainer = new ContainerBuilder()
                .WithImage("mcr.microsoft.com/mssql/server:2019-latest")
                .WithPortBinding(MsSqlPort, true)
                .WithEnvironment("ACCEPT_EULA", "Y")
                .WithEnvironment("MSSQL_SA_PASSWORD", Password)
                .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(MsSqlPort))
                .Build();
        }
        protected override void ConfigureWebHost(IWebHostBuilder builder)
        {
            var host = _mssqlContainer.Hostname;
            var port = _mssqlContainer.GetMappedPublicPort(MsSqlPort);

            builder.ConfigureServices(services =>
            {
                services.RemoveAll(typeof(DbContextOptions<CategoryDbContext>));

                services.AddDbContext<CategoryDbContext>(options =>
                    options.UseSqlServer(
                        $"Server={host},{port};Database={Database};User Id={Username};Password={Password};TrustServerCertificate=True"));
                // Build the service provider
                var serviceProvider = services.BuildServiceProvider();

                // Ensure the database is created
                using (var scope = serviceProvider.CreateScope())
                {
                    var dbContext = scope.ServiceProvider.GetRequiredService<CategoryDbContext>();

                    // Ensure the database is created asynchronously
                    dbContext.Database.EnsureCreatedAsync().Wait();
                }
            });
        }

        public async Task InitializeAsync()
        {
            await _mssqlContainer.StartAsync();
        }

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

using FluentAssertions;
using System.Net.Http.Json;

namespace CategoryAPITests.Categories
{
    public class CategoryApiTests : IClassFixture<CategoryApiApplicationFactory>
    {
        private HttpClient _client;

        public CategoryApiTests(CategoryApiApplicationFactory factory)
        {
           _client = factory.CreateClient();
        }

        [Fact]
        public async Task GetCategories_ShouldReturnSuccess()
        {
            var response = await _client.GetAsync("/api/categories");
            response.EnsureSuccessStatusCode();
            var content = await response.Content.ReadAsStringAsync();
            content.Should().NotBeNullOrEmpty();
        }
        [Fact]
        public async Task CreateCategory_ShouldAddNewCategory()
        {
             var newCategory = new
            {
                Name = "New Category",
                Description = "This is a test category"
            };
            var response = await _client.PostAsJsonAsync("/api/categories", newCategory);
            response.EnsureSuccessStatusCode();
            var content = await response.Content.ReadAsStringAsync();
            content.Should().Contain("New Category");
        }
    }
}

最後運行整合測試。

總結:未來自己若遇到EF Core的系統,未來在寫整合測試的時候,也是一個這樣搭配組合備選方案的評估,畢竟容器化已經簡化了很多處理過程,不用自己準備環境和建置環境之類。

元哥的筆記