## Description Follow-up to #25338 (the guides revamp). That PR collapsed most multi-page guide series into single pages but left ~50 guides wrapped in a `<name>/index.md` directory structure alongside guides that are already flat `<name>.md` files — an inconsistent mix. This PR finishes the flattening: - Flattens 50 folder-wrapped guides to `content/guides/<slug>.md` via `git mv` (history preserved). These keep the same URL, since Hugo generates the same permalink for a leaf-bundle `dir/index.md` and a flat `dir.md`. - Moves the last 4 guides' local per-guide image bundles (`genai-leveraging-rag`, `genai-video-bot`, `genai-claude-code-mcp`, `java`) into the shared `content/guides/images/` folder with slug-prefixed filenames, matching the convention already used by most other guides (`databases-1.webp`, `kafka-1.webp`, etc.), and rewrites the in-body image references. - Normalizes `genai-claude-code-mcp`'s non-standard filename (`claude-code-mcp-guide.md`) and capitalized `Images/` folder to match convention. - Promotes `content/guides/frameworks/laravel/index.md` to a top-level `content/guides/laravel.md`, since it was the only guide under `frameworks/` and every other language/framework guide is flat. - Adds `aliases` entries for the two URLs that actually changed (`/guides/frameworks/laravel/` and `/guides/genai-claude-code-mcp/claude-code-mcp-guide/`); every other rename preserves its existing URL. Companion PR establishing this as the documented convention going forward: #25503 ## Validation - `hugo --minify` build via `docker compose run` completes with zero warnings/errors (3174 pages, 1524 aliases — unchanged from before this PR). - Verified both changed-URL guides render at their new path and the old path 301s via the Hugo alias redirect. - Verified every image reference in the 4 migrated guides resolves to an existing file in `content/guides/images/`. - Multi-agent docs review (link-checker, cross-reference-checker, guide-conventions) found zero issues. ## Related issues or tickets Discussed in the team's docs Slack channel following #25338. ## Reviews - [ ] Technical review
17 KiB
title, linkTitle, description, keywords, summary, aliases, params
| title | linkTitle | description | keywords | summary | aliases | params | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Testing an ASP.NET Core web app with Testcontainers | ASP.NET Core testing | Learn how to use Testcontainers for .NET to replace SQLite with a real Microsoft SQL Server in ASP.NET Core integration tests. | testcontainers, dotnet, csharp, testing, mssql, asp.net core, integration testing, entity framework | Learn how to test an ASP.NET Core web app using Testcontainers for .NET with a real Microsoft SQL Server instance instead of SQLite. |
|
|
In this guide, you'll learn how to:
- Use Testcontainers for .NET to spin up a Microsoft SQL Server container for integration tests
- Replace SQLite with a production-like database provider in ASP.NET Core tests
- Customize
WebApplicationFactoryto configure test dependencies with Testcontainers - Manage container lifecycle with xUnit's
IAsyncLifetime
Prerequisites
- .NET 8.0+ SDK
- A code editor or IDE (Visual Studio, VS Code, Rider)
- A Docker environment supported by Testcontainers. For details, see the Testcontainers .NET system requirements.
Note
If you're new to Testcontainers, visit the Testcontainers overview to learn more about Testcontainers and the benefits of using it.
Set up the project
Background
This guide builds on top of Microsoft's Integration tests in ASP.NET Core documentation. The original sample uses an in-memory SQLite database as the backing store for integration tests. You'll replace SQLite with a real Microsoft SQL Server instance running in a Docker container using Testcontainers.
You can find the original code sample in the dotnet/AspNetCore.Docs.Samples repository.
Clone the repository
Clone the Testcontainers guide repository and change into the project directory:
$ git clone https://github.com/testcontainers/tc-guide-testing-aspnet-core.git
$ cd tc-guide-testing-aspnet-core
Project structure
The solution contains two projects:
RazorPagesProject.sln
├── src/RazorPagesProject/ # ASP.NET Core Razor Pages app
└── tests/RazorPagesProject.Tests/ # xUnit integration tests
Application project
The application project (src/RazorPagesProject/RazorPagesProject.csproj)
is a Razor Pages web app that uses Entity Framework Core with SQLite as its
default database provider:
<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>
The ApplicationDbContext stores Message entities and provides methods to
query and manage them:
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." }
};
}
}
Test project
The test project (tests/RazorPagesProject.Tests/RazorPagesProject.Tests.csproj)
includes xUnit, the ASP.NET Core testing infrastructure, and the
Testcontainers MSSQL module:
<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>
The key dependencies are:
Microsoft.AspNetCore.Mvc.Testing- providesWebApplicationFactoryfor bootstrapping the app in testsMicrosoft.EntityFrameworkCore.SqlServer- the SQL Server database provider for Entity Framework CoreTestcontainers.MsSql- the Testcontainers module for Microsoft SQL Server
Existing SQLite-based test factory
The original project includes a CustomWebApplicationFactory that replaces
the application's database with an in-memory SQLite instance:
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");
}
}
While this approach works, SQLite has behavioral differences from the database you'd use in production. In the next section, you'll replace it with a Testcontainers-managed Microsoft SQL Server instance.
Write tests with Testcontainers
The existing tests use an in-memory SQLite database. While convenient, this doesn't match production behavior. You can replace it with a real Microsoft SQL Server instance managed by Testcontainers.
Add dependencies
Change to the test project directory and add the SQL Server Entity Framework provider and the Testcontainers MSSQL module:
$ cd tests/RazorPagesProject.Tests
$ dotnet add package Microsoft.EntityFrameworkCore.SqlServer --version 7.0.0
$ dotnet add package Testcontainers.MsSql --version 3.0.0
Note
Testcontainers for .NET offers a range of modules that follow best practice configurations.
Create the test class
Create a MsSqlTests.cs file in the IntegrationTests directory. This class
manages the SQL Server container lifecycle and contains a nested test class.
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));
});
}
}
}
}
Understand the test structure
Container lifecycle with IAsyncLifetime
The outer MsSqlTests class implements IAsyncLifetime. xUnit calls
InitializeAsync() right after creating the class instance, which starts the
SQL Server container. After all tests complete, DisposeAsync() stops and
removes the container.
private readonly MsSqlContainer _msSqlContainer = new MsSqlBuilder().Build();
MsSqlBuilder().Build() creates a pre-configured Microsoft SQL Server
container. Testcontainers modules follow best practices, so you don't need
to configure ports, passwords, or startup wait strategies yourself.
Nested test class with IClassFixture
The IndexPageTests class is nested inside MsSqlTests and implements
IClassFixture<MsSqlTests>. This gives the test class access to the
container's private field and creates a clean hierarchy in the test explorer.
Custom WebApplicationFactory
Instead of using the SQLite-based factory, the nested
CustomWebApplicationFactory retrieves the connection string from the running
SQL Server container and passes it to 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));
});
}
}
This factory:
- Removes the existing
DbContextOptions<ApplicationDbContext>registration - Removes the existing
DbConnectionregistration - Adds a new
ApplicationDbContextconfigured with the SQL Server connection string from the Testcontainers-managed container
Note
The Microsoft SQL Server Docker image isn't compatible with ARM devices, such as Macs with Apple Silicon. You can use the SqlEdge module or Testcontainers Cloud as alternatives.
Run tests and next steps
Run the tests
Run the tests from the solution root:
$ dotnet test ./RazorPagesProject.sln
The first run may take longer because Docker needs to pull the Microsoft SQL Server image. On subsequent runs, the image is cached locally.
You should see xUnit discover and run the tests, including the
MsSqlTests.IndexPageTests class. Testcontainers starts a SQL Server
container, the tests execute against it, and the container is stopped and
removed automatically after the tests finish.
Summary
By replacing SQLite with a Testcontainers-managed Microsoft SQL Server instance, the integration tests run against the same type of database used in production. This approach catches database-specific issues early, such as differences in SQL dialect, transaction behavior, or data type handling between SQLite and SQL Server.
The MsSqlTests class uses IAsyncLifetime to manage the container lifecycle,
and a nested CustomWebApplicationFactory wires the container's connection
string into the application's service configuration. You can apply this same
pattern to any database or service that Testcontainers supports.
To learn more about Testcontainers, visit the Testcontainers overview.