In the first article of this series, we explored how to test minimal web APIs in ASP.NET using an in-memory dictionary. But now, it's time to level up!
Instead of relying on a simple in-memory store, we’ll integrate a PostgreSQL database and use Testcontainers to run isolated, repeatable integration tests in containers. In this article, we will:
- Set up testing with Testcontainers.
- Adjust our tests to use a real database.
- Watch our tests fail and fix them step-by-step.
- Create and apply migrations using
dotnet ef
.
🧪 From In-Memory to Containers
In the first iteration, we tested using:
- A
ConcurrentDictionary
to store books. - Lightweight xUnit tests with no external dependencies.
Now, we’re upgrading to:
- Centralized NuGet package versioning for cleaner dependency management.
- Testcontainers to isolate tests in containers.
- PostgreSQL using EF Core for persistence.
🧰 Centralizing Package Versions with Directory.Packages.props
First, ensure that you manage package versions centrally across all projects. Run this command in the root of your repository:
dotnet new packagesprops
This creates a Directory.Packages.props
file. Then, add the following content:
true
Include="coverlet.collector" Version="6.0.2" />
Include="FluentAssertions" Version="8.2.0" />
Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.3" />
Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
Include="xunit" Version="2.9.2" />
Include="xunit.runner.visualstudio" Version="2.8.2" />
See Central Package Management | Microsoft Learn for more details.
🔌 Project Setup
Ensure your project files no longer include version information, since this is now defined in Directory.Packages.props
. Your *.csproj
files should look like this:
📘 Web API (BooksInventory.WebApi.csproj
)
Sdk="Microsoft.NET.Sdk.Web">
net9.0
enable
enable
🧪 Tests (BooksInventory.WebApi.Tests.csproj
)
Sdk="Microsoft.NET.Sdk">
net9.0
enable
enable
false
Include="coverlet.collector" />
Include="FluentAssertions" />
Include="Microsoft.AspNetCore.Mvc.Testing" />
Include="Microsoft.NET.Test.Sdk" />
Include="xunit" />
Include="xunit.runner.visualstudio" />
Include="..\..\src\BooksInventory.WebApi\BooksInventory.WebApi.csproj" />
After updating, run a dotnet restore
to ensure your solution picks up the centralized versioning.
🧪 Writing Tests with Testcontainers
This section shows how to write tests using Testcontainers while leveraging xUnit features for management of asynchronous initialization and shared setups. We use the following concepts from xUnit:
- IAsyncLifetime: For asynchronous setup and teardown of test resources.
- CollectionDefinition: To group tests that share the same setup/teardown logic.
- Collection: To associate test classes with the shared collection, ensuring tests run in an isolated, consistent environment.
See Shared Context between Tests | xUnit.net for more details.
Adding Required NuGet Packages
For the Test Project:
Add the Testcontainers NuGet package via:
dotnet add package TestContainers.PostgreSql --version 4.3.0
For the Web API Project:
Add the following packages to integrate EF Core with PostgreSQL:
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL --version 9.0.4
dotnet add package Microsoft.EntityFrameworkCore.Design --version 9.0.4
dotnet add package Microsoft.EntityFrameworkCore --version 9.0.4
🏗 Setting Up the Database Context
First, update your Book
record to include an immutable Id
property and use init
accessors for all properties. The updated version looks like this:
public record Book
{
public int Id { get; init; }
public required string Title { get; init; }
public required string Author { get; init; }
public required string ISBN { get; init; }
}
Note: After updating the
Book
class, ensure that any code referencing it is modified accordingly so that the project compiles.
Then, add the BooksInventoryDbContext
class to the Web API project:
using Microsoft.EntityFrameworkCore;
namespace BooksInventory.WebApi;
public class BooksInventoryDbContext : DbContext
{
public DbSet<Book> Books { get; set; }
public BooksInventoryDbContext(DbContextOptions<BooksInventoryDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Book>()
.HasKey(u => u.Id);
modelBuilder.Entity<Book>()
.HasIndex(u => u.Title)
.IsUnique();
}
}
🚀 Integration Test Setup
To prepare for integration testing, we introduce several helper classes and attributes.
PostgreSqlContainerFixture
This fixture starts a PostgreSQL container using Testcontainers and applies all pending migrations to initialize the database schema.
public class PostgreSqlContainerFixture : IAsyncLifetime
{
public PostgreSqlContainer Postgres { get; private set; }
public PostgreSqlContainerFixture()
{
Postgres = new PostgreSqlBuilder()
.WithImage("postgres:latest")
.Build();
}
public async Task InitializeAsync()
{
await Postgres.StartAsync();
// Ensure that the database schema is created by applying migrations.
var options = new DbContextOptionsBuilder<BooksInventoryDbContext>()
.UseNpgsql(Postgres.GetConnectionString())
.Options;
using var context = new BooksInventoryDbContext(options);
await context.Database.MigrateAsync();
}
public Task DisposeAsync() => Postgres.DisposeAsync().AsTask();
}
Usage Explanation:
The PostgreSqlContainerFixture
sets up a PostgreSQL container for the test run and applies migrations so that the database mirrors the production schema.
CustomWebApplicationFactory
By extending WebApplicationFactory
, this class configures the test host to use PostgreSQL instead of the default in-memory store. The override in ConfigureWebHost
replaces the default DB context registration with one that uses the container’s connection string.
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
private readonly string postgreSqlConnectionString;
public CustomWebApplicationFactory(string postgreSqlConnectionString)
{
this.postgreSqlConnectionString = postgreSqlConnectionString;
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
// Overwrite the existing DB context registration so that tests use the PostgreSQL container.
builder.ConfigureServices(services =>
{
services.AddDbContext<BooksInventoryDbContext>(options =>
{
options.UseNpgsql(this.postgreSqlConnectionString);
});
});
}
}
Usage Explanation:
This abstraction hides the details of database registration during tests, keeping test classes clean while ensuring that the Web API connects to the proper PostgreSQL instance.
Test Collection and Class Setup
We then define a collection to share our PostgreSQL container fixture and apply it to our test classes:
[CollectionDefinition(nameof(IntegrationTestsCollection))]
public class IntegrationTestsCollection : ICollectionFixture<PostgreSqlContainerFixture> {}
[Collection(nameof(IntegrationTestsCollection))]
public class BooksInventoryTests : IAsyncLifetime
{
private readonly CustomWebApplicationFactory factory;
private readonly HttpClient client;
private readonly BooksInventoryDbContext dbContext;
public BooksInventoryTests(PostgreSqlContainerFixture fixture)
{
this.factory = new CustomWebApplicationFactory(fixture.Postgres.GetConnectionString());
this.client = this.factory.CreateClient();
// Create a scope to retrieve a scoped instance of the DB context.
// This allows direct interaction with the database for setup and teardown.
var scope = this.factory.Services.CreateScope();
dbContext = scope.ServiceProvider.GetRequiredService<BooksInventoryDbContext>();
}
public async Task InitializeAsync()
{
// Clean the database to ensure test isolation.
dbContext.Books.RemoveRange(dbContext.Books);
await dbContext.SaveChangesAsync();
}
// Dispose resources to maintain isolation after each test run.
public Task DisposeAsync()
{
this.client.Dispose();
this.factory.Dispose();
return Task.CompletedTask;
}
// ... your tests go here
}
Usage Explanation:
- [CollectionDefinition] and [Collection] Attributes: These group test classes that share the same PostgreSQL fixture so that the container is started once per collection.
- IAsyncLifetime: Implements asynchronous setup and cleanup of test resources.
Phew! That’s a lot of setup, but it’s crucial for ensuring that our tests run in a clean, isolated environment.
❌ Watch the Tests Fail
At this point, running the tests will fail because the application is still using an in-memory store.
✅ Update Program.cs
to Use EF Core
Now let's update Program.cs
to make the application use EF Core with PostgreSQL.
using BooksInventory.WebApi;
var builder = WebApplication.CreateBuilder(args);
// Register the PostgreSQL service with EF Core.
// This replaces any default in-memory service configuration.
builder.Services.AddDbContext<BooksInventoryDbContext>(options =>
{
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"));
});
var app = builder.Build();
// Inject the DB context to add a new book asynchronously.
app.MapPost("/addBook", async (AddBookRequest request, BooksInventoryDbContext db) =>
{
var book = new Book
{
Title = request.Title,
Author = request.Author,
ISBN = request.ISBN
};
db.Books.Add(book);
await db.SaveChangesAsync();
return Results.Ok(new AddBookResponse(book.Id));
});
// Inject the DB context to get a book asynchronously.
app.MapGet("/books/{id}", async (int id, BooksInventoryDbContext db) =>
{
var book = await db.Books.FindAsync(id);
return book is not null
? Results.Ok(book)
: Results.NotFound(new { Message = "Book not found", BookId = id });
});
app.Run();
public record AddBookRequest(string Title, string Author, string ISBN);
public record AddBookResponse(int BookId);
public record Book
{
public int Id { get; init; }
public required string Title { get; init; }
public required string Author { get; init; }
public required string ISBN { get; init; }
};
// Explicitly define Program as partial for integration tests.
public partial class Program { }
Additional Instructions:
- Update
appsettings.Development.json
with the following connection string:
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=books_inventory;Username=user;Password=password"
}
🧱 Add Migrations
Now that the application has been updated to use PostgreSQL, add the initial migrations with:
dotnet ef migrations add InitialCreate --project src/BooksInventory.WebApi/BooksInventory.WebApi.csproj
This command generates the migration files necessary to set up your PostgreSQL database schema.
After adding the migrations, run your tests using:
dotnet test
If your tests fail, is mostly because you should update them to reflect the change we've made. For example:
// this won't work anymore
result!.BookId.Should().NotBeNullOrEmpty();
// should be change to this
result!.BookId.Should().BeGreaterThan(0);
Also, if you see a warning like this:
/usr/lib/dotnet/sdk/9.0.105/Microsoft.Common.CurrentVersion.targets(2413,5): warning MSB3277:
Found conflicts between different versions of "Microsoft.EntityFrameworkCore.Relational" that could not be resolved.
You can fix it just by adding the Microsoft.EntityFrameworkCore.Relational
package to the BooksInventory.WebApi
project with the right version:
dotnet add src/BooksInventory.WebApi package Microsoft.EntityFrameworkCore.Relational --version 9.0.4
Your tests should now run smoothly within the containerized environment.
⚙️ Developer Setup for Manual Testing
For manual testing in a development environment, be sure to apply the migrations after starting the database. Use the following docker-compose.yml
in your root folder, to start PostgreSQL:
services:
database:
image: postgres:15-alpine
container_name: books_inventory_db
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: password
POSTGRES_DB: books_inventory
ports:
- "5432:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "user", "-d", "books_inventory"]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres-data:
- Start the database container:
docker compose up -d
- Apply migrations:
Right after the service is up, run:
dotnet ef database update --project src/BooksInventory.WebApi/BooksInventory.WebApi.csproj
If you see an error message such as:
Failed executing DbCommand (10ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT "MigrationId", "ProductVersion"
FROM "__EFMigrationsHistory"
ORDER BY "MigrationId";
this is normal since this error appears when no migrations have been applied yet. You may safely ignore it.
- Test the database connection:
Install the PostgreSQL client (for example, on Ubuntu use sudo apt install postgresql-client
), then connect using:
psql -h localhost -U user -d books_inventory
# Enter "password" as password when prompted.
- Verify the Database Schema:
In the psql
terminal, run:
\dt
The expected output should be similar to:
Schema | Name | Type | Owner
--------+-----------------------+-------+-------
public | Books | table | user
public | __EFMigrationsHistory | table | user
(2 rows)
Manual Testing in VSCode Using a REST Client
For a quick manual test of your API endpoints:
- Open the project in VSCode.
- Use the REST client extension and create an
*.http
file with the following content:
# Base URL
@baseUrl = http://localhost:5000
# Test POST /addBook
POST {{baseUrl}}/addBook HTTP/1.1
Content-Type: application/json
{
"Title": "The Pragmatic Programmer",
"Author": "Andy Hunt and Dave Thomas",
"ISBN": "9780135957059"
}
###
# Test GET /books/{id} (replace {id} with a valid BookId from the POST response)
GET {{baseUrl}}/{id} HTTP/1.1
Accept: application/json
- Start your web app:
dotnet run --project src/BooksInventory.WebApi/BooksInventory.WebApi.csproj
. - Send these requests to verify that the API routes are functioning as expected.
🚢 Final Thoughts
By following these steps, we’ve:
- Set up Testcontainers for isolated, reproducible integration tests.
- Switched our storage from an in-memory dictionary to a real PostgreSQL database.
- Applied migrations to ensure that our tests run against a schema identical to production.
Next Steps:
- Setting up a CI pipeline.
- Explore integrating Redis for caching.
- Add RabbitMQ for messaging.
- Implement OpenTelemetry for distributed tracing.
Stay tuned for more in-depth articles into system design and testing strategies!
Your Turn: Try this approach in your own projects and see how much smoother your testing workflow becomes. Got a cool testing trick? Share it in the comments or hit me up on GitHub here. 📬