Integration Tests
By the end of this lesson
Test against real infrastructure to catch wiring mistakes.
An integration test runs your code against the real thing it will use in production. A real database, a real migration, real SQL generated by the real provider.
Everything in the previous module removed infrastructure so tests could be fast and focused. That is the right trade for logic, and it leaves a gap: every unit test in the suite can pass while the application fails to start, because nothing the unit tests did ever touched a connection string, a registration or a query the database has to understand.
So this is not a stricter version of unit testing. It is a different question. A unit test asks whether a rule is correct. An integration test asks whether the parts fit together.
Defects a unit test with doubles structurally cannot find, because the double is not the component that fails:
- Wiring. A service registered with the wrong lifetime, or an interface with no registration at all. Tests construct the class themselves, so the container is never consulted.
- Migrations. A migration that has not been applied, or that fails on a table with existing rows. A fake repository has no schema, so it has no opinion about a missing column.
- Query translation. An expression that works against an in-memory list and cannot be turned into SQL. The fake evaluates it in C# and passes; the provider throws at run time.
- Database constraints. A unique index, a check constraint, a foreign key. A dictionary-backed fake accepts the duplicate that the database rejects.
- Serialisation. A property that round-trips through JSON as null because the name casing does not match, or a decimal that arrives as a string.
- Configuration. A connection string read from the wrong section, or a setting that only exists on a developer's machine.
- Concurrency and transactions. Two operations that each work alone and deadlock together, or a unit of work that commits half its changes.
using Microsoft.EntityFrameworkCore;
using Testcontainers.MsSql;
using Xunit;
public class SqlServerFixture : IAsyncLifetime
{
private readonly MsSqlContainer _container = new MsSqlBuilder().Build();
public string ConnectionString => _container.GetConnectionString();
public async Task InitializeAsync()
{
await _container.StartAsync();
await using var db = CreateContext();
await db.Database.MigrateAsync();
}
public OrderingDbContext CreateContext() =>
new OrderingDbContext(
new DbContextOptionsBuilder<OrderingDbContext>()
.UseSqlServer(ConnectionString)
.Options);
public Task DisposeAsync() => _container.DisposeAsync().AsTask();
}- Testcontainers starts a database in a container for the test run and throws it away afterwards. Nothing is installed on the machine, and the version is pinned by the image rather than by whatever a developer happens to have.
- IAsyncLifetime is xUnit's hook for setup and teardown that needs to await something. InitializeAsync runs before the tests, DisposeAsync after. The exact signatures differ between xUnit major versions, so match whatever the interface in your project declares.
- MigrateAsync applies your real migrations to the empty database. This single line is doing more work than it looks like: if a migration is broken, the fixture fails and every test in the class reports it.
- Starting the container takes a few seconds, which is why it belongs in a fixture shared by a class or a collection rather than running per test. Share the container; do not share the data — that is the next section.
- This requires a container runtime on the machine and in the pipeline. Where that is not available, the alternative is a database the pipeline provisions itself; the test code is the same, only ConnectionString changes.
public class OrderQueryTests : IClassFixture<SqlServerFixture>
{
private readonly SqlServerFixture _fixture;
public OrderQueryTests(SqlServerFixture fixture) => _fixture = fixture;
[Fact]
public async Task FindByCompany_MatchesCaseInsensitively()
{
// Arrange
await using var db = _fixture.CreateContext();
db.Customers.Add(new Customer { CompanyName = "Kirby Logistics" });
await db.SaveChangesAsync();
var repository = new CustomerRepository(db);
// Act
IReadOnlyList<Customer> matches = await repository.FindByCompanyAsync("kirby");
// Assert
Assert.Single(matches);
}
[Fact]
public async Task SaveChanges_WithDuplicateEmail_IsRejectedByTheDatabase()
{
await using var db = _fixture.CreateContext();
db.Customers.Add(new Customer { CompanyName = "Ward Supplies", Email = "ops@ward.example" });
db.Customers.Add(new Customer { CompanyName = "Ward Supplies Ltd", Email = "ops@ward.example" });
await Assert.ThrowsAsync<DbUpdateException>(() => db.SaveChangesAsync());
}
}- The first test is the one worth studying. If FindByCompanyAsync lowercases both sides in C#, it works perfectly against a list and throws when the provider tries to turn it into SQL. A fake repository reports success for code that cannot run.
- It also pins down behaviour that depends on the database rather than on your code. Whether "kirby" matches "Kirby" is decided by the column's collation, and no amount of C# reasoning will tell you what that is on the server you deploy to.
- The second test asserts that the unique index exists and is enforced. The in-memory fake from the previous module cheerfully accepts both rows, so this rule has no coverage until a real database is involved.
- DbUpdateException is the wrapper Entity Framework Core raises when the database refuses a write. Asserting on the wrapper rather than the provider-specific error inside it keeps the test readable and still proves the write was rejected.
- IClassFixture<T> tells xUnit to create the fixture once and hand it to every test in the class, so the container starts once rather than twice.
Sharing a database between tests means state leaks unless you deal with it. Four approaches, with what each costs:
- Recreate the schema for every test
- The simplest to reason about and the slowest by a wide margin. Fine for a handful of tests; unusable once there are two hundred.
- Clear the data between tests
- Delete the rows and leave the schema. The Respawn library does this in foreign-key order so you do not have to maintain a delete script: Respawner.CreateAsync inspects the schema once, then ResetAsync empties the tables. Put both on the test class, which xUnit constructs per test, so the container is still created once by the fixture. This is the usual default — a fraction of a second per test, and tests can commit real transactions.
- Wrap each test in a transaction and roll back
- Fastest of the three. The limitation is real: code under test that manages its own transaction, or that needs to see committed data from another connection, does not work inside it.
- Give every test its own data
- No cleanup at all — each test inserts rows with its own identifiers and only queries those. Cheap and allows parallel runs, but any query without a filter sees everybody's rows, and the database grows over a long run.
Summary
- An integration test asks whether the parts fit together, which is a different question from whether a rule is correct
- Wiring, migrations, query translation, constraints, serialisation and configuration are outside what unit tests can observe
- Start a real database in a container so the version is pinned and nothing is installed on the machine
- Share the container, never the data: clear rows between tests or give each test its own
- They are slower and more fragile, so keep them focused on the seams rather than duplicating unit tests
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Prove a constraint is enforced
Your schema has a unique index on order_items covering (order_id, product_id), so the same product cannot appear twice on one order.
Write an integration test that proves it. Then write the equivalent test against the in-memory fake repository from the previous module and compare the outcomes.
Show solution
The integration test adds two matching lines and asserts that SaveChangesAsync throws. That is the only place the rule lives, because the rule is in the database.
The same test against the fake passes without throwing, because a dictionary has no unique index. Two tests, same intent, opposite result — which is the clearest demonstration available of what a double cannot tell you.
It is worth deciding what should happen at the application level too. A DbUpdateException reaching a user as a 500 is a poor experience, so most teams check for the duplicate before attempting the write and keep the constraint as the guarantee of last resort. Both layers are justified: the check gives a good message, the constraint makes the rule true even when a code path forgets to check.
[Fact]
public async Task AddingTheSameProductTwiceToAnOrder_IsRejected()
{
await using var db = _fixture.CreateContext();
var order = new Order { CustomerId = 417, OrderDate = new DateOnly(2026, 3, 14) };
order.Items.Add(new OrderItem { ProductId = 88, Quantity = 2, UnitPrice = 25m });
order.Items.Add(new OrderItem { ProductId = 88, Quantity = 1, UnitPrice = 25m });
db.Orders.Add(order);
var exception = await Assert.ThrowsAsync<DbUpdateException>(() => db.SaveChangesAsync());
Assert.Contains("uq_order_items_order_product", exception.InnerException!.Message);
}Think about it
Where does this test belong?
For each of these, decide whether it belongs in the fast unit suite, the integration suite, or both, and say why:
1. A discount of 10 percent on 200.00 produces 180.00.
2. Submitting an order that is already cancelled is rejected.
3. A customer search matches regardless of letter case.
4. The application starts with every service it needs registered.
5. An order total is rounded to two decimal places when stored.
Show solution
1. Unit only. Pure arithmetic with no infrastructure. Running it against a database adds seconds and proves nothing extra.
2. Unit only. A rule about state, decided entirely in your own code.
3. Integration. Case sensitivity is decided by the column collation and by how the query is translated, neither of which exists in a unit test.
4. Integration, and it is cheap. Building the application's service provider and resolving the top-level services catches a missing or wrongly scoped registration in milliseconds. This test earns its place more than most.
5. Both, for different reasons. A unit test covers the rounding rule in your code. An integration test covers the column's precision — a decimal(10,2) column silently rounds on write, so the value you read back may not be the value your rule produced. The second failure mode is invisible without a real database.
Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.