Testcontainers로 ASP.NET Core 웹 앱 테스트하기

Testcontainers로 ASP.NET Core 웹 앱 테스트하기

Testcontainers for .NET과 SQLite 대신 실제 Microsoft SQL Server 인스턴스로 ASP.NET Core 웹 앱을 테스트하는 방법을 배워볼게요.

출처: 문서

본문

이 가이드에서 배울 내용:

  • 통합 테스트용 Microsoft SQL Server 컨테이너를 띄우기 위해 Testcontainers for .NET 사용하기
  • ASP.NET Core 테스트에서 SQLite를 프로덕션과 유사한 데이터베이스 프로바이더로 교체하기
  • WebApplicationFactory 를 커스터마이즈해 Testcontainers로 테스트 의존성 구성하기
  • xUnit의 IAsyncLifetime 으로 컨테이너 수명주기 관리하기

사전 요구사항

  • .NET 8.0+ SDK
  • 코드 편집기 또는 IDE(Visual Studio, VS Code, Rider)
  • Testcontainers가 지원하는 Docker 환경. 자세한 내용은 Testcontainers .NET 시스템 요구사항 을 참고하세요.

참고: Testcontainers를 처음 접한다면 Testcontainers 개요 를 방문해 Testcontainers와 그 이점에 대해 알아보세요.

프로젝트 설정

배경

이 가이드는 Microsoft의 ASP.NET Core 통합 테스트 문서 를 기반으로 해요. 원래 샘플은 인메모리 SQLite 데이터베이스를 통합 테스트의 백킹 저장소로 사용해요. Testcontainers를 사용해 Docker 컨테이너에서 실행되는 실제 Microsoft SQL Server 인스턴스로 SQLite를 교체할 거예요.

원본 코드 샘플은 dotnet/AspNetCore.Docs.Samples 리포지토리 에서 찾을 수 있어요.

리포지토리 클론

Testcontainers 가이드 리포지토리를 클론하고 프로젝트 디렉터리로 이동해주세요:

$ git clone https://github.com/testcontainers/tc-guide-testing-aspnet-core.git
$ cd tc-guide-testing-aspnet-core

프로젝트 구조

솔루션에는 두 개의 프로젝트가 있어요:

RazorPagesProject.sln
├── src/RazorPagesProject/ # ASP.NET Core Razor Pages app
└── tests/RazorPagesProject.Tests/ # xUnit integration tests

어플리케이션 프로젝트

어플리케이션 프로젝트( src/RazorPagesProject/RazorPagesProject.csproj )는 SQLite를 기본 데이터베이스 프로바이더로 사용하는 Entity Framework Core를 쓰는 Razor Pages 웹 앱이에요:

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net9.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.0" />
    <PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="7.0.0" />
    <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="7.0.0" />
    <PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="7.0.0" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.0">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
  </ItemGroup>
</Project>

ApplicationDbContext 는 Message 엔티티를 저장하고 이를 쿼리·관리하는 메서드를 제공해요:

public class ApplicationDbContext : IdentityDbContext
{
    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
        : base(options)
    {
    }

    public virtual DbSet<Message> Messages { get; set; }

    public async virtual Task<List<Message>> GetMessagesAsync()
    {
        return await Messages.OrderBy(message => message.Text).AsNoTracking().ToListAsync();
    }

    public async virtual Task AddMessageAsync(Message message)
    {
        await Messages.AddAsync(message);
        await SaveChangesAsync();
    }

    public async virtual Task DeleteAllMessagesAsync()
    {
        foreach (Message message in Messages)
        {
            Messages.Remove(message);
        }
        await SaveChangesAsync();
    }

    public async virtual Task DeleteMessageAsync(int id)
    {
        var message = await Messages.FindAsync(id);
        if (message != null)
        {
            Messages.Remove(message);
            await SaveChangesAsync();
        }
    }

    public void Initialize()
    {
        Messages.AddRange(GetSeedingMessages());
        SaveChanges();
    }

    public static List<Message> GetSeedingMessages()
    {
        return new List<Message>()
        {
            new Message(){ Text = "You're standing on my scarf." },
            new Message(){ Text = "Would you like a jelly baby?" },
            new Message(){ Text = "To the rational mind, nothing is inexplicable; only unexplained." }
        };
    }
}

테스트 프로젝트

테스트 프로젝트( tests/RazorPagesProject.Tests/RazorPagesProject.Tests.csproj )는 xUnit, ASP.NET Core 테스트 인프라, Testcontainers MSSQL 모듈을 포함해요:

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net9.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="AngleSharp" Version="0.17.1" />
    <PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="7.0.0" />
    <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="7.0.0" />
    <PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="7.0.0" />
    <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="7.0.0" />
    <PackageReference Include="Microsoft.EntityFrameworkCore" Version="7.0.0" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.0" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="7.0.0" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.0">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.0" />
    <PackageReference Include="Testcontainers.MsSql" Version="3.0.0" />
    <PackageReference Include="xunit" Version="2.4.2" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\..\src\RazorPagesProject\RazorPagesProject.csproj" />
  </ItemGroup>
  <ItemGroup>
    <Content Update="xunit.runner.json">
      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
    </Content>
  </ItemGroup>
</Project>

핵심 의존성은 다음과 같아요:

  • Microsoft.AspNetCore.Mvc.Testing - 테스트에서 앱을 부트스트랩하기 위한 WebApplicationFactory 제공
  • Microsoft.EntityFrameworkCore.SqlServer - Entity Framework Core용 SQL Server 데이터베이스 프로바이더
  • Testcontainers.MsSql - Microsoft SQL Server용 Testcontainers 모듈

기존 SQLite 기반 테스트 팩토리

원래 프로젝트는 어플리케이션의 데이터베이스를 인메모리 SQLite 인스턴스로 교체하는 CustomWebApplicationFactory 를 포함해요:

public class CustomWebApplicationFactory<TProgram> : WebApplicationFactory<TProgram>
    where TProgram : class
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            var dbContextDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<ApplicationDbContext>));
            services.Remove(dbContextDescriptor);

            var dbConnectionDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbConnection));
            services.Remove(dbConnectionDescriptor);

            // Create open SqliteConnection so EF won't automatically close it.
            services.AddSingleton<DbConnection>(container =>
            {
                var connection = new SqliteConnection("DataSource=:memory:");
                connection.Open();
                return connection;
            });

            services.AddDbContext<ApplicationDbContext>((container, options) =>
            {
                var connection = container.GetRequiredService<DbConnection>();
                options.UseSqlite(connection);
            });
        });

        builder.UseEnvironment("Development");
    }
}

이 접근법은 작동하지만 SQLite는 프로덕션에서 사용할 데이터베이스와 동작 차이가 있어요. 다음 섹션에서 Testcontainers가 관리하는 Microsoft SQL Server 인스턴스로 교체할 거예요.

Testcontainers로 테스트 작성하기

기존 테스트는 인메모리 SQLite 데이터베이스를 사용해요. 편리하지만 프로덕션 동작과 일치하지 않아요. Testcontainers가 관리하는 실제 Microsoft SQL Server 인스턴스로 교체할 수 있어요.

의존성 추가

테스트 프로젝트 디렉터리로 이동해 SQL Server Entity Framework 프로바이더와 Testcontainers MSSQL 모듈을 추가해주세요:

$ cd tests/RazorPagesProject.Tests
$ dotnet add package Microsoft.EntityFrameworkCore.SqlServer --version 7.0.0
$ dotnet add package Testcontainers.MsSql --version 3.0.0

참고: Testcontainers for .NET은 모범 사례 구성을 따르는 다양한 모듈 을 제공해요.

테스트 클래스 만들기

IntegrationTests 디렉터리에 MsSqlTests.cs 파일을 만들어주세요. 이 클래스는 SQL Server 컨테이너 수명주기를 관리하고 중첩 테스트 클래스를 포함해요.

using System.Data.Common;
using System.Net;
using AngleSharp.Html.Dom;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using RazorPagesProject.Data;
using RazorPagesProject.Tests.Helpers;
using Testcontainers.MsSql;
using Xunit;

namespace RazorPagesProject.Tests.IntegrationTests;

public sealed class MsSqlTests : IAsyncLifetime
{
    private readonly MsSqlContainer _msSqlContainer = new MsSqlBuilder().Build();

    public Task InitializeAsync()
    {
        return _msSqlContainer.StartAsync();
    }

    public Task DisposeAsync()
    {
        return _msSqlContainer.DisposeAsync().AsTask();
    }

    public sealed class IndexPageTests : IClassFixture<MsSqlTests>, IDisposable
    {
        private readonly WebApplicationFactory<Program> _webApplicationFactory;
        private readonly HttpClient _httpClient;

        public IndexPageTests(MsSqlTests fixture)
        {
            var clientOptions = new WebApplicationFactoryClientOptions();
            clientOptions.AllowAutoRedirect = false;
            _webApplicationFactory = new CustomWebApplicationFactory(fixture);
            _httpClient = _webApplicationFactory.CreateClient(clientOptions);
        }

        public void Dispose()
        {
            _webApplicationFactory.Dispose();
        }

        [Fact]
        public async Task Post_DeleteAllMessagesHandler_ReturnsRedirectToRoot()
        {
            // Arrange
            var defaultPage = await _httpClient.GetAsync("/").ConfigureAwait(false);
            var document = await HtmlHelpers.GetDocumentAsync(defaultPage).ConfigureAwait(false);

            // Act
            var form = (IHtmlFormElement)document.QuerySelector("form[id='messages']");
            var submitButton = (IHtmlButtonElement)document.QuerySelector("button[id='deleteAllBtn']");
            var response = await _httpClient.SendAsync(form, submitButton).ConfigureAwait(false);

            // Assert
            Assert.Equal(HttpStatusCode.OK, defaultPage.StatusCode);
            Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
            Assert.Equal("/", response.Headers.Location.OriginalString);
        }

        private sealed class CustomWebApplicationFactory : WebApplicationFactory<Program>
        {
            private readonly string _connectionString;

            public CustomWebApplicationFactory(MsSqlTests fixture)
            {
                _connectionString = fixture._msSqlContainer.GetConnectionString();
            }

            protected override void ConfigureWebHost(IWebHostBuilder builder)
            {
                builder.ConfigureServices(services =>
                {
                    services.Remove(services.SingleOrDefault(service => typeof(DbContextOptions<ApplicationDbContext>) == service.ServiceType));
                    services.Remove(services.SingleOrDefault(service => typeof(DbConnection) == service.ServiceType));
                    services.AddDbContext<ApplicationDbContext>((_, option) => option.UseSqlServer(_connectionString));
                });
            }
        }
    }
}

테스트 구조 이해하기

IAsyncLifetime 으로 컨테이너 수명주기

바깥쪽 MsSqlTests 클래스가 IAsyncLifetime 을 구현해요. xUnit은 클래스 인스턴스를 만든 직후 InitializeAsync() 를 호출해 SQL Server 컨테이너를 시작해요. 모든 테스트가 완료된 후 DisposeAsync() 가 컨테이너를 중지하고 제거해요.

private readonly MsSqlContainer _msSqlContainer = new MsSqlBuilder().Build();

MsSqlBuilder().Build() 는 사전 구성된 Microsoft SQL Server 컨테이너를 만들어요. Testcontainers 모듈은 모범 사례를 따르므로 포트, 패스워드, 시작 대기 전략을 직접 구성할 필요가 없어요.

IClassFixture 로 중첩 테스트 클래스

IndexPageTests 클래스는 MsSqlTests 안에 중첩되어 IClassFixture 를 구현해요. 이는 테스트 클래스에게 컨테이너의 프라이빗 필드에 접근 권한을 주고 테스트 탐색기에서 깔끔한 계층 구조를 만들어요.

커스텀 WebApplicationFactory

SQLite 기반 팩토리 대신 중첩된 CustomWebApplicationFactory 가 실행 중인 SQL Server 컨테이너에서 연결 문자열을 가져와 UseSqlServer() 에 전달해요:

private sealed class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
    private readonly string _connectionString;

    public CustomWebApplicationFactory(MsSqlTests fixture)
    {
        _connectionString = fixture._msSqlContainer.GetConnectionString();
    }

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            services.Remove(services.SingleOrDefault(service => typeof(DbContextOptions<ApplicationDbContext>) == service.ServiceType));
            services.Remove(services.SingleOrDefault(service => typeof(DbConnection) == service.ServiceType));
            services.AddDbContext<ApplicationDbContext>((_, option) => option.UseSqlServer(_connectionString));
        });
    }
}

이 팩토리는:

  • 기존 DbContextOptions 등록을 제거해요
  • 기존 DbConnection 등록을 제거해요
  • Testcontainers가 관리하는 컨테이너의 SQL Server 연결 문자열로 구성된 새 ApplicationDbContext 를 추가해요

참고: Microsoft SQL Server Docker 이미지는 Apple Silicon Mac 같은 ARM 기기와 호환되지 않아요. 대안으로 SqlEdge 모듈 이나 Testcontainers Cloud 를 사용할 수 있어요.

테스트 실행 및 다음 단계

테스트 실행

솔루션 루트에서 테스트를 실행해주세요:

$ dotnet test ./RazorPagesProject.sln

첫 실행은 Docker가 Microsoft SQL Server 이미지를 내려받아야 하므로 더 오래 걸릴 수 있어요. 이후 실행에서는 이미지가 로컬에 캐시돼요.

xUnit이 MsSqlTests.IndexPageTests 클래스를 포함한 테스트를 발견하고 실행하는 것을 볼 수 있어요. Testcontainers가 SQL Server 컨테이너를 시작하고, 테스트가 그것을 대상으로 실행되며, 테스트 완료 후 컨테이너가 자동으로 중지·제거돼요.

요약

SQLite를 Testcontainers가 관리하는 Microsoft SQL Server 인스턴스로 교체함으로써 통합 테스트가 프로덕션에서 사용하는 것과 같은 유형의 데이터베이스에 대해 실행돼요. 이 접근법은 SQLite와 SQL Server 사이의 SQL 방언 차이, 트랜잭션 동작, 데이터 타입 처리 차이 같은 데이터베이스 특유의 문제를 조기에 잡아내요.

MsSqlTests 클래스는 IAsyncLifetime 을 사용해 컨테이너 수명주기를 관리하고, 중첩된 CustomWebApplicationFactory 가 컨테이너의 연결 문자열을 어플리케이션의 서비스 구성에 연결해요. 이 패턴은 Testcontainers가 지원하는 어떤 데이터베이스나 서비스에도 적용할 수 있어요.

Testcontainers에 대해 더 배우려면 Testcontainers 개요 를 방문해주세요.

추가 자료

  • Testcontainers for .NET 문서
  • Testcontainers for .NET 모듈
  • Microsoft SQL Server 모듈
  • ASP.NET Core 통합 테스트

더 알아보기 (Learn more)

  • Testcontainers for .NET
  • ASP.NET Core 통합 테스트
  • Testcontainers