Testing Vertical Slices: Integration Tests per Slice and Unit Tests Where They Matter
A slice's natural test unit is the path from the HTTP request to the real database, while domain rules are tested cheaply with pure unit tests. We build per-slice tests with WebApplicationFactory, a real SQL Server via Testcontainers, and Respawn, and show with real output how tests break each other without a reset and how the InMemory provider both passes and fails wrongly on the same search query.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
A book search slice is being added to Bookstore. So that typing "dune" also finds "Dune", the developer makes the comparison explicitly case-insensitive:
app.MapGet("/books", (string term, BookstoreDbContext db, CancellationToken ct) =>
db.Books
.Where(b => b.Title.Contains(term, StringComparison.OrdinalIgnoreCase))
.OrderBy(b => b.Title)
.Select(b => new BookSummary(b.Id, b.Title, b.Price))
.ToListAsync(ct));
To keep it fast, the slice's test is written with the InMemory provider of Entity Framework Core (EF Core), and it passes:
[Fact]
public async Task Search_ignores_case()
{
HttpClient client = factory.CreateClient();
List<BookSummary>? books = await client.GetFromJsonAsync<List<BookSummary>>("/api/books?term=dune", TestContext.Current.CancellationToken);
BookSummary book = Assert.Single(books!);
Assert.Equal("Dune", book.Title);
}
passed (851ms)
Test run summary: Passed!
total: 1
failed: 0
succeeded: 1
Guess: when the same slice runs against the SQL (Structured Query Language) Server database in production, what does a search for "dune" return?
The Natural Test Unit of a Slice
In a layered project, tests usually follow the layers: a unit test for the service with a fake repository; a unit test for the controller with a fake service. When Bogard's 2018 VSA (Vertical Slice Architecture) post calls layered architectures "mock-heavy", this is what he's pointing at. In a slice, those boundaries are mostly absent: the endpoint, validation, handler, query, and database together make up one behaviour. Whether the search slice works depends on which SQL the LINQ (Language Integrated Query) query is translated into and how the database runs that SQL. Testing it with a mock means leaving the very thing that needs testing outside the test.
So in VSA the default test unit is the slice itself: from the HTTP (Hypertext Transfer Protocol) request down to the database. WebApplicationFactory, from the ASP.NET Core Web API (Application Programming Interface) series post "Integration Testing an ASP.NET Core Web API: End-to-End Tests with WebApplicationFactory", brings the application up in memory; what's missing is the same database as production. Pure unit tests have their place too, but a different one: the domain model where the rules live.
This doesn't mean turning the classic test pyramid upside down; the definition of its middle layer changes. In a layered project an "integration test" is often a test that runs a few classes together but still fakes the database. In a slice, the middle layer is a test that runs one application request end to end, with the real database but without a browser and with external services faked. External systems such as the payment provider stay fake (FakePaymentGateway) because their behaviour isn't under our control; the database is part of the application and isn't faked.
A Real Database: WebApplicationFactory and Testcontainers
Testcontainers starts a real SQL Server container in Docker during the tests and removes it when they finish. One container and one application for all tests:
using Bookstore.Api.Domain;
using Bookstore.Api.Infrastructure;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Respawn;
using Testcontainers.MsSql;
namespace Bookstore.Api.Tests;
// One container and one application for all tests
public sealed class BookstoreApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly MsSqlContainer _sql = new MsSqlBuilder("mcr.microsoft.com/mssql/server:2025-latest").Build();
private Respawner _respawner = null!;
public async ValueTask InitializeAsync()
{
await _sql.StartAsync();
using IServiceScope scope = Services.CreateScope();
BookstoreDbContext db = scope.ServiceProvider.GetRequiredService<BookstoreDbContext>();
await db.Database.EnsureCreatedAsync();
db.Books.AddRange(
new Book { Title = "Dune", Price = 12.50m },
new Book { Title = "Emma", Price = 8.00m });
await db.SaveChangesAsync();
// Books are reference data: only the order tables are cleared before each test
await using SqlConnection connection = new(_sql.GetConnectionString());
await connection.OpenAsync();
_respawner = await Respawner.CreateAsync(connection, new RespawnerOptions
{
TablesToIgnore = ["Books"]
});
}
public async Task ResetDatabaseAsync()
{
await using SqlConnection connection = new(_sql.GetConnectionString());
await connection.OpenAsync();
await _respawner.ResetAsync(connection);
}
protected override void ConfigureWebHost(IWebHostBuilder builder) =>
builder.UseSetting("ConnectionStrings:Bookstore", _sql.GetConnectionString());
public override async ValueTask DisposeAsync()
{
await base.DisposeAsync();
await _sql.DisposeAsync();
}
}
[CollectionDefinition(nameof(ApiCollection))]
public sealed class ApiCollection : ICollectionFixture<BookstoreApiFactory>;
The application itself didn't change: Program.cs reads the connection string from configuration, and the factory supplies the container's with UseSetting. In Testcontainers.MsSql 4.16 the image goes to the constructor; the old new MsSqlBuilder().WithImage(...) form now produces a CS0618 warning. For System.Security.Cryptography.Pkcs 9.0.11, which SQL Server's SqlClient brings in, NuGet reported known vulnerabilities with NU1902 (moderate) and NU1903 (high) warnings; I silenced them by adding version 10.0.14 of the package explicitly to the API project. The factory is an xUnit collection fixture: every test class shares the same container and application, and the container starts once per test run, not once per test.
A Test per Slice
Each slice's test class exercises, over HTTP, the behaviours a user expects from that slice:
using System.Net;
using System.Net.Http.Json;
using Bookstore.Api.Features.Orders.PlaceOrder;
using Microsoft.AspNetCore.Mvc;
namespace Bookstore.Api.Tests;
public sealed class PlaceOrderTests(BookstoreApiFactory factory) : SliceTestBase(factory)
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
[Fact]
public async Task Places_order_and_returns_total()
{
HttpResponseMessage response = await Client.PostAsJsonAsync("/api/orders",
new { customerId = 7, lines = new[] { new { bookId = 1, quantity = 2 }, new { bookId = 2, quantity = 1 } } }, Ct);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
PlaceOrderResponse? body = await response.Content.ReadFromJsonAsync<PlaceOrderResponse>(Ct);
Assert.Equal(33.00m, body!.Total);
}
[Fact]
public async Task Rejects_more_than_five_copies_across_lines()
{
HttpResponseMessage response = await Client.PostAsJsonAsync("/api/orders",
new { customerId = 7, lines = new[] { new { bookId = 1, quantity = 3 }, new { bookId = 1, quantity = 4 } } }, Ct);
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
ProblemDetails? problem = await response.Content.ReadFromJsonAsync<ProblemDetails>(Ct);
Assert.Equal("At most 5 copies of book 1 per order.", problem!.Detail);
}
[Fact]
public async Task Rejects_invalid_input_before_the_handler_runs()
{
HttpResponseMessage response = await Client.PostAsJsonAsync("/api/orders",
new { customerId = 0, lines = new[] { new { bookId = 1, quantity = 9 } } }, Ct);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}
The request body is deliberately an anonymous object: the test uses the HTTP contract, not the slice's request record; if a property on the record is renamed, the test catches it. Some behaviours don't show in the HTTP response. The declined payment bug in post 04 was exactly like that: the response was 402, but the order had been written to the database. So this test looks at the database too:
using System.Net;
using System.Net.Http.Json;
using Bookstore.Api.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace Bookstore.Api.Tests;
public sealed class CheckoutTests(BookstoreApiFactory factory) : SliceTestBase(factory)
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
[Fact]
public async Task Declined_payment_leaves_no_order_behind()
{
HttpResponseMessage response = await Client.PostAsJsonAsync("/api/checkout",
new { customerId = 8, lines = new[] { new { bookId = 2, quantity = 2 } }, cardToken = "declined" }, Ct);
Assert.Equal(HttpStatusCode.PaymentRequired, response.StatusCode);
using IServiceScope scope = Factory.Services.CreateScope();
BookstoreDbContext db = scope.ServiceProvider.GetRequiredService<BookstoreDbContext>();
Assert.Equal(0, await db.Orders.CountAsync(Ct));
}
[Fact]
public async Task Successful_payment_stores_a_paid_order()
{
HttpResponseMessage response = await Client.PostAsJsonAsync("/api/checkout",
new { customerId = 8, lines = new[] { new { bookId = 2, quantity = 2 } }, cardToken = "tok_ok" }, Ct);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
string details = await Client.GetStringAsync(response.Headers.Location, Ct);
Assert.Contains("\"status\":\"Paid\"", details);
}
}
How many tests should a slice have? A practical measure is one test for every distinct HTTP response the slice can return: the happy path, an input validation failure (400), a business rule violation (422), a missing record (404), unauthorized access (401 or 403). Test names describe those behaviours too: Rejects_more_than_five_copies_across_lines tells the reader the slice's promise, not the handler's internals. Because these tests don't depend on the slice's internal structure, none of them changed when we moved a rule from the handler into the entity in post 04; that is exactly the safety net refactoring needs.
When checking the database we open a new scope and a new DbContext; looking at the context the request used would mean also seeing unsaved changes.
Isolation Between Tests: Respawn
Since all tests share the same database, one can see the data another left behind. A small base class that resets the database before every test:
namespace Bookstore.Api.Tests;
// Every test starts with clean order tables
[Collection(nameof(ApiCollection))]
public abstract class SliceTestBase(BookstoreApiFactory factory) : IAsyncLifetime
{
protected BookstoreApiFactory Factory { get; } = factory;
protected HttpClient Client { get; } = factory.CreateClient();
public async ValueTask InitializeAsync() => await Factory.ResetDatabaseAsync();
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
Jimmy Bogard's Respawn library (Apache 2.0, version 7.0.0) reads the schema once and generates DELETE commands in foreign key order; TablesToIgnore keeps reference data such as books. In a copy with the reset turned off, the result:
passed Bookstore.Api.Tests.PlaceOrderTests.Places_order_and_returns_total (45ms)
passed Bookstore.Api.Tests.CheckoutTests.Successful_payment_stores_a_paid_order (46ms)
failed Bookstore.Api.Tests.CheckoutTests.Declined_payment_leaves_no_order_behind (9ms)
Assert.Equal() Failure: Values differ
Expected: 0
Actual: 2
The test broke not because of a slice doing its job wrong, but because of two orders other tests left behind; and since xUnit doesn't guarantee test order, this failure shows up in some runs and not in others. That's the worst kind of test.
The alternative is running each test inside a transaction and rolling back at the end. EF Core's own documentation describes this technique too; but with WebApplicationFactory, the application opens its own DbContext and connection per request, so carrying the test's transaction into the application means adding test hooks to production code. Respawn's cost is a few DELETEs per test; as the timings below show, it's unnoticeable for a small schema.
Pure Unit Tests for Domain Rules
The invariants we moved into Order in post 04 need neither HTTP nor a database. For them, a classic unit test is the right tool:
using Bookstore.Api.Domain;
namespace Bookstore.Api.Tests;
// Domain rules: no database, no HTTP, milliseconds
public sealed class OrderTests
{
private static Order NewOrder() => new(customerId: 7, placedAtUtc: new DateTime(2026, 11, 8, 10, 0, 0, DateTimeKind.Utc));
[Fact]
public void Adding_the_same_book_twice_merges_the_line()
{
Order order = NewOrder();
order.AddItem(bookId: 1, quantity: 2, unitPrice: 12.50m);
order.AddItem(bookId: 1, quantity: 3, unitPrice: 12.50m);
OrderItem item = Assert.Single(order.Items);
Assert.Equal(5, item.Quantity);
Assert.Equal(62.50m, order.Total);
}
[Fact]
public void Sixth_copy_is_rejected()
{
Order order = NewOrder();
order.AddItem(1, 5, 12.50m);
DomainException ex = Assert.Throws<DomainException>(() => order.AddItem(1, 1, 12.50m));
Assert.Equal("At most 5 copies of book 1 per order.", ex.Message);
}
[Fact]
public void Cancelled_order_cannot_change()
{
Order order = NewOrder();
order.Cancel();
Assert.Throws<DomainException>(() => order.AddItem(2, 1, 8.00m));
Assert.Throws<DomainException>(order.MarkPaid);
}
}
You can try every edge case of a rule here cheaply (exactly 5, 6, cancelled, paid); the slice test then verifies once that the rule surfaces correctly over HTTP, with 422 and the right message. Had the rule stayed in the slice, inside the handler, every edge case would have needed an HTTP test. Every rule moved into the domain makes the tests cheaper too.
Managing Run Time
A run of the whole suite on this machine:
passed Bookstore.Api.Tests.OrderTests.Sixth_copy_is_rejected (4ms)
passed Bookstore.Api.Tests.OrderTests.Adding_the_same_book_twice_merges_the_line (3ms)
passed Bookstore.Api.Tests.OrderTests.Cancelled_order_cannot_change (0ms)
passed Bookstore.Api.Tests.CheckoutTests.Successful_payment_stores_a_paid_order (231ms)
passed Bookstore.Api.Tests.CheckoutTests.Declined_payment_leaves_no_order_behind (21ms)
passed Bookstore.Api.Tests.PlaceOrderTests.Rejects_more_than_five_copies_across_lines (12ms)
passed Bookstore.Api.Tests.PlaceOrderTests.Places_order_and_returns_total (16ms)
passed Bookstore.Api.Tests.PlaceOrderTests.Rejects_invalid_input_before_the_handler_runs (3ms)
passed Bookstore.Api.Tests.SearchBooksTests.Search_ignores_case (14ms)
failed: 0
duration: 9s 236ms
Most of the nine seconds is one-off: according to Testcontainers' log, about 6 seconds passed between creating the SQL Server container and it being ready (on Apple Silicon the x64 image runs under emulation). The first HTTP test, at 231 ms, carries the warm-up of the application and the EF Core model; the rest take 3–21 ms. A few rules for keeping the time under control as the number of slices grows:
- Start the container once per run, not per test (collection fixture); clean the database with Respawn instead of recreating it.
- Leave edge cases to domain unit tests and give each slice a few representative scenarios.
- Tests sharing the same database run sequentially in one collection; if you need parallelism, give each collection its own database.
- In CI (continuous integration), pull or cache the container image ahead of time; most of the first run is download time.
Pitfall: The InMemory Provider
Back to the opening question. The same test, against SQL Server in Testcontainers:
System.InvalidOperationException: The LINQ expression 'DbSet<Book>()
.Where(b => b.Title.Contains(
value: @term,
comparisonType: OrdinalIgnoreCase))' could not be translated. Additional information: Translation of method 'string.Contains' failed. If this method can be mapped to your custom function, see https://go.microsoft.com/fwlink/?linkid=2132413 for more information. Either rewrite the query in a form that can be translated, or switch to client evaluation explicitly by inserting a call to 'AsEnumerable', 'AsAsyncEnumerable', 'ToList', or 'ToListAsync'. See https://go.microsoft.com/fwlink/?linkid=2101038 for more information.
The InMemory provider doesn't translate the query into SQL; it runs the LINQ in .NET, in memory, where Contains with a StringComparison parameter works fine. SQL Server can't translate that overload into SQL. The test is green, and in production every search returns 500. The fix is to leave the comparison to the database:
public sealed class SearchBooksEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder app) =>
app.MapGet("/books", (string term, BookstoreDbContext db, CancellationToken ct) =>
db.Books
// Translated to LIKE on SQL Server; the collation decides case sensitivity
.Where(b => b.Title.Contains(term))
.OrderBy(b => b.Title)
.Select(b => new BookSummary(b.Id, b.Title, b.Price))
.ToListAsync(ct));
}
Because SQL Server's default collation is case-insensitive, the Testcontainers test now passes. This time the InMemory test breaks, because Contains in .NET is case-sensitive:
failed Bookstore.Api.InMemoryTests.SearchBooksTests.Search_ignores_case (314ms)
Assert.Single() Failure: The collection was empty
The InMemory provider both passed where it shouldn't have and failed where it shouldn't have. EF Core's "Choosing a testing strategy" documentation explicitly discourages using this provider for tests and says it's supported only for legacy applications; ignoring transactions and not supporting raw SQL at all are on the same list. In the EF Core series post "Testing with EF Core: SQLite In-Memory and InMemory Provider Pitfalls" we saw that SQLite in-memory is better but still a different database; case sensitivity differs from SQL Server there too.
Common Pitfalls
Mocking DbContext and DbSet. Queries are extension methods on IQueryable, so a real mock isn't possible; a "mocked DbSet" is in practice a list in memory, with the same problems as the InMemory provider.
Unit testing the handler and never testing the slice. A slice whose handler test passes but whose validation attribute was forgotten, whose route has a typo, or whose exception handler isn't registered is broken from the user's point of view. At least one HTTP test per slice verifies those connections.
Writing tests that depend on each other. "First the test that places an order runs, then the one that cancels it" is fragile where order isn't guaranteed. Each test sets up the data it needs: the cancel test places its own order first.
Testing with database settings production doesn't have. If the container's SQL Server differs from production in collation, version, or compatibility level, Testcontainers can mislead you just like InMemory. Keep the image tag the same as production.
A Small Experiment
★☆☆ Run the search slice in its StringComparison.OrdinalIgnoreCase form against the Testcontainers tests and read the error message. Then write the same query with EF.Functions.Like and compare the results in both test projects.
★★☆ Write a slice test for CancelOrder: the test places its own order, cancels it, and verifies that adding a book to the cancelled order returns 422. Run it on its own and as part of the whole suite.
★★★ Set up isolation with transaction rollback instead of Respawn: re-register BookstoreDbContext in the factory so that it runs on a single connection and transaction opened by the test. What did you have to add to production code? Compare the run time with the Respawn version.
That completes how slices are written, shared, and tested. In the series' final post we turn to the decision: when Vertical Slice Architecture fits and when it doesn't, how to use it together with Clean Architecture and a Modular Monolith, and how to move a layered project to slices gradually.