Sitemap
iamprovidence

iamprovidence

👨🏼‍💻 Full Stack Dev writing about software architecture, patterns and other programming stuff https://www.buymeacoffee.com/iamprovidence

Integration Tests

15 min readAug 27, 2025

--

Press enter or click to view image in full size

Hey, you 🫵. Yes, you 🫵. I hope you’ve already read the previous article where I covered naming conventions and project structure — those principles apply to integration tests as well. To avoid repeating myself, I’ll focus here specifically on what matters most for integration tests.

We’ll look at what integration tests are, what they should cover, what to mock, useful NuGet packages for writing clean tests, and which testing methodologies are worth applying.

Don’t forget to check out other articles on this topic:

If you are ready, let us begin.

Understanding Integration Tests

Instead of testing a single function in isolation, integration tests check how multiple components work together without relying on mocks or stubs.

[Test]
public async Task Create_and_save_order_in_the_database()
{
// Arrange
var orderDto = new CreateOrderDto
{
ProductId = 1,
Quantity = 2,
};

// Act
await _orderService.CreateOrderAsync(orderDto);

// Assert
var orderInDb = await _dbContext.Orders.FirstOrDefaultAsync(o => o.ProductId == 1);
orderInDb.Should().NotBeNull();
orderInDb.Quantity.Should().Be(2);
}

Integration tests help catch issues that unit tests can’t — like misconfigured services, broken database queries, or incorrect wiring in dependency injection.

Integration Test Boundaries *

The term integration test has become vague and increasingly blurred. Different teams define “component” in their own way.

In some projects, integration testing refers to verifying interactions between multiple classes, for example, a service calling a repository:

[Test]
public async Task Create_and_save_order_in_the_database()
{
// Arrange
var orderDto = new CreateOrderDto
{
ProductId = 1,
Quantity = 2,
};

// Act
await _orderService.CreateOrderAsync(orderDto);

// Assert
var orderInDb = await _dbContext.Orders.FirstOrDefaultAsync(o => o.ProductId == 1);
orderInDb.Should().NotBeNull();
orderInDb.Quantity.Should().Be(2);
}

In other contexts, integration testing starts at the API level and exercises the entire stack — including controllers, middlewares, services, repositories, and database access:

[Test]
public async Task Get_user_profile()
{
// Arrange
var userId = await TestData.CreateUserAsync("john.doe@example.com");

// Act
var response = await _apiClient.GetAsync($"/api/users/{userId}");

// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<UserProfileDto>();
result.Email.Should().Be("john.doe@example.com");
}

Microsoft wouldn’t even classify this as an integration test; they’d refer to it as a Functional Tests, where you send input to the entire system and verify the output.

I don’t really see any difference 😅; therefore, I treat them the same way.

Integration Test Characteristics

An integration test typically has the following traits:

  • slower — since they involve more moving parts, they naturally take more time to execute than unit tests
  • realistic — they use actual infrastructure components (like test databases, real HTTP calls, or file systems) to mimic real-world behavior
  • more brittle — if any part of the system changes (e.g., schema, config, or service dependencies), these tests are more likely to break
  • higher confidence — they validate that your system works as expected in a production-like setup
  • coupled — they test the behavior of combined components rather than individual pieces
Press enter or click to view image in full size

As you might expect, integration tests require more maintenance and careful setup to keep them reliable and useful 😅.

What to Test?

As we discussed in the article about unit tests, code can vary based on complexity and the number of dependencies.

While the same principles from unit testing still apply, they often manifest differently in integration tests.

Press enter or click to view image in full size

Let’s see how our testing quadrant behaves:

  • bottom-left corner — code with no complexity and no dependencies
public class User
{
public string FirstName { get; set; }
public string LastName { get; set; }

public string FullName => $"{FirstName} {LastName}";
}

This is usually simple getters/setters, constructors, etc.

Testing this offers little value and is typically not worth it at all. Moreover, it would be an overkill for integration tests.

  • top-left corner — code with complexity and no dependencies
public class Note
{
public int Id { get; private set; }
public string Text { get; private set; }
public DateTime UpdatedAt { get; private set; }

// business logic
public void ChangeText(string newText, DateTime updatedAtUtc)
{
if (string.IsNullOrWhiteSpace(newText))
{
throw new ArgumentNullException(nameof(newText));
}

if (updatedAtUtc.Kind != DateTimeKind.Utc)
{
throw new ArgumentException("The date must be in UTC.", nameof(updatedAtUtc));
}

this.Text = newText;
this.UpdatedAt = updatedAtUtc;
}
}

Those are the business logic, algorithms, data structures, etc.

This code has pure logic and no external dependencies — perfectly isolated. Unit tests here are fast, reliable, and easy to write.

Integration tests would just slow you down without adding value. They’d repeat the same assertions but with unnecessary setup and infrastructure overhead.

  • top-right corner — code with complexity and dependencies
public class GodService
{
. . .

public GodService(
IOrderRepository orderRepository,
IEmailSender emailSender,
IPaymentProcessor paymentProcessor,
IInventoryService inventoryService,
IShippingService shippingService,
ICustomerRepository customerRepository,
ILogger logger,
IDiscountCalculator discountCalculator,
ITaxService taxService,
INotificationService notificationService)
{
_orderRepository = orderRepository;
_emailSender = emailSender;
_paymentProcessor = paymentProcessor;
_inventoryService = inventoryService;
_shippingService = shippingService;
_customerRepository = customerRepository;
_logger = logger;
_discountCalculator = discountCalculator;
_taxService = taxService;
_notificationService = notificationService;
}

public async Task HandleOrderAsync(Order order)
{
. . .
}
}

These are your God classes — big, complex, and deeply wired.

In theory, this kind of code should not exist. In practice, we all have been there 😅.

Integration tests can help you to refactor this code. Instead of mocking 10+ services, let the real ones run. Integration tests can verify the whole workflow, catch wiring mistakes, and give you confidence that the system works as a whole.

Covering all cases in complex code can be challenging, but once you do, you can safely refactor it into smaller, more focused classes.

  • bottom-right corner — code with no complexity but dependencies
public class OrderCreatedConsumer : IConsumer<OrderCreated>
{
public async Task Consume(ConsumeContext<OrderCreated> context)
{
_arderAppService(context.Message);
}
}

These are your controllers, message consumers, command handlers, and similar glue code.

Unit testing this kind of code brings little value — it has no logic, just delegation. Yet you still need to mock everything, which makes tests brittle and fake.

This is a perfect fit for integration tests. Test the full flow instead of slicing it thin. You’ll gain more confidence with less mocking.

Press enter or click to view image in full size

How many integration tests do you need?

According to the testing pyramid, you should have more unit tests, fewer integration tests, and even fewer end-to-end tests.

The idea is to keep your test suite fast, reliable, and maintainable. Integration tests are valuable for verifying how components work together, especially around critical flows — but they shouldn’t dominate your suite.

Write just enough integration tests to cover the key interactions and avoid blind spots, while relying on unit tests for detailed logic checks and E2E tests for full-system confidence.

Press enter or click to view image in full size

However, lately, many teams have started using integration tests instead of unit tests — they’re faster to write, require no mocks, less setup, tend to be more stable against regressions, and give a better confidence about the system behavior 🙃.

You can also give this approach a try, but from my experience, integration tests come with trade-offs. When their number grows, you can notice that they’re slower to run, and developers start running them less frequently before pushing code.

Additionaly, they are harder to investigate when they fail, since debugging often means digging through the entire system.

Therefore, I write one or two integration tests per endpoint, following this advice:

Use integration tests to cover the longest path in your use case, verifying as many interactions as possible.

While integration tests add confidence in system behavior, they should complement — not replace — unit tests. Run them less frequently, but make sure they cover the critical interaction paths.

What to Mock?

Even though integration tests are generally meant to run with real components to verify how they work together, there are cases when you might still want to use mocks 😬.

All dependencies fall into two categories:

  • in-process — are components or services that run within the same application or process boundary. These are usually libraries, internal services, or modules directly accessible by the code under test
  • out-of-proces — run outside the application’s process — such as external databases, message brokers, web services, or APIs
Press enter or click to view image in full size

In-process dependencies can be divided into two types:

  • stable — these are components like internal services or modules that provide consistent, predictable behavior. (e.g., logging service, a calculation library)
  • unstable — these include elements that introduce unpredictability into your code (e.g. current time, random number generators, or thread behavior)

Out-of-process dependencies also fall into two categories:

  • managed — these are external systems or services that you can control in your test environment (e.g. test databases, local instances of message brokers, local SMTP server, first-party API)
  • unmanaged — these are systems outside your control (e.g. cloud services, third-party APIs, or external payment gateways)

Understanding these categories helps decide when to mock dependencies and when to run full integration tests, balancing test speed, reliability, and coverage:

  • in-process stable dependencies are easy to test and usually don’t require special handling in tests
  • in-process unstable dependencies require special attention in tests, often through mocking to control their output — to ensure test reliability, and consistency
  • out-of-process managed dependencies can be configured, reset, or seeded without mocks. Thus, you can create reliable and repeatable integration tests
  • out-of-process unmanaged dependencies are slow, unreliable, and costly. In such cases, you often mock these dependencies in your tests to avoid flaky tests and excessive test execution times
Press enter or click to view image in full size

Just be careful not to overuse mocks — your goal is to test real integration points whenever possible, so mock only when absolutely necessary.

How to Mock?

When testing how your system interacts with external services, it’s important to verify those interactions accurately without introducing unnecessary complexity or fragility.

— managed

For managed dependencies (like your API, databases, or message brokers), you may have a tendency to use your local instance, but this can lead to inconsistent test results, environment drift, and difficulties in test isolation.

ASP.NET Core includes a built-in test web host that can be used to handle HTTP requests without network overhead, meaning that you can run those tests faster than when using a real web host.

[Test]
public async Task Get_user_profile()
{
// Arrange
var userId = await TestData.CreateUserAsync("john.doe@example.com");

var webHost = new WebHostBuilder().UseStartup<Startup>();
var testServer = new TestServer(webHost); // 👈
var apiClient = testServer.CreateClient();

// Act
// in-memory calls instead of real network connection
var response = await apiClient.GetAsync($"/api/users/{userId}"); // 👈

// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<UserProfileDto>();
result.Email.Should().Be("john.doe@example.com");
}

For the database, you can use Respawn. The library from Jimmy Bogard (creator of AutoMapper and MediatR), and unlike those, people actually seem to enjoy this one 🤣.

It allows you to quickly reset your database state between tests, ensuring isolation and repeatability without slow database recreations.

[Test]
public async Task Get_user_profile()
{
// Arrange
const string connectionString = "YourConnectionStringHere";

var checkpoint = await Respawner.CreateAsync(connectionString); // 👈

var userId = await TestData
.CreateUserAsync(connectionString, "john.doe@example.com");

// Act
var response = await _apiClient.GetAsync($"/api/users/{userId}");

// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<UserProfileDto>();
result.Email.Should().Be("john.doe@example.com");

await checkpoint.ResetAsync(connectionString); // 👈
}

Another approach would be using containerized instances (e.g., via Docker and Testcontainers). It ensures a clean, reproducible environment that is isolated from your local setup and other tests.

[Test]
public async Task Get_user_profile()
{
// Arrange
// 👇
var postgresContainer = new TestcontainersBuilder<PostgreSqlTestcontainer>()
.WithDatabase(new PostgreSqlTestcontainerConfiguration
{
Database = "testdb",
Username = "postgres",
Password = "password",
})
.Build();
var connectionString = postgresContainer.GetConnectionString();

var userId = await TestData
.CreateUserAsync(connectionString, "john.doe@example.com");

// Act
var response = await _apiClient.GetAsync($"/api/users/{userId}");

// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<UserProfileDto>();
result.Email.Should().Be("john.doe@example.com");

await postgresContainer.StopAsync(); // 👈
}

When writing integration tests, it’s common to seed test data — such as users, orders, or products — into your test database. This helps simulate realistic scenarios and makes your tests more robust.

Get iamprovidence’s stories in your inbox

Join Medium for free to get updates from this writer.

Instead of hardcoding values, you can use the Bogus library to generate fake but realistic data quickly and easily.

var userFaker = new Faker<User>()
.RuleFor(u => u.Id, f => Guid.NewGuid())
.RuleFor(u => u.FirstName, f => f.Name.FirstName())
.RuleFor(u => u.LastName, f => f.Name.LastName())
.RuleFor(u => u.Email, f => f.Internet.Email());

/*
{
"Id": "31f60c8c-6d41-4525-8efa-1a9d7d777aad",
"FirstName": "Audrey",
"LastName": "Spencer",
"Email": "Audrey82@gmail.com"
}
*/
var user = userFaker.Generate();

— unmanaged

For unmanaged dependencies — like third-party APIs, payment gateways, or external services — you don’t control their behavior or availability. That’s why it’s crucial to test how your system interacts with them, not just assume everything works.

Two key rules when testing with unmanaged dependencies:

  • verify the number of calls (e.g., was the external API called exactly once?)
  • verify interactions at the system edges (e.g., what was actually sent in the HTTP request?)

The best practice is to mock at the very edge of your system, right where the HTTP request is made.

Instead of mocking your service layer (IThirdPartyApiClient), mock the actual DelegatingHandler to intercept real HTTP calls made by HttpClient.

This approach ensures that:

  • you verify HTTP behavior (headers, body, status codes)
  • you test the formatting and structure of requests
  • you’re not over-mocking the business logic

You can go even further. Use WireMock.NET to spin up a lightweight local HTTP server and simulate external services:

using var fakeServer = WireMockServer.Start();

fakeServer
.Given(Request.Create().UsingGet().WithPath("/api/server/ping"))
.RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK).WithBodyAsJson(new
{
State = "Running...",
}));

// in-memory calls to the third API
var thirdPartyApiClient = new HttpClient
{
BaseAddress = new Uri(fakeServer.Urls[0]),
};

fakeServer.LogEntries.Should().HaveCount(1); // verify number of calls

Mocking Authentication

Authentication is essential in production, but during testing, it just slows things down.

Fortunately, there are several strategies you can use to mock or bypass authentication depending on your needs.

— login in every test

In integration tests, you may want to simulate real login behavior:

  • programmatically perform a login request before each test
  • store the returned token and use it in subsequent API calls

This approach keeps things realistic but can slow down your test suite and may require a test user with known credentials 🙃.

— use a long-lived token

To avoid logging in repeatedly:

  • generate a valid access token manually with extended life-time (like 5 years) and use it in tests

Be cautious: don’t commit real tokens to version control, especially if they grant access to live data.

— bypass auth

In local development, you can simplify things even further 😅:

This is useful for rapid development or testing user roles/permissions without going through the full login flow.

Tricks and Tips

There are also some other practices and tips I would like to share 🙃.

— use separate DTOs for PublicAPI

If your system exposes an API intended for use by other systems, it’s better to create a separate copy of the request and response models rather than directly referencing the internal DTOs used in your application code.

class IntegrationTests
{
// 👇 copy of internal DTOs
internal class OrderCreateRequest { . . . }
internal class OrderCreateResponse { . . . }

[Test]
public async Task Create_order()
{
// Arrange
var request = new OrderCreateRequest // 👈
{
Name = "Order",
};

// Act
var response = await _apiClient.PostAsync($"/api/order", request);

// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var response = await response.Content.ReadFromJsonAsync<OrderCreateResponse>(); // 👈
response.Name.Should().Be("Order");
}
}

Picture this: you’re using the internal model and decide to rename a property — your integration tests will be updated automatically and still pass, even though the API contract is now broken. But if you’re using a separate copy of the DTO, the mismatch becomes immediately visible, helping you catch breaking changes before they reach consumers.

However, this applies only to public APIs. For internal APIs, it’s perfectly acceptable to use the same models in your integration tests, as you’re in full control of both the implementation and the consumers.

— override test attributes

Another helpful pattern I saw online is to customize test execution based on attributes.

If, in your local setup, you want to exclude all integration tests by default — but without having to manually filter them every time — you can control it with a simple comment/uncomment toggle.

For example:

// Uncomment to enable tests
//class FactSwitch : FactAttribute
//{
//}

// Comment out to disable them
class FactSwitch : FactAttribute
{
public ovveride string Skip => "Skiping integration tests";
}

[FactSwitch]
public async Task Create_order()
{
. . .
}

You can also make test execution dynamic based on configuration, so your CI runs all tests, while local runs skip integration ones by default:

class TestAttribute : FactAttribute
{
public IntegrationTestAttribute()
{
var enabled = Environment.GetEnvironmentVariable("RUN_INTEGRATION_TESTS");
if (enabled != "true")
{
Skip = "Integration tests are disabled. Set RUN_INTEGRATION_TESTS=true to enable.";
}
}
}

[Test]
public async Task Create_order()
{
. . .
}

You can come up with other clever uses for overriding attributes 😉.

Testing Methodologies

Not every testing methodology suits every scenario equally well. Some approaches are great for verifying detailed logic, while others shine when validating the system as a whole or involving stakeholders in defining behavior.

Let’s explore how different testing styles — Test-After (Test Last), TDD, Snapshot Testing, and BDD — align (or don’t) with the goals of integration testing.

— Test Last Development 🟢

Writing tests after you core logic tends to be a good approach.

Press enter or click to view image in full size

These tests help ensure that different parts of your application communicate correctly, external dependencies behave as expected, and workflows complete successfully.

Moreover, integration tests act as a guardrail against future regressions — giving you confidence that changes or refactoring won’t break existing functionality.

— TDD (Test Driven Development) 🟢

With integration testing, when you have a clear contract defining the inputs and expected outputs of your API, writing tests before implementing it is a solid approach.

Press enter or click to view image in full size

You might still encounter challenges when it comes to verifying or mocking dependencies, but overall, this method tends to work effectively.

— Snapshot Tests 🟢

Snapshot testing can be a valuable tool in integration testing, especially when dealing with complex objects or outputs that are mostly static.

Press enter or click to view image in full size

In C#, you can use the Verify library, which captures structured or textual output and compares it against a previously approved snapshot:

[UsesVerify]
public class UserApiTests
{
[Test]
public async Task Get_user_profile()
{
// Arrange
var userId = await TestData.CreateUserAsync("john.doe@example.com");

// Act
var response = await _apiClient.GetAsync($"/api/users/{userId}");

// Assert
// 👇 no need in multiple assertions
await Verifier.Verify(response);
}
}

On the first run, the test will fail and generate a .received.json file. You then review and approve this file to create the .verified.json snapshot.

Snapshot tests are particularly useful for verifying API responses where outputs rarely change unexpectedly. They help catch unintended changes with minimal setup.

— BDD (Behaviour Driven Development) 🟢

Writing behavior-focused scenarios like “Given, When, Then” helps clarify how a system should behave from the user’s or stakeholder’s perspective.

This can be valuable when testing API contracts in a more human-readable way.

Press enter or click to view image in full size

For smaller teams or internal APIs, the added structure may not be worth the cost. Unless you’re working closely with non-technical stakeholders or need living documentation, plain integration tests with clear assertions are often more efficient and easier to maintain.

CI/CD

Integration tests play a key role in catching issues that unit tests can’t — such as misconfigured services, broken API contracts, or missing infrastructure pieces.

However, they’re slower and more complex to run, so they’re not typically executed as often during local development.

In CI/CD, integration tests should run after unit tests, ideally on every pull request or before merging into main.

Press enter or click to view image in full size

They ensure that the system works as a whole in a realistic environment and help prevent breaking changes from slipping through.

Summary

I know, I know — yet another long article about testing 😅

So let’s wrap it up with a quick, practical summary:

  • don’t write integration tests for every edge case. Instead, focus on a few tests that cover the longest path in your use case, verifying as many system interactions as possible
  • mock unstable and unmanaged dependencies (like third-party services or anything that’s outside your control)
  • use TestServer to host your ASP.NET Core API in-memory during integration tests
  • use Respawn to reset your database to a clean state between tests
  • use Testcontainers to isolate dependencies like databases or queues in Docker containers
  • use Bogus to generate realistic and consistent test data
  • for external dependencies, verify the number of calls and what’s being sent
  • verify interactions at the system edges, e.g., by mocking HttpMessageHandler
  • use WireMock.NET to simulate and inspect HTTP requests to external services
  • create separate DTOs in integration tests that validate the public API to detect potential backward compatibility bugs
  • all testing methodologies can be combined with integration tests, depending on your needs
  • try Verify for easy and effective snapshot testing of complex responses or serialized output

That’s it — modern integration testing without the noise 🙃.

And here is your reward. A small joke for getting that far:

Press enter or click to view image in full size

Let me know what other practices you apply in the integration tests 💬

Give this article a clap👏

Support me using a link below ☕️

And don’t forget to follow ✅

--

--

iamprovidence
iamprovidence

Written by iamprovidence

👨🏼‍💻 Full Stack Dev writing about software architecture, patterns and other programming stuff https://www.buymeacoffee.com/iamprovidence