Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

C# & .NET (C# 14, .NET 10 LTS)  Module 22 – Testing C# code

Integration tests with real dependencies

Test C# code against a real SQLite file, a real local HTTP server and PostgreSQL in Docker, with fixtures that keep the tests isolated, parallel-safe and fast.

  • Advanced
  • 30 minutes
  • Examples run with .NET 10.0.401 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 10 of 30 minutes free
Free: A real database catches what a fake hides · Each test gets its own database and folder. With a pass: 3 in-depth parts and 3 interview answers, about 20 minutes.

What you will learn

  • Test a repository against a real SQLite database file of its own for each test
  • Isolate integration tests from each other with per-test folders, class fixtures and free ports
  • Test an HTTP client against a real server started inside the test process
  • Run PostgreSQL tests with Testcontainers when Docker is there, and skip them with the reason when it is not

Before you start

What you’ll learn · Before you start

What you will learn

  • Test a repository against a real SQLite database file of its own for each test
  • Isolate integration tests from each other with per-test folders, class fixtures and free ports
  • Test an HTTP client against a real server started inside the test process
  • Run PostgreSQL tests with Testcontainers when Docker is there, and skip them with the reason when it is not

Before you start

On this page

The unit tests of a monthly expense report passed against a fake store, and three tests of the real store failed on the same code with SQLite Error 1: 'no such column: amount_paise': its query named a column that the table does not have. A fake never runs SQL, so only a test against a real database could see the mistake.

You will test a repository against a real SQLite file, give every test a database and a folder of its own, start a real HTTP server inside the test process, and run PostgreSQL tests with Testcontainers where Docker is installed, keeping such tests isolated, safe to run in parallel, and out of the way of the quick unit tests.

A real database catches what a fake hides

MonthlyReport adds up a month’s expenses by category. Its unit tests give it an InMemoryExpenseStore, a fake that filters a list; the integration tests give SqliteExpenseStore, the store the app uses, a real database file. This session swaps in a store whose query says amount_paise where the table’s column is paise, and runs both kinds:

Unit tests pass, the real database refuses the query Python · fake_session.py
from session import run

# network: true. dotnet test restores Microsoft.Data.Sqlite 10.0.12, xunit.v3 4.0.1, Testcontainers.PostgreSql 4.15.0
# and Npgsql 10.0.3 from nuget.org into the session's own, empty home folder; ASP.NET Core comes with the SDK.
# variants/typo/SqliteExpenseStore.cs, whose query names a column the table does not have, replaces the real store,
# and the report's unit tests and the store's integration tests run. The run is meant to fail; --parallel none keeps
# the order of its reports the same in every run.
run("""
cd expenses
cp ../variants/typo/SqliteExpenseStore.cs src/Expenses/SqliteExpenseStore.cs
!dotnet test --project tests/ExpensesTests --filter-class Expenses.Tests.MonthlyReportTests --filter-class Expenses.Tests.SqliteExpenseStoreTests --parallel none
""", network=True)

Output

$ cd expenses
$ cp ../variants/typo/SqliteExpenseStore.cs src/Expenses/SqliteExpenseStore.cs
$ dotnet test --project tests/ExpensesTests --filter-class Expenses.Tests.MonthlyReportTests --filter-class Expenses.Tests.SqliteExpenseStoreTests --parallel none
Running tests from expenses/tests/ExpensesTests/bin/Debug/net10.0/Expenses.Tests.dll (net10.0|arm64)
failed Expenses.Tests.SqliteExpenseStoreTests.Migrating_twice_keeps_the_data (…)
  from expenses/tests/ExpensesTests/bin/Debug/net10.0/Expenses.Tests.dll (net10.0|arm64)
  Xunit.MicrosoftTestingPlatform.XunitException: Microsoft.Data.Sqlite.SqliteException : SQLite Error 1: 'no such column: amount_paise'.
    at Microsoft.Data.Sqlite.SqliteException.ThrowExceptionForRC(Int32 rc, sqlite3 db)
    at Microsoft.Data.Sqlite.SqliteCommand.PrepareAndEnumerateStatements()+MoveNext()
    at Microsoft.Data.Sqlite.SqliteCommand.GetStatements()+MoveNext()
    at Microsoft.Data.Sqlite.SqliteDataReader.NextResult()
    at Microsoft.Data.Sqlite.SqliteCommand.ExecuteReader(CommandBehavior behavior)
    at Microsoft.Data.Sqlite.SqliteCommand.ExecuteReader()
    at Expenses.SqliteExpenseStore.InMonth(Int32 year, Int32 month) in expenses/src/Expenses/SqliteExpenseStore.cs:49
    at Expenses.Tests.SqliteExpenseStoreTests.Migrating_twice_keeps_the_data() in expenses/tests/ExpensesTests/SqliteExpenseStoreTests.cs:34
    at System.Reflection.MethodBaseInvoker.InterpretedInvoke_Method(Object obj, IntPtr* args)
    at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr)
failed Expenses.Tests.SqliteExpenseStoreTests.Reads_back_the_month_it_stored (…)
  from expenses/tests/ExpensesTests/bin/Debug/net10.0/Expenses.Tests.dll (net10.0|arm64)
  Xunit.MicrosoftTestingPlatform.XunitException: Microsoft.Data.Sqlite.SqliteException : SQLite Error 1: 'no such column: amount_paise'.
    at Microsoft.Data.Sqlite.SqliteException.ThrowExceptionForRC(Int32 rc, sqlite3 db)
    at Microsoft.Data.Sqlite.SqliteCommand.PrepareAndEnumerateStatements()+MoveNext()
    at Microsoft.Data.Sqlite.SqliteCommand.GetStatements()+MoveNext()
    at Microsoft.Data.Sqlite.SqliteDataReader.NextResult()
    at Microsoft.Data.Sqlite.SqliteCommand.ExecuteReader(CommandBehavior behavior)
    at Microsoft.Data.Sqlite.SqliteCommand.ExecuteReader()
    at Expenses.SqliteExpenseStore.InMonth(Int32 year, Int32 month) in expenses/src/Expenses/SqliteExpenseStore.cs:49
    at Expenses.Tests.SqliteExpenseStoreTests.Reads_back_the_month_it_stored() in expenses/tests/ExpensesTests/SqliteExpenseStoreTests.cs:24
    at System.Reflection.MethodBaseInvoker.InterpretedInvoke_Method(Object obj, IntPtr* args)
    at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr)
failed Expenses.Tests.SqliteExpenseStoreTests.The_report_adds_up_what_the_database_returns (…)
  from expenses/tests/ExpensesTests/bin/Debug/net10.0/Expenses.Tests.dll (net10.0|arm64)
  Xunit.MicrosoftTestingPlatform.XunitException: Microsoft.Data.Sqlite.SqliteException : SQLite Error 1: 'no such column: amount_paise'.
    at Microsoft.Data.Sqlite.SqliteException.ThrowExceptionForRC(Int32 rc, sqlite3 db)
    at Microsoft.Data.Sqlite.SqliteCommand.PrepareAndEnumerateStatements()+MoveNext()
    at Microsoft.Data.Sqlite.SqliteCommand.GetStatements()+MoveNext()
    at Microsoft.Data.Sqlite.SqliteDataReader.NextResult()
    at Microsoft.Data.Sqlite.SqliteCommand.ExecuteReader(CommandBehavior behavior)
    at Microsoft.Data.Sqlite.SqliteCommand.ExecuteReader()
    at Expenses.SqliteExpenseStore.InMonth(Int32 year, Int32 month) in expenses/src/Expenses/SqliteExpenseStore.cs:49
    at Expenses.MonthlyReport.ByCategory(IExpenseStore store, Int32 year, Int32 month) in expenses/src/Expenses/MonthlyReport.cs:7
    at Expenses.Tests.SqliteExpenseStoreTests.The_report_adds_up_what_the_database_returns() in expenses/tests/ExpensesTests/SqliteExpenseStoreTests.cs:51

Text longer than 4,000 characters is cut here; run the example yourself to see all of it.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 fake_session.py

The two unit tests passed, because the fake never sees the query. Three of the four tests of the real store failed with SQLite’s own error, and the fourth, which only inserts, passed: the bug is in reading. That is what an integration test: A test that runs code together with a real dependency, such as a database file, a web server or another service, to catch the mistakes between them that a stand-in cannot show, such as SQL the database refuses. is for. It runs code together with the real thing it depends on, so it catches what a stand-in cannot: SQL that the database refuses, a schema that differs from the code, a constraint such as CHECK (paise > 0), the conversion of a date to text and back, and the wiring of configuration.

Microsoft’s guide to unit tests asks you to keep such infrastructure out of unit tests, because it makes them slow and brittle, and suggests keeping integration tests in a project of their own. This lesson keeps both in one project, so that its examples stay small, and marks each test with a Category trait, Unit or Integration, so a command can run either kind alone. SQLite runs inside the test process and needs nothing installed; a service that runs on PostgreSQL also needs its SQL tested against PostgreSQL itself, which Testcontainers can start in Docker for a test run.

Key idea

A fake makes a unit test fast by leaving the real dependency out, so the dependency’s own mistakes, SQL above all, need an integration test that runs against the real thing.

Checkpoint

Check: What a real database catches

  1. Question 1 of 2 Why did the report's unit tests pass while the store's query named a column the table does not have?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: They used InMemoryExpenseStore, a fake that filters a list and never runs any SQL

    A fake makes a unit test fast by replacing the dependency, so bugs in the dependency's own code, SQL in particular, need integration tests against the real thing.

  2. Question 2 of 2 Which of these mistakes can only an integration test against the real database catch? Choose all that apply.

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • A CHECK constraint that refuses an amount of zero
    • A date stored as text in one format and parsed in another
    • A query that names a column the table does not have

    Integration tests catch the mistakes that live between the code and the dependency: SQL, schemas, constraints and conversions. The code's own logic is cheaper to check in unit tests.

Each test gets its own database and folder

A real dependency has state, and state shared between tests makes results depend on order. The store’s tests avoid that with TempDatabase, which gives each test a new folder under the temporary folder, with a database file in it, and deletes both when the test ends:

TempDatabase.cs C# · expenses/tests/ExpensesTests/TempDatabase.cs
using Microsoft.Data.Sqlite;

namespace Expenses.Tests;

/// <summary>A SQLite database file of its own for one test, in a folder of its own, deleted when the test ends.</summary>
sealed class TempDatabase : IDisposable
{
    private readonly string _folder = Directory.CreateTempSubdirectory("expenses-").FullName;

    public string ConnectionString => $"Data Source={Path.Combine(_folder, "expenses.db")}";

    public void Dispose()
    {
        SqliteConnection.ClearAllPools();   // the pool keeps the file open otherwise
        Directory.Delete(_folder, recursive: true);
    }
}
SqliteExpenseStoreTests.cs C# · expenses/tests/ExpensesTests/SqliteExpenseStoreTests.cs
namespace Expenses.Tests;

// Integration tests: the real SQL, against a real SQLite database file for each test.
[Trait("Category", "Integration")]
public sealed class SqliteExpenseStoreTests : IDisposable
{
    private readonly TempDatabase _database = new();
    private readonly SqliteExpenseStore _store;

    public SqliteExpenseStoreTests()
    {
        _store = new SqliteExpenseStore(_database.ConnectionString);
        _store.Migrate();
    }

    public void Dispose() => _database.Dispose();

    [Fact]
    public void Reads_back_the_month_it_stored()
    {
        _store.Add(new Expense("E1", new DateOnly(2026, 3, 2), "Food", 45_000));
        _store.Add(new Expense("E2", new DateOnly(2026, 4, 1), "Food", 9_999));

        Assert.Equal([new Expense("E1", new DateOnly(2026, 3, 2), "Food", 45_000)], _store.InMonth(2026, 3));
    }

    [Fact]
    public void Migrating_twice_keeps_the_data()
    {
        _store.Add(new Expense("E1", new DateOnly(2026, 3, 2), "Food", 45_000));

        _store.Migrate();

        Assert.Single(_store.InMonth(2026, 3));
    }

    [Fact]
    public void Refuses_an_amount_of_zero()
    {
        var error = Assert.Throws<Microsoft.Data.Sqlite.SqliteException>(
            () => _store.Add(new Expense("E1", new DateOnly(2026, 3, 2), "Food", 0)));
        Assert.Equal(19, error.SqliteErrorCode);   // SQLITE_CONSTRAINT
    }

    [Fact]
    public void The_report_adds_up_what_the_database_returns()
    {
        _store.Add(new Expense("E1", new DateOnly(2026, 3, 2), "Food", 45_000));
        _store.Add(new Expense("E2", new DateOnly(2026, 3, 9), "Travel", 1_20_000));

        Assert.Equal([("Travel", 1_20_000L), ("Food", 45_000L)], MonthlyReport.ByCategory(_store, 2026, 3));
    }
}

SqliteConnection.ClearAllPools() comes first because Microsoft.Data.Sqlite pools connections, and a pooled connection keeps the file open after the test has closed its own. An in-memory database is the other way to give each test its own: Microsoft’s documentation explains that Data Source=:memory: gives every connection a private database, which disappears when that connection closes. The sync client’s tests use one more real dependency, a web server started inside the test process, and share it between the tests of their class. Follow the life of the databases and of that server:

Interactive · message by message A database for every test, a server for every class Step through two tests of the store and the tests of the sync client: what is created, when, and when it goes away.
A database for every test, a server for every class, step 8 of 8. Messages so far: xUnit to Test objects: new test object; Test objects to Real dependencies: new folder, migrate; Test objects to Real dependencies: real SQL; xUnit to Test objects: Dispose; Test objects to Real dependencies: another new folder; xUnit to Real dependencies: start the server; Test objects to Real dependencies: HTTP requests; xUnit to Real dependencies: stop the server.xUnitthe test runnerTest objectsone per testReal dependenciesfiles and a servernew test objectnew folder, migratereal SQLDisposeanother new folderstart the serverHTTP requestsstop the server

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. A new object. Before each test of SqliteExpenseStoreTests, xUnit creates a new object of the class, so the constructor runs once for every test.
  2. A database of its own. The constructor creates a folder of its own under the temporary folder, with expenses.db in it, and migrates that file. No other test can see it.
  3. The test itself. The test runs its real SQL against the file: inserts, a query for one month, a constraint that refuses an amount of zero.
  4. Cleaning up. xUnit calls Dispose when the test ends, pass or fail. It clears the connection pool, which would keep the file open, and deletes the folder.
  5. The next test. The next test gets a new object and a new folder again, so the order of the tests can never change their results.
  6. A server per class. SyncClientTests asks for a class fixture. xUnit creates SyncServerFixture once, before the class's first test, and the fixture starts a real web server inside the test process.
  7. Shared by the class. Every test of the class gets the same fixture through its constructor and talks to the same server, so each test uses ids of its own.
  8. After the last test. When the class's last test has run, xUnit disposes the fixture once, and the server stops.

xUnit offers three ways to share something expensive instead:

  • A class fixture: In xUnit, an object created once before the first test of a test class, handed to the class's constructor in every test and disposed after the last one, for setup too expensive to repeat for each test. is created once for the tests of one class and handed to its constructor.
  • A collection fixture is shared by every class in one named test collection.
  • An assembly fixture, new in xUnit v3, is shared by every test class of the test project.

Sharing has a price. xUnit runs test classes in parallel by default, so anything two classes share must be safe to use at the same time, and anything a fixture keeps between tests can leak from one test into the next.

Checkpoint

Check: A database and a folder for each test, and fixtures

  1. Question 1 of 2 SqliteExpenseStoreTests creates its TempDatabase in a field, and xUnit creates a new object of the class for every test. How many database folders do its 4 tests create in one run?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 4, one for each test, each deleted when its test ends

    Per-test setup in the constructor and cleanup in Dispose give each test a database of its own, so no test can see another's rows.

  2. Question 2 of 2 Why does TempDatabase call SqliteConnection.ClearAllPools() before it deletes its folder?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Pooled connections keep the database file open after the test has closed its own connection

    A pooled connection survives the test's own Close. Clear the pools, then delete the file and its folder.

Shared state, reproduced: two tests that pass alone and fail together Pro: opens with a Pro, Premium or Ultimate pass

A test that passes alone and fails in the suite is the most common sign of shared state. This part reproduces one with two tests that share a database file, then shows how fixtures share expensive things on purpose without sharing state between tests.

In this part:

  • Two tests that pass alone and fail together
  • Fixtures that start expensive things once

2 runnable examples (C#, Python) · about 4 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

A real HTTP server in the test process, on a free port Pro: opens with a Pro, Premium or Ultimate pass

A stub HttpMessageHandler checks what a client sends; a real server also checks that the request survives HTTP itself. This part starts ASP.NET Core’s Kestrel inside the test process and tests the sync client against it.

In this part:

  • Starting Kestrel on 127.0.0.1, port 0
  • The client against the server

2 runnable examples (C#, Python) · about 3 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

PostgreSQL in Docker with Testcontainers, and keeping the suite fast Pro: opens with a Pro, Premium or Ultimate pass

A PostgreSQL test needs PostgreSQL itself, and a container costs seconds to start. This part writes that test with Testcontainers, runs it on a computer without Docker, and splits the suite so the slow tests never slow down the quick ones.

In this part:

  • A PostgreSQL test that skips itself without Docker
  • Running the quick tests alone

3 runnable examples (PostgresFixture.cs, PostgresTests.cs, Python) · about 4 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Practise

These questions cover the first two sections: what a real database catches, and how each test gets a database of its own.

Practice that adapts to you

Practise this lesson

6 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 6 What makes a test an integration test?

    Choose one answer.

    Show the answer: Question 1 of 6

    Answer: It runs the code together with a real dependency, such as a database file or a server

    An integration test exercises the code with the real thing it depends on, so mistakes between them show up.

  2. Question 2 of 6 The project marks tests with [Trait("Category", "Unit")] or "Integration". What does that make possible?

    Choose one answer.

    Show the answer: Question 2 of 6

    Answer: Running the quick unit tests alone, with a trait filter, and everything on the build server

    Traits name kinds of test, and the test runner's trait filters run one kind or leave one out.

  3. Question 3 of 6 A team's 300 unit tests all use fakes, and production failed on a schema change. Where do new tests do the most good?

    Choose one answer.

    Show the answer: Question 3 of 6

    Answer: A few integration tests per store, against a real database migrated from scratch

    Put integration tests where the code meets the dependency: each store, with its real SQL, against a database built by the real migrations.

  4. Question 4 of 6 Where does TempDatabase put its database file?

    Choose one answer.

    Show the answer: Question 4 of 6

    Answer: In a new folder of its own under the temporary folder, made for one test

    A fresh folder per test keeps databases, and any other files a test writes, out of each other's way.

  5. Question 5 of 6 Which xUnit fixture is created once and shared by every test class of a test project?

    Choose one answer.

    Show the answer: Question 5 of 6

    Answer: An assembly fixture

    Class fixtures serve one class, collection fixtures one collection, and assembly fixtures the whole project.

  6. Question 6 of 6 Two test classes use the same fixed database file path. Why can they fail even when each class's tests are right?

    Choose one answer.

    Show the answer: Question 6 of 6

    Answer: xUnit runs test classes in parallel by default, so both classes use the file at the same time

    Give each test its own file, or put the classes in one collection so they run one at a time.

Interview questions

  1. Mid to senior

    A service's 400 unit tests all pass with an in-memory fake of its repository, and its first deploy fails with a SQL error on a column name. How would you restructure the test suite so this cannot happen again, without making it slow?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    Your integration tests pass when run one at a time and fail about one run in five when the whole suite runs on the build server. How do you find the cause, and what do you change?

    Model answer, Pro. First step and follow-ups

  3. Senior to staff

    Your service runs on PostgreSQL in production. When do you test its data access against SQLite, when against PostgreSQL started by Testcontainers, and how do you keep the suite fast and the build servers simple?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • An integration test runs code with the real dependency, so it catches what a fake cannot: SQL, schemas, constraints and conversions.
  • Give each integration test its own database file and folder, and clear the connection pool before deleting it.
  • Share expensive objects through class, collection or assembly fixtures, never the state that tests change.

Terms in this lesson

class fixture
In xUnit, an object created once before the first test of a test class, handed to the class's constructor in every test and disposed after the last one, for setup too expensive to repeat for each test.
flaky test
A test that sometimes passes and sometimes fails on the same code, usually because of state it shares with other tests, a race, or an assumption about timing.
integration test
A test that runs code together with a real dependency, such as a database file, a web server or another service, to catch the mistakes between them that a stand-in cannot show, such as SQL the database refuses.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.