Integration Testing
I’ve spent a lot of time testing APIs the traditional way. Make a change. Run the application. Open Swagger. Try an endpoint. Maybe open Postman. Create some data. Try another endpoint. Check the response. Change something else. Repeat. And then, eventually, deploy.
The problem isn’t that Swagger or Postman are bad tools. They are excellent tools for manually exploring and debugging APIs. The problem is what happens when an existing codebase becomes large. Every time I make a change to an existing API, I have to ask myself:
“What did I just break?”
A seemingly harmless change to one endpoint can affect routing, model binding, dependency injection, database persistence, serialization, authentication, authorization, middleware, or another endpoint that depends on the same behavior. And manually checking all of that every time is both expensive and unreliable.
Humans are good at many things. Repeating the same 30 API checks after every code change isn’t one of them. This is where integration testing becomes extremely valuable. Instead of me repeatedly testing on Postman:
“Full lifecycle per API endpoint including success and error cases”
I can tell a test suite once:
“This is how the API is supposed to behave. Check it every time.”
And then:
dotnet testThe computer does the boring part. That’s the real motivation behind this tutorial. I want to understand integration testing well enough that I don’t just know how to write:
[Fact]
public async Task Something_Works()
{
...
}I want to understand why the test is written that way, what exactly is being tested, what isn’t being tested, how the test infrastructure works, how databases fit into integration tests, how to avoid flaky tests, and eventually how the same ideas apply to real-world systems.
This tutorial is therefore deliberately bigger than “how to test a Todo API.” The Todo API is simply our laboratory.
Table of Contents
- What Is Integration Testing?
- Why Integration Testing Matters
- Unit vs Integration vs End-to-End Testing
- Our Todo API
- The Repository
- Testing Frameworks: xUnit, NUnit, MSTest
- Why I’m Using xUnit
- Assertions and Fluent Assertions
- Our First Integration Test
- Fact, Theory and Test Attributes
- Arrange, Act, Assert
- WebApplicationFactory
- HttpClient
- Exposing Program for Integration Tests
- Building a Custom Test Factory
- Testing Create
- Testing Get
- Testing Update
- Testing Delete
- Testing the Result, Not Just the Status Code
- Test Isolation
- The Shared Database Problem
- Why InMemory Is Useful — and Its Limitations
- Good Integration Test Design
- Testing Error Scenarios
- Testing Authentication and Authorization
- Testing External Services
- Integration Testing and the Real Database
- Testcontainers
- Integration Tests in CI/CD
- Common Integration Testing Mistakes
- From Beginner to Mastery
- Final Thoughts
1. What Is Integration Testing?
Let’s start with the simplest definition:
Integration testing verifies that multiple parts of a system work correctly together.
The important word is together. Suppose we have this API:
HTTP Request
↓
ASP.NET Core
↓
Controller
↓
Entity Framework Core
↓
Database
↓
HTTP ResponseA unit test might test the controller or service independently. An integration test asks:
“When I make a real API request, do all these pieces cooperate correctly?”
For example:
POST /api/todoswith:
{
"title": "Learn integration testing",
"isCompleted": false
}We can verify that:
HTTP request
↓
Routing works
↓
Model binding works
↓
Controller works
↓
EF Core works
↓
Database persistence works
↓
Response serialization works
↓
HTTP response is correctThat’s much closer to how a real consumer interacts with our application.
Microsoft’s ASP.NET Core documentation uses WebApplicationFactory<TEntryPoint> specifically for bootstrapping the application under test and creating an HttpClient for integration tests.
2. Why Integration Testing Matters
Imagine that you have an API with 50 endpoints. You make a change to your authentication middleware. You now need to know:
- Does login still work?
- Can authenticated users access protected endpoints?
- Are unauthenticated requests rejected?
- Are roles still respected?
- Do existing endpoints still return the correct status codes?
- Did you accidentally break dependency injection?
- Does the database interaction still work?
You could manually test all of these. But now imagine doing that after every significant change. And imagine doing it six months from now when the application has 200 endpoints.
This is where automated integration testing starts paying for itself.
The human problem
Manual testing is often:
Change code
↓
Run application
↓
Open Swagger/Postman
↓
Test endpoint
↓
Check result
↓
Remember another endpoint
↓
Test another endpoint
↓
Deploy
↓
Something breaks
↓
"How did we miss that?"Automated integration testing changes that to:
Change code
↓
dotnet test
↓
Tests execute
↓
PASS / FAILThat’s not about replacing developers. It’s about freeing developers from repetitive verification.
3. Unit vs Integration vs End-to-End Testing
These three terms are often mixed together. Let’s separate them.
Unit testing
A unit test tests a small piece of functionality in isolation. For example:
var calculator = new Calculator();
var result = calculator.Add(2, 3);
Assert.Equal(5, result);Conceptually:
Calculator
↓
TestFast. Focused. Isolated.
Integration testing
Now imagine:
HTTP
↓
Controller
↓
Service
↓
EF Core
↓
DatabaseThe integration test verifies that these components cooperate.
Example:
var response =
await client.PostAsJsonAsync(
"/api/todos",
request);We’re not calling the controller directly. We’re going through the application.
End-to-end testing
An end-to-end test goes even further:
Browser
↓
Frontend
↓
API
↓
Database
↓
External systemsFor example:
“A real user logs in, navigates to the Todo page, creates a Todo, and sees it appear on the screen.”
That’s E2E testing.
The pyramid
A useful mental model is:
/ \
/ \
/ E2E \
/ ------ \
/ \
/Integration \
/--------------\
/ \
/ Unit Tests \
/ ------------------ \Generally:
- Unit tests are numerous and fast.
- Integration tests are fewer and broader.
- E2E tests are fewer still and more expensive.
You don’t have to choose only one. A mature system normally uses all three.
4. Our Todo API
For this tutorial, we’ll use a deliberately simple Todo API. Our API supports:
GET /api/todos
GET /api/todos/{id}
POST /api/todos
PUT /api/todos/{id}
DELETE /api/todos/{id}Our Todo model is approximately:
public class Todo
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public bool IsCompleted { get; set; }
}The application uses ASP.NET Core and EF Core’s InMemory provider for the learning example. The repository’s Program.cs registers the Todo database using UseInMemoryDatabase("TodoDb").
The important thing, however, isn’t the Todo application. It’s what happens around it.
5. The Repository
I’ve made the complete example available here: Integration Testing repository
The repository contains the final application and integration-test project. If you want to follow along from the beginning, start with the version of the Todo API before integration testing was introduced: Todo API before integration testing
The final repository currently contains:
TodoApi/
TodoApi.IntegrationTests/
TodoApi.slnThe test project targets .NET 9 and references Microsoft.AspNetCore.Mvc.Testing, xUnit, the xUnit Visual Studio runner, and the application project.
You are also completely free to use the repository as a starting point, experiment with it, break things, fix them, and adapt the ideas to your own projects. In fact, breaking the tests is encouraged. That’s how you learn what they’re actually protecting.
6. Testing Frameworks: xUnit, NUnit, MSTest
Before writing tests, there’s an important question:
“What framework should I use?”
In .NET, some of the common choices are:
- xUnit
- NUnit
- MSTest
All three can be used to build serious test suites. The testing framework isn’t the same thing as integration testing. Think of it this way:
Integration Testing
↑
What we are doing
xUnit / NUnit / MSTest
↑
Tools we use to write/run testsNUnit
NUnit is one of the long-standing .NET testing frameworks. A simple NUnit test looks like:
[Test]
public void Add_TwoNumbers_ReturnsFive()
{
var result = 2 + 3;
Assert.That(result, Is.EqualTo(5));
}NUnit uses attributes such as [Test] to identify test methods and has a rich assertion/constraint model. NUnit is absolutely capable of integration testing.
MSTest
MSTest is Microsoft’s testing framework. A test looks like:
[TestClass]
public class TodoTests
{
[TestMethod]
public void CreateTodo_Works()
{
...
}
}MSTest is fully supported, open-source, and cross-platform. It integrates well with the .NET ecosystem and Visual Studio.
xUnit
Our tutorial uses xUnit. A test looks like:
[Fact]
public async Task GetTodos_ReturnsSuccess()
{
...
}And assertions look like:
Assert.Equal(
HttpStatusCode.OK,
response.StatusCode);Microsoft’s own .NET testing documentation has extensive xUnit examples, and ASP.NET Core’s integration-testing documentation uses xUnit examples for WebApplicationFactory.
7. Why I’m Using xUnit
This doesn’t mean:
“xUnit is objectively the best testing framework.”
That’s not a useful conclusion. NUnit, MSTest, and xUnit are all capable frameworks. I’m using xUnit here because:
- It has excellent .NET ecosystem support.
- It works naturally with ASP.NET Core integration testing.
WebApplicationFactoryexamples commonly use xUnit.- Its fixture model is useful for integration-test infrastructure.
[Fact]and[Theory]provide a simple way to express test scenarios.- It is widely used in modern .NET projects.
Most importantly, the integration-testing concepts in this article aren’t xUnit-specific. If you prefer NUnit, the concepts remain:
Arrange
Act
AssertHTTP request
Application
Database
Response
AssertionsOnly the testing syntax changes.
8. Assertions and Fluent Assertions
An assertion answers one question:
“Did the actual result match what I expected?”
With xUnit:
Assert.Equal(
HttpStatusCode.OK,
response.StatusCode);We have:
Expected → 200 OK
Actual → response.StatusCodeIf they match:
PASSOtherwise:
FAILFluent Assertions
You will also encounter a popular .NET library called Fluent Assertions. Instead of:
Assert.Equal(
"Learn testing",
todo.Title);you can write:
todo.Title.Should()
.Be("Learn testing");Or:
todo.Should().NotBeNull();Or:
todos.Should().HaveCount(2);Fluent Assertions is designed to make test expectations read more naturally and provides a large collection of assertion helpers. For example:
todos.Should()
.Contain(todo => todo.Title == "Learn testing");This can be very expressive.
Should we use Fluent Assertions?
Absolutely possible. But we’re deliberately going to use xUnit’s built-in assertions first.
Why? Because I want you to understand:
Assert.Equal(...)
Assert.NotNull(...)
Assert.True(...)
Assert.Contains(...)before adding another abstraction.
Once you understand assertions, Fluent Assertions becomes an improvement in readability rather than something mysterious. Also, Fluent Assertions works with multiple test frameworks, including xUnit and NUnit.
9. Our First Integration Test
Let’s write our first test.
Create:
TodoApi.IntegrationTests/
Tests/
TodoGetTests.csThe basic idea is:
public class TodoGetTests
{
private readonly HttpClient _client;
public TodoGetTests(TodoApiFactory factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task GetTodos_ReturnsSuccess()
{
var response =
await _client.GetAsync("/api/todos");
Assert.Equal(
HttpStatusCode.OK,
response.StatusCode);
}
}Look carefully at what we’re doing. We’re not doing:
var controller = new TodosController(...);We’re doing:
_client.GetAsync("/api/todos");That’s a huge distinction. We’re behaving like an API consumer.
10. Fact, Theory and Test Attributes
When you see:
[Fact]don’t think:
“That’s some magic xUnit decoration.”
Think:
“This method represents one test scenario.”
For example:
[Fact]
public async Task GetTodo_WithExistingId_ReturnsTodo()
{
...
}The [Fact] tells xUnit:
“This method is a test.”
What does “Fact” mean?
A fact is something that should always be true. For example:
GET existing Todo → 200 OKThat’s a concrete expected behavior.
Theory
A theory is useful when the same test logic should be executed with different data. Conceptually:
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
public void TodoId_IsValid(int id)
{
...
}Instead of writing:
Test ID 1
Test ID 2
Test ID 3you have one test definition with multiple inputs. This becomes very useful for validation and edge cases.
11. Arrange, Act, Assert
You will see these three words everywhere in testing.
Arrange
Act
AssertAt first they look like testing jargon. They’re actually extremely simple.
Arrange
Set up what you need.
var request = new
{
title = "Learn integration testing",
isCompleted = false
};You’re saying:
“Here’s the situation I want to test.”
Act
Perform the operation.
var response =
await _client.PostAsJsonAsync(
"/api/todos",
request);You’re saying:
“Now do the thing.”
Assert
Verify the result.
Assert.Equal(
HttpStatusCode.Created,
response.StatusCode);You’re saying:
“Did what I expected actually happen?”
The whole story
[Fact]
public async Task CreateTodo_WithValidRequest_ReturnsCreated()
{
// Arrange
var request = new
{
title = "Learn integration testing",
isCompleted = false
};
// Act
var response =
await _client.PostAsJsonAsync(
"/api/todos",
request);
// Assert
Assert.Equal(
HttpStatusCode.Created,
response.StatusCode);
}That’s Arrange → Act → Assert.
12. WebApplicationFactory
Now we get to one of the most important pieces of ASP.NET Core integration testing:
WebApplicationFactory<Program>What does it do? It creates a test environment for your ASP.NET Core application. Instead of you manually doing:
dotnet runand then pointing Postman at:
https://localhost:5001the test infrastructure can bootstrap the application and provide an HttpClient.
Conceptually:
Test
│
▼
WebApplicationFactory
│
▼
ASP.NET Core application
│
├── Middleware
├── Routing
├── Controllers
├── Dependency Injection
└── DatabaseThen:
var client = factory.CreateClient();gives us an HTTP client. Microsoft documents WebApplicationFactory<TEntryPoint> as the mechanism for creating a TestServer for ASP.NET Core integration tests.
13. HttpClient
Once we have:
HttpClientwe can interact with our API like a normal HTTP consumer.
GET: await _client.GetAsync("/api/todos");
POST: await _client.PostAsJsonAsync(
"/api/todos",
request);
PUT: await _client.PutAsJsonAsync(
"/api/todos/1",
request)
DELETE: await _client.DeleteAsync(
"/api/todos/1");This is one of the reasons API integration testing is so powerful. The test is close to how the real world interacts with your application.
14. Exposing Program for Integration Tests
This line in the repository deserves special attention:
public partial class Program { }It appears at the bottom of Program.cs:
app.Run();public partial class Program { }At first glance, this looks completely random. Why do we need it? Modern ASP.NET Core applications use top-level statements. So your Program.cs might look like:
var builder = WebApplication.CreateBuilder(args);
...
var app = builder.Build();
...
app.Run();There isn’t an ordinary:
public class Program
{
public static void Main(...)
}that you can easily reference from the integration-test project. But WebApplicationFactory needs an entry point type. So we expose one:
public partial class Program { }Now our test project can write:
WebApplicationFactory<Program>Microsoft explicitly documents this approach for exposing the implicitly generated Program class to integration tests.
Why partial?
Because the compiler already generates the Program class for the top-level statements. We’re essentially saying:
public partial class Program { }“The Program class exists here too; consider this another part of it.”
We’re not replacing the application entry point. We’re exposing the type so the test infrastructure can reference it.
15. Building a Custom Test Factory
Our repository has:
TodoApi.IntegrationTests/
Fixtures/
TodoApiFactory.csThe factory inherits from:
WebApplicationFactory<Program>Why create our own factory? Because eventually we need to customize the application for testing. For example:
Production application
↓
Real SQL Server
Real authentication
Real external services
VS
Test application
↓
Test database
Fake authentication
Mock external services
Test configurationOur factory is the bridge between those two worlds.
16. Replacing the Database
Our application normally registers:
builder.Services.AddDbContext<TodoDbContext>(
options =>
options.UseInMemoryDatabase("TodoDb"));Our test factory replaces that registration with a test database. The repository generates a unique database name:
private readonly string _databaseName =
$"TodoTestDb_{Guid.NewGuid()}";and then registers:
services.AddDbContext<TodoDbContext>(
options =>
options.UseInMemoryDatabase(_databaseName));This teaches us a powerful concept:
The application under test doesn’t have to use exactly the same dependencies it uses in production.
We can modify the application’s dependency injection container for tests. This is one of the most important skills you’ll develop as your integration tests become more sophisticated.
17. Testing Create
Our Create tests live in:
TodoCreateTests.csThe endpoint is:
POST /api/todosOne of the tests is essentially:
var request = new
{
title = "Learn integration testing",
isCompleted = false
};
var response =
await _client.PostAsJsonAsync(
"/api/todos",
request);
Assert.Equal(
HttpStatusCode.Created,
response.StatusCode);That’s the simplest form. But we can test more.
Test the response body
var createdTodo =
await response.Content
.ReadFromJsonAsync<Todo>();
Assert.NotNull(createdTodo);
Assert.True(createdTodo.Id > 0);
Assert.Equal(
"Learn ASP.NET Core",
createdTodo.Title);
Assert.False(
createdTodo.IsCompleted);Now we’re testing:
HTTP status
+
JSON response
+
generated ID
+
propertiesThat’s much more useful.
18. Testing the Location Header
Our API returns:
201 Created
Location: /api/Todos/9We can test that.
Assert.NotNull(
response.Headers.Location);
Assert.EndsWith(
$"/api/Todos/{createdTodo.Id}",
response.Headers.Location.ToString());The repository contains this test. Notice the lesson here: Don’t test implementation details unnecessarily. We care that:
Location → newly created TodoWe don’t particularly care whether some internal method generated that URL.
19. Testing Persistence
This is one of my favorite integration-test patterns. Create something:
POST /api/todosThen retrieve it:
GET /api/todos/{id}For example:
var createResponse =
await _client.PostAsJsonAsync(
"/api/todos",
request);
var createdTodo =
await createResponse.Content
.ReadFromJsonAsync<Todo>();
var getResponse =
await _client.GetAsync(
$"/api/todos/{createdTodo!.Id}");Now assert the retrieved Todo. Why is this useful? Because we aren’t merely checking:
POST → 201We’re checking:
POST
↓
Controller
↓
EF Core
↓
Database
↓
GET
↓
EF Core
↓
Database
↓
ResponseWe’re verifying the actual behavior. The repository uses this pattern in CreateTodo_WithValidRequest_PersistsTodo.
20. Testing Get
Our GET tests cover:
GET /api/todosand:
GET /api/todos/{id}A healthy GET test should answer questions like:
- Does the endpoint return 200?
- Is the response valid JSON?
- Does an existing Todo come back?
- Does the returned Todo have the correct data?
- What happens when the Todo doesn’t exist?
For example:
var response =
await _client.GetAsync(
$"/api/todos/{createdTodo.Id}");
Assert.Equal(
HttpStatusCode.OK,
response.StatusCode);Then:
var todo =
await response.Content
.ReadFromJsonAsync<Todo>();
Assert.NotNull(todo);
Assert.Equal(
createdTodo.Id,
todo.Id);21. Testing 404s
Integration tests shouldn’t only test happy paths. For example:
const int nonExistingId = 999999;
var response =
await _client.GetAsync(
$"/api/todos/{nonExistingId}");
Assert.Equal(
HttpStatusCode.NotFound,
response.StatusCode);This is extremely important. A real consumer of the API doesn’t care that our controller contains:
if (todo == null)
{
return NotFound();
}The consumer cares about: 404 Not Found. Integration testing validates the behavior at the boundary.
22. Testing Update
Our update endpoint is: PUT /api/todos/{id}. The test sequence is:
Create Todo
↓
Remember ID
↓
PUT updated data
↓
Expect 204
↓
GET Todo
↓
Verify changed dataThis is much stronger than simply asserting:
Assert.Equal(
HttpStatusCode.NoContent,
response.StatusCode);Why? Because an API could theoretically return: 204 without correctly persisting the change. So we verify:
PUT
↓
204
↓
GET
↓
Updated dataThe repository uses this approach in its update tests.
23. Testing Delete
Delete testing gives us another excellent example. We create: Todo #1
Then: DELETE /api/todos/1
Expect: 204 No Content
But we’re not finished. We then ask: GET /api/todos/1
and expect: 404 Not Found
Get G’s stories in your inbox
Join Medium for free to get updates from this writer.
The test is effectively:
CREATE
↓
DELETE
↓
GET
↓
404This proves the delete actually had the intended effect. The repository includes this pattern in DeleteTodo_RemovesTodo and DeleteTodo_CannotBeRetrievedAfterDeletion.
24. Testing the Result, Not Just the Status Code
This is one of the biggest lessons in integration testing. Don’t write only:
Assert.Equal(200, response.StatusCode);when you can test the behavior. For example:
POST->201->Todo returned->Todo has ID->Todo persisted
OR
PUT->204->GET->Data changed
OR
DELETE->204->GET->404Your integration tests should tell a story about behavior.
25. Test Isolation
Now we arrive at one of the most important lessons in the repository. The test database. Our factory uses a unique database name:
$"TodoTestDb_{Guid.NewGuid()}"This is already an improvement over having every test use:
TodoDbBut there is another subtle issue. A factory can be shared across tests in a class through:
IClassFixture<TodoApiFactory>The repository does this:
public class TodoGetTests
: IClassFixture<TodoApiFactory>This means tests need to be conscious of shared state.
26. The Shared Database Problem
Consider:
Test A
Create TodoThen:
Test B
Create TodoThen:
Test C
Create TodoIf they all use the same database:
Test A → 1 Todo
Test B → 2 Todos
Test C → 3 TodosNow imagine Test C says:
Assert.Equal(2, todos.Count);It may fail. And this is exactly the sort of issue you will encounter in real integration-test suites. The repository contains a commented assertion in TodoGetTests explaining this problem:
// Assert.Equal(2, todos.Count);because other tests may have created Todos in the same test run. That’s not something to sweep under the rug. It’s a lesson.
27. What Is Test Isolation?
A good test should ideally be independent. You should be able to run:
dotnet testor:
dotnet test --filter "GetTodos_ReturnsCorrectTodoCount"and get the same meaningful result. You don’t want:
Test B only passes if Test A ran first.That’s a dangerous test. A good mental model is:
Test
↓
Known state
↓
Perform action
↓
Assert
↓
Clean stateNot:
Test
↓
Hope another test didn't change the database
↓
Assert28. Why InMemory Is Useful — and Its Limitations
The repository uses:
UseInMemoryDatabase(...)This is excellent for learning. It’s easy. It’s fast. It requires no SQL Server installation. It makes our example approachable. But there’s an important warning:
EF Core’s InMemory provider is not a relational database.
It doesn’t reproduce every behavior of SQL Server or PostgreSQL. For example, database-specific behavior, relational constraints, transactions, SQL translation, indexes, and other provider-specific characteristics may behave differently.
So:
InMemory databaseis useful for:
Learning
Simple integration tests
Fast test scenariosBut for serious systems, you should consider testing against a real database engine.
29. Real Database Integration Testing
Imagine your production application uses SQL Server. A high-confidence integration test might use:
Integration Test
↓
SQL Server container
↓
Real SQL Server engineNow you are testing much closer to reality. This becomes particularly important when your application depends heavily on:
- EF Core queries
- Transactions
- Constraints
- Stored procedures
- SQL-specific behavior
- Database indexes
- Database migrations
- concurrency
- relational relationships
30. Testcontainers
This is where tools such as Testcontainers become interesting. The idea is:
Test starts
↓
Start Docker container
↓
SQL Server/PostgreSQL/etc.
↓
Run migrations
↓
Run integration tests
↓
Destroy containerYour test environment becomes reproducible. Instead of telling every developer:
“Install SQL Server, create a database called IntegrationTests, run these scripts…”
the test infrastructure can create what it needs. This is a major step toward production-quality integration testing.
31. Testing Validation
Don’t only test successful requests. For example, suppose our API eventually requires:
Title cannot be empty.We should test:
{
"title": ""
}and expect:
400 Bad RequestA good integration test might be:
[Fact]
public async Task CreateTodo_WithEmptyTitle_ReturnsBadRequest()
{
// Arrange
var request = new
{
title = "",
isCompleted = false
};
// Act
var response =
await _client.PostAsJsonAsync(
"/api/todos",
request);
// Assert
Assert.Equal(
HttpStatusCode.BadRequest,
response.StatusCode);
}Now we’re testing the validation as exposed by the API.
32. Testing Authentication and Authorization
This is where integration testing becomes extremely valuable in real applications. Suppose:
GET /api/todosrequires authentication. You should test:
No token
↓
401 UnauthorizedThen:
Valid token
↓
200 OKAnd perhaps:
Authenticated but insufficient permission
↓
403 ForbiddenThis is much harder to validate with isolated unit tests because authentication involves multiple parts of the ASP.NET Core pipeline. Integration testing lets us exercise those pieces together.
33. Testing External Services
Real applications rarely live alone. Imagine:
Your API->Payment Provider->Bank
OR
Your API->SMS Provider
OR
Your API->Third-party APIYou generally don’t want your integration tests making real payments or sending real SMS messages. Instead:
Production
↓
Real provider
Tests
↓
Fake/mock providerThis is another place where WebApplicationFactory becomes powerful. You can override services in:
ConfigureWebHost(...)and replace the real implementation with a test implementation.
34. The Test Boundary
This gives us an important question:
“What should an integration test include?”
There’s no universal answer. It depends on what you’re trying to prove. For an API, a useful boundary is often:
HTTP
↓
Application
↓
Databasewhile replacing truly external systems:
HTTP
↓
Application
↓
Database
↓
Fake external providerThis allows us to test our application thoroughly without making tests dependent on external systems we don’t control.
35. Good Integration Test Names
Look at names such as:
CreateTodo_WithValidRequest_ReturnsCreatedThey may look long. That’s intentional. A test name should communicate:
What am I doing?
What condition?
What should happen?For example:
CreateTodo_WithValidRequest_ReturnsCreatedmeans:
Action:
CreateTodo
Condition:
WithValidRequest
Expected result:
ReturnsCreatedAnother:
GetTodo_WithNonExistingId_ReturnsNotFoundreads almost like English. That’s good. Your test names are documentation.
36. Tests Should Tell Stories
Compare these:
[Fact]
public async Task Test1()and:
[Fact]
public async Task DeleteTodo_Twice_ReturnsNotFoundOnSecondAttempt()Which one tells you more when CI fails at 2:00 AM? The second. Good tests should make the failure understandable before you open the code.
37. Avoid Testing Implementation Details
Suppose the controller currently calls:
_todoService.CreateTodo(...)Don’t make your integration test care about that. Your test should care about:
POST /api/todos
↓
Expected API behaviorTomorrow you might change:
TodoServiceto:
TodoCommandHandlerThe API behavior remains the same. A good integration test should continue passing. That’s one reason integration tests should focus on observable behavior.
38. Don’t Make Tests Depend on Test Order
This is a common mistake. Bad:
Test 1 creates user
Test 2 expects user
Test 3 modifies user
Test 4 deletes userNow:
Test 4depends on:
Test 1
Test 2
Test 3Instead:
Test 1
└── Creates what it needs
Test 2
└── Creates what it needs
Test 3
└── Creates what it needs
Test 4
└── Creates what it needsTests should be independently understandable.
39. Don’t Test Everything Through the API
Integration testing is not a replacement for unit testing. Suppose you have:
CalculateDiscount()with 30 different business-rule combinations. You probably don’t want to send 30 HTTP requests through your entire application just to test arithmetic. Unit tests are better for that. Integration tests should verify important boundaries and collaborations.
For example:
Controller
↓
Service
↓
Databaseor:
API
↓
Authentication
↓
DatabaseUse the right tool for the problem.
40. A Useful Test Strategy
For an API, I like thinking in terms of:
Happy path
Valid input->Expected successValidation
Invalid input->400Not found
Unknown resource->404Authentication
No credentials->401Authorization
Insufficient permission->403Persistence
Write->Read->VerifySide effects
API operation->Expected event/message/external interactionFailure handling
Dependency failure->Expected application behaviorThis is much more useful than simply thinking:
“I need to test every method.”
41. Integration Testing Is About Confidence
Imagine changing this:
TransactionControllerin a financial API. A unit test might tell you:
Controller method works.An integration test can tell you:
HTTP routing works
Authentication works
Model binding works
Controller works
Service works
Database works
Response serialization worksThat gives you a different level of confidence. The goal isn’t:
“Have 1,000 tests.”
The goal is:
“Have automated tests that give me confidence that important behavior still works.”
42. Running the Tests
From the solution directory:
dotnet testYou can run a specific project:
dotnet test TodoApi.IntegrationTestsYou can filter tests:
dotnet test --filter "FullyQualifiedName~TodoCreateTests"Or:
dotnet test --filter "FullyQualifiedName~DeleteTodo"This becomes particularly useful as your test suite grows.
43. Integration Tests in CI/CD
This is where automated testing becomes really powerful. Imagine your GitHub workflow:
Developer
↓
Push code
↓
Pull Request
↓
Build
↓
Unit Tests
↓
Integration Tests
↓
PASS
↓
MergeNow every change gets tested automatically. You don’t have to remember:
“Did I test that endpoint?”
The pipeline does it. And if an integration test fails:
Pull Request
↓
Integration Test ❌
↓
Merge blockedThat’s much cheaper than discovering the same problem after deployment.
44. Integration Testing and Production Confidence
There is an important psychological benefit too. Without automated integration tests:
"I think this change is okay."With good integration tests:
"The tests covering this behavior are passing."Neither gives absolute certainty. No test suite can prove software contains zero bugs. But automated tests move you from:
Hopetoward:
EvidenceAnd that’s what we’re really buying with tests.
45. A More Advanced Integration-Test Architecture
As your application grows, your test project might eventually look like:
IntegrationTests/
│
├── Fixtures/
│ ├── CustomWebApplicationFactory.cs
│ ├── DatabaseFixture.cs
│ └── AuthenticationFixture.cs
│
├── Helpers/
│ ├── TodoHelper.cs
│ └── AuthenticationHelper.cs
│
├── Builders/
│ └── TodoBuilder.cs
│
├── Tests/
│ ├── Todos/
│ │ ├── CreateTodoTests.cs
│ │ ├── GetTodoTests.cs
│ │ ├── UpdateTodoTests.cs
│ │ └── DeleteTodoTests.cs
│ │
│ ├── Authentication/
│ │ └── AuthenticationTests.cs
│ │
│ └── Users/
│ └── UserTests.cs
│
└── IntegrationTests.csprojThis is where integration testing starts looking like an engineering discipline rather than a handful of tests.
46. Test Data Builders
As your tests grow, you’ll notice something:
new
{
title = "Something",
isCompleted = false
}appears everywhere. Then you have 30 properties.Then:
new Todo
{
...
}starts becoming huge. A test-data builder can help:
var todo = new TodoBuilder()
.WithTitle("Learn integration testing")
.Incomplete()
.Build();Now your test says what matters instead of being buried in setup details.
47. Helper Methods
You might eventually create:
private async Task<Todo> CreateTodoAsync(
string title)
{
var response =
await _client.PostAsJsonAsync(
"/api/todos",
new
{
title,
isCompleted = false
});
response.EnsureSuccessStatusCode();
return await response.Content
.ReadFromJsonAsync<Todo>();
}Then a test becomes:
var todo =
await CreateTodoAsync(
"Learn integration testing");
var response =
await _client.GetAsync(
$"/api/todos/{todo.Id}");
Assert.Equal(
HttpStatusCode.OK,
response.StatusCode);The test becomes much easier to read.
48. But Don’t Abstract Too Early
There’s a trap here. You can make testing so abstract that nobody understands it. Imagine:
await ExecuteScenario(
ScenarioFactory.Create()
.WithDefaultUser()
.WithDefaultTodo()
.WithExpectedResponse(...)
.Build());That’s clever. But if someone needs three hours to understand the helper before understanding the test, you’ve lost something. Start simple. Extract repetition when repetition becomes painful.
49. Fluent Assertions Revisited
Once the tests are stable, Fluent Assertions can make them more expressive. Instead of:
Assert.NotNull(todo);
Assert.Equal(
"Learn integration testing",
todo.Title);
Assert.False(
todo.IsCompleted);you can write:
todo.Should().NotBeNull();
todo!.Title.Should()
.Be("Learn integration testing");
todo.IsCompleted.Should()
.BeFalse();Collections become particularly expressive:
todos.Should()
.HaveCount(2);
todos.Should()
.Contain(todo =>
todo.Title == "Learn integration testing");The Fluent Assertions documentation specifically focuses on intention-revealing assertions and richer failure messages. Once you’re comfortable with xUnit assertions, this is a worthwhile upgrade.
50. The Testing Vocabulary You Should Now Understand
Let’s decode some of the terminology.
SUT
System Under Test: The application you’re testing. In our case:
TodoApiTest
A specific scenario verifying behavior.
[Fact]
public async Task ...Fixture
Reusable test infrastructure. Our:
TodoApiFactoryis a fixture.
Arrange
Prepare the scenario.
Act
Perform the operation.
Assert
Verify the result.
Assertion
A statement about what should be true.
Assert.Equal(...)Test isolation
Tests don’t depend on other tests.
Test double
A replacement for a dependency used during testing. Examples include:
Mock
Stub
Fake
SpyIntegration test
Tests multiple components working together.
End-to-end test
Tests a complete user/system workflow.
51. What Does “Mastery” Actually Mean?
Mastery isn’t memorizing:
WebApplicationFactoryor:
IClassFixtureMastery is being able to look at an application and ask:
“What boundary is important to verify?”
For example:
API->DatabaseMaybe that’s your integration boundary.
Or:
API->Message brokerMaybe that’s the important boundary.
Or:
API->External providerMaybe you need a fake provider. Or:
API->Authentication->Authorization->DatabaseMaybe that’s what you need confidence in. The tools are secondary. The thinking is primary.
52. From Beginner to Mastery
Here’s the progression I’d recommend.
Level 1 — Understand the vocabulary
Learn:
Test
Fact
Theory
Assertion
Arrange
Act
Assert
Unit test
Integration test
E2E testLevel 2 — Write HTTP integration tests
Be comfortable with:
HttpClientand:
GetAsync()
PostAsJsonAsync()
PutAsJsonAsync()
DeleteAsync()Level 3 — Understand WebApplicationFactory
Know:
What it does
Why it exists
How it boots the application
How CreateClient worksLevel 4 — Control the application
Learn:
ConfigureWebHost
ConfigureServices
Dependency replacement
Test configurationLevel 5 — Master test data
Learn:
Fixtures
Builders
Factories
Seed data
Database reset
IsolationLevel 6 — Test real infrastructure
Learn:
SQL Server
PostgreSQL
Testcontainers
Docker
Database migrationsLevel 7 — Test security
Learn:
Authentication
Authorization
Claims
Roles
Policies
JWT
CookiesLevel 8 — Test distributed systems
Learn how to test:
HTTP services
Message queues
Events
Caching
External APIs
Retries
Timeouts
Failure scenariosLevel 9 — CI/CD
Run integration tests automatically in:
GitHub Actions
Azure DevOps
GitLab CI
JenkinsLevel 10 — Test strategy
Eventually, the most important question isn’t:
“How do I write this test?”
It’s:
“Should I write this test, and what should it prove?”
That’s where testing becomes engineering.
53. A Practical Checklist
When adding an integration test for an API endpoint, ask:
Request
- What HTTP method?
- What route?
- What request body?
- What headers?
- Is authentication required?
Success
- What status code?
- What response body?
- What headers?
- What database state should change?
Validation
- What happens with invalid input?
- What happens with missing fields?
- What happens with invalid IDs?
Security
- What happens without authentication?
- What happens with insufficient permissions?
Persistence
- Was the data actually saved?
- Can it be retrieved afterward?
- Was an update actually persisted?
- Was a deletion actually persisted?
Isolation
- Does this test depend on another test?
- Does it leave data behind?
- Can I run it independently?
External dependencies
- Does the test call an external system?
- Should that dependency be replaced?
Maintainability
- Does the test name explain the scenario?
- Is the test easy to understand?
- Am I testing behavior rather than implementation details?
54. What We Built
Starting with a simple Todo API, we’ve gone from:
No automated integration teststo:
Todo API
↓
Integration Test Project
↓
xUnit
↓
WebApplicationFactory
↓
HttpClient
↓
Test Database
↓
CRUD Integration Tests
↓
AssertionsOur repository now has tests covering:
CREATE
GET
UPDATE
DELETEincluding successful scenarios, persistence checks, not-found scenarios, and delete behavior.
55. The Bigger Picture
The Todo API is intentionally boring. That’s a feature. Real applications are complicated enough.We don’t need authentication, payments, queues, databases, third-party providers, caching, background jobs, and distributed services just to understand the fundamental idea.
The fundamental idea is:
Given this state...
When I interact with the system...
Then I expect this behavior.That’s the heart of integration testing. The Todo API simply gives us a small system in which we can see that idea clearly.
56. Final Thoughts
Integration testing initially looked to me like another thing developers are expected to know:
WebApplicationFactory
HttpClient
Fixtures
Facts
Assertions
Test databasesBut after breaking it down, the concepts are much simpler.At its core, we’re doing this:
Set up the world
↓
Talk to the application
↓
Observe what happened
↓
Verify the resultThe magic isn’t really the framework. The magic is that we can do this automatically. Instead of manually opening Swagger after every change and hoping we’ve remembered all the important scenarios, we can encode those scenarios into executable documentation.
And then we can run:
dotnet testagain. And again. And again. That’s the part I find most valuable. The test suite becomes a second pair of eyes. Not a replacement for thinking. Not a guarantee that production will never break. But a reliable, repeatable safety net that can tell us:
“The behavior you told me was important still works.”
And as applications grow, that safety net becomes increasingly valuable.
The Repository
If you want to follow the tutorial hands-on: Start with the Todo API before integration testing:
Clone the pre-testing Todo API
Compare your finished work with the final repository:
View the completed integration-testing repository
The official ASP.NET Core integration-testing documentation is also worth keeping nearby as a reference, particularly when you start customizing WebApplicationFactory, authentication, databases, and application services.
One Last Mental Model
If you remember nothing else from this tutorial, remember this:
INTEGRATION TEST
│
▼
HTTP Request
│
▼
┌─────────────────┐
│ ASP.NET Core │
│ │
│ Routing │
│ Middleware │
│ Model Binding │
│ Controller │
│ Services │
│ EF Core │
└────────┬────────┘
│
▼
Test Database
│
▼
HTTP Response
│
▼
AssertYou’re not just testing a method. You’re asking:
“When a real consumer interacts with my application, do the important pieces work together to produce the behavior I promised?”
That’s integration testing.










