Sitemap

Integration Testing

23 min readSep 27, 2026

--

Press enter or click to view image in full size

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 test

The 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

  1. What Is Integration Testing?
  2. Why Integration Testing Matters
  3. Unit vs Integration vs End-to-End Testing
  4. Our Todo API
  5. The Repository
  6. Testing Frameworks: xUnit, NUnit, MSTest
  7. Why I’m Using xUnit
  8. Assertions and Fluent Assertions
  9. Our First Integration Test
  10. Fact, Theory and Test Attributes
  11. Arrange, Act, Assert
  12. WebApplicationFactory
  13. HttpClient
  14. Exposing Program for Integration Tests
  15. Building a Custom Test Factory
  16. Testing Create
  17. Testing Get
  18. Testing Update
  19. Testing Delete
  20. Testing the Result, Not Just the Status Code
  21. Test Isolation
  22. The Shared Database Problem
  23. Why InMemory Is Useful — and Its Limitations
  24. Good Integration Test Design
  25. Testing Error Scenarios
  26. Testing Authentication and Authorization
  27. Testing External Services
  28. Integration Testing and the Real Database
  29. Testcontainers
  30. Integration Tests in CI/CD
  31. Common Integration Testing Mistakes
  32. From Beginner to Mastery
  33. 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 Response

A 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/todos

with:

{
"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 correct

That’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 / FAIL

That’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
↓
Test

Fast. Focused. Isolated.

Integration testing

Now imagine:

HTTP
↓
Controller
↓
Service
↓
EF Core
↓
Database

The 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 systems

For 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.sln

The 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 tests

NUnit

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:

  1. It has excellent .NET ecosystem support.
  2. It works naturally with ASP.NET Core integration testing.
  3. WebApplicationFactory examples commonly use xUnit.
  4. Its fixture model is useful for integration-test infrastructure.
  5. [Fact] and [Theory] provide a simple way to express test scenarios.
  6. 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
Assert
HTTP request
Application
Database
Response
Assertions

Only 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.StatusCode

If they match:

PASS

Otherwise:

FAIL

Fluent 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.cs

The 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 OK

That’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 3

you 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
Assert

At 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 run

and then pointing Postman at:

https://localhost:5001

the test infrastructure can bootstrap the application and provide an HttpClient.

Conceptually:

Test
│
▼
WebApplicationFactory
│
▼
ASP.NET Core application
│
├── Middleware
├── Routing
├── Controllers
├── Dependency Injection
└── Database

Then:

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:

HttpClient

we 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.cs

The 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 configuration

Our 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.cs

The endpoint is:

POST /api/todos

One 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
+
properties

That’s much more useful.

18. Testing the Location Header

Our API returns:

201 Created
Location: /api/Todos/9

We 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 Todo

We 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/todos

Then 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 → 201

We’re checking:

POST
↓
Controller
↓
EF Core
↓
Database
↓
GET
↓
EF Core
↓
Database
↓
Response

We’re verifying the actual behavior. The repository uses this pattern in CreateTodo_WithValidRequest_PersistsTodo.

20. Testing Get

Our GET tests cover:

GET /api/todos

and:

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 data

This 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 data

The 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
↓
404

This 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->404

Your 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:

TodoDb

But 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 Todo

Then:

Test B
Create Todo

Then:

Test C
Create Todo

If they all use the same database:

Test A → 1 Todo
Test B → 2 Todos
Test C → 3 Todos

Now 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 test

or:

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 state

Not:

Test
↓
Hope another test didn't change the database
↓
Assert

28. 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 database

is useful for:

Learning
Simple integration tests
Fast test scenarios

But 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 engine

Now 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 container

Your 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 Request

A 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/todos

requires authentication. You should test:

No token
↓
401 Unauthorized

Then:

Valid token
↓
200 OK

And perhaps:

Authenticated but insufficient permission
↓
403 Forbidden

This 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 API

You generally don’t want your integration tests making real payments or sending real SMS messages. Instead:

Production
↓
Real provider



Tests
↓
Fake/mock provider

This 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
↓
Database

while replacing truly external systems:

HTTP
↓
Application
↓
Database
↓
Fake external provider

This 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_ReturnsCreated

They may look long. That’s intentional. A test name should communicate:

What am I doing?
What condition?
What should happen?

For example:

CreateTodo_WithValidRequest_ReturnsCreated

means:

Action:
CreateTodo
Condition:
WithValidRequest
Expected result:
ReturnsCreated

Another:

GetTodo_WithNonExistingId_ReturnsNotFound

reads 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 behavior

Tomorrow you might change:

TodoService

to:

TodoCommandHandler

The 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 user

Now:

Test 4

depends on:

Test 1
Test 2
Test 3

Instead:

Test 1
└── Creates what it needs
Test 2
└── Creates what it needs
Test 3
└── Creates what it needs
Test 4
└── Creates what it needs

Tests 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
↓
Database

or:

API
↓
Authentication
↓
Database

Use 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 success

Validation

Invalid input->400

Not found

Unknown resource->404

Authentication

No credentials->401

Authorization

Insufficient permission->403

Persistence

Write->Read->Verify

Side effects

API operation->Expected event/message/external interaction

Failure handling

Dependency failure->Expected application behavior

This is much more useful than simply thinking:

“I need to test every method.”

41. Integration Testing Is About Confidence

Imagine changing this:

TransactionController

in 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 works

That 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 test

You can run a specific project:

dotnet test TodoApi.IntegrationTests

You 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
↓
Merge

Now 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 blocked

That’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:

Hope

toward:

Evidence

And 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.csproj

This 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:

TodoApi

Test

A specific scenario verifying behavior.

[Fact]
public async Task ...

Fixture

Reusable test infrastructure. Our:

TodoApiFactory

is 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
Spy

Integration 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:

WebApplicationFactory

or:

IClassFixture

Mastery is being able to look at an application and ask:

“What boundary is important to verify?”

For example:

API->Database

Maybe that’s your integration boundary.

Or:

API->Message broker

Maybe that’s the important boundary.

Or:

API->External provider

Maybe you need a fake provider. Or:

API->Authentication->Authorization->Database

Maybe 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 test

Level 2 — Write HTTP integration tests

Be comfortable with:

HttpClient

and:

GetAsync()
PostAsJsonAsync()
PutAsJsonAsync()
DeleteAsync()

Level 3 — Understand WebApplicationFactory

Know:

What it does
Why it exists
How it boots the application
How CreateClient works

Level 4 — Control the application

Learn:

ConfigureWebHost
ConfigureServices
Dependency replacement
Test configuration

Level 5 — Master test data

Learn:

Fixtures
Builders
Factories
Seed data
Database reset
Isolation

Level 6 — Test real infrastructure

Learn:

SQL Server
PostgreSQL
Testcontainers
Docker
Database migrations

Level 7 — Test security

Learn:

Authentication
Authorization
Claims
Roles
Policies
JWT
Cookies

Level 8 — Test distributed systems

Learn how to test:

HTTP services
Message queues
Events
Caching
External APIs
Retries
Timeouts
Failure scenarios

Level 9 — CI/CD

Run integration tests automatically in:

GitHub Actions
Azure DevOps
GitLab CI
Jenkins

Level 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 tests

to:

Todo API
↓
Integration Test Project
↓
xUnit
↓
WebApplicationFactory
↓
HttpClient
↓
Test Database
↓
CRUD Integration Tests
↓
Assertions

Our repository now has tests covering:

CREATE
GET
UPDATE
DELETE

including 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 databases

But 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 result

The 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 test

again. 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
│
▼
Assert

You’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.

--

--

Written by G

I love breaking things responsibly just to see what makes them tick. Technology, software engineering & the future genuinely excite me. On Twitter @KibaruSymon