How to Write Effective Integration Tests for .NET APIs

Integration tests give a .NET API a realistic workout. Instead of checking one class in isolation, they send requests through routing, authentication, middleware, validation, serialisation, database access, and external service boundaries. This makes them especially useful for finding defects that unit tests cannot see, such as an incorrectly registered dependency or a JSON contract that no longer matches the client.

For teams building software in Australia, reliable API tests also protect important operational details. A service used in Sydney may call a database in Melbourne, serve customers in Perth, and process personal information under the Privacy Act 1988. Testing the complete request path helps developers detect failures before they reach production, where time zones, mobile connectivity, and customer expectations can make a small defect expensive.

Define The Boundary Of An Integration Test

An integration test should verify how several real application components work together. In an ASP.NET Core API, that commonly means starting the application with a test host, sending an HTTP request, and checking the response produced by the configured pipeline. The test may use a real database engine, a test authentication handler, and selected fake implementations for unstable third-party systems.

This boundary is different from a unit test. A unit test might call a controller method with mocked services and verify that it returns an object. An integration test should usually call the route itself, such as POST /api/orders, then verify the status code, response body, database state, and important side effects. It tests behaviour from the consumer’s perspective rather than the internal arrangement of classes.

Avoid turning every test into an end-to-end journey through every dependency. A payment gateway, email provider, or address validation service can make a suite slow and unpredictable. Replace those boundaries with controlled test doubles, while keeping your own HTTP pipeline and persistence layer real. This gives the test a meaningful scope without making failures impossible to diagnose.

A useful test inventory covers the paths that matter commercially. Include successful reads and writes, invalid input, unauthorised requests, forbidden operations, missing records, concurrency conflicts, and downstream failures. For an Australian retail API, that might include an order delivered to Brisbane, a customer changing details from Adelaide, and a request that must not expose private data in logs or response payloads.

Build A Realistic Test Host

WebApplicationFactory<TEntryPoint> is a practical foundation for integration testing an ASP.NET Core application. It can boot the application in memory and provide an HttpClient configured against the test server. Your test then uses the same routing, dependency injection, model binding, filters, middleware, and serialisation settings as the deployed API.

Create a custom factory for test-specific configuration. It can replace selected registrations, load an appsettings test file, disable background workers, and provide a known authentication scheme. Keep these changes explicit. If the factory silently removes too many production services, the test may pass while the deployed application fails during startup.

A common pattern is to replace the production database registration with a dedicated test database. An in-memory provider can be convenient for simple repositories, but it does not reproduce relational constraints, SQL translation, transactions, indexes, or provider-specific behaviour. For applications using SQL Server, a disposable SQL Server container is often a better choice than relying exclusively on an in-memory substitute.

Each test needs isolated data. Reset the database between tests using transactions, a reset tool, or a carefully designed cleanup process. Seed only the records needed for the scenario, and make identifiers and timestamps predictable. Tests that depend on leftover rows from another test will eventually fail in parallel execution or on a developer’s machine configured differently from the build server.

Authentication deserves first-class treatment. A custom test authentication handler can create claims for an administrator, ordinary customer, or support worker without contacting an identity provider. Then write tests that prove both the allowed and denied paths. Check that a customer in Melbourne cannot retrieve another customer’s account merely by changing an ID in the URL.

Assert Behaviour At The HTTP Level

A strong API integration test asserts the complete observable contract. Start with the status code, then inspect headers and the response body. For a validation failure, verify the problem-details format, field names, and useful error codes. For a successful command, confirm the returned resource and any Location header or version value that clients rely on.

Do not assert every incidental property. Exact full-body comparisons become noisy when the API gains a harmless field or changes JSON formatting. Assert the fields that define the contract, and use structured comparisons where ordering is not meaningful. If order is part of the business rule, state that rule clearly and test it directly.

Database assertions should confirm durable behaviour rather than duplicate implementation details. After creating an invoice, query the database through a suitable test repository or direct SQL and verify the persisted total, customer relationship, and status. Then retrieve the invoice through the API and confirm that the public representation is correct. This catches mapping errors between request models, domain objects, database rows, and response DTOs.

Include negative cases with the same care as happy paths. An invalid Australian postcode, a missing required field, an expired token, or a duplicate idempotency key can reveal defects in different layers. Test that sensitive values such as access tokens, payment details, and private notes are absent from responses and error messages. The Australian Privacy Principles make careful handling of personal information an operational responsibility, not merely a testing preference.

For endpoints that call external systems, define the expected interaction. A fake shipping service can return a successful label, a timeout, or a structured rejection. Your test should verify the API’s response for each condition and confirm that retries do not create duplicate orders. Use a controllable clock for expiry and scheduling logic so a test does not depend on the wall clock in Sydney, Perth, or any other location.

Manage External Services And Browser Workflows

Containers make integration environments closer to production. Testcontainers for .NET can start services such as SQL Server, PostgreSQL, Redis, or a message broker for the duration of a test run. The setup takes longer than an in-memory substitute, but it exposes schema, transaction, networking, and configuration problems that otherwise appear only after deployment.

Keep container lifecycle management centralised. A collection fixture or shared test context can start one database container for a group of tests, while each test receives isolated data. Wait for a genuine readiness signal before running migrations. Use fixed image versions so a new upstream image does not unexpectedly alter the environment on a build agent.

External HTTP calls should normally be intercepted at your application’s boundary. HttpMessageHandler stubs, WireMock-style servers, or a local fake service can return realistic status codes, headers, delays, and malformed payloads. Record the scenarios that have caused incidents, including rate limiting, partial responses, certificate problems, and a downstream service returning HTTP 200 with an error object.

Some teams need browser-level coverage as well as API integration tests. A browser test can prove that a deployed page, JavaScript client, and API work together, while API tests remain faster for most business rules. Tools such as Thucydides WebTests can help when a workflow must be exercised through a real browser. For C# projects that need headless Chrome automation or crawling, the Puppeteer Sharp guide provides a relevant implementation path.

Keep browser tests few and purposeful. Use them for critical journeys such as signing in, submitting an order, or completing a consent flow. A failure in a browser suite is expensive to investigate when dozens of pages are involved. The API integration suite should carry the detailed coverage for validation, authorisation, persistence, and error handling.

Run The Suite Reliably In Continuous Integration

A test that passes only on a developer laptop is not a dependable safety net. Run integration tests in continuous integration with the same .NET SDK family, database engine version, environment variables, and migration process used by the application. GitHub Actions, Azure DevOps, and other build systems can run service containers or provision disposable dependencies for each job.

Separate fast unit tests from integration tests so developers can get immediate feedback without hiding the broader suite. A typical pipeline runs unit tests on every change, then starts the required services and runs integration tests before packaging or deployment. Publish test results and code coverage as build artefacts, but treat coverage as a diagnostic measure rather than the goal itself.

Parallel execution can shorten build times, but it exposes shared-state mistakes. Give each test an isolated schema, database, tenant, or data namespace where necessary. Avoid static mutable state, shared user accounts, and fixed ports. If a test must run serially because it exercises a singleton resource, document that constraint and keep the serial group small.

Diagnostics should make a failed test actionable. Capture request method, route, status code, correlation ID, and relevant response content while removing secrets and personal information. Collect server logs, container output, and database setup errors only when a test fails. This is particularly valuable for teams spread across Sydney, Melbourne, and Perth, where a broken pipeline may otherwise wait hours for the right developer to reproduce it.

Use retries carefully. Retrying an assertion can hide a race condition, and retrying a command can create duplicate records. It is reasonable to wait for a container to become ready or for an eventually consistent message to arrive, but the application behaviour should still have a clear timeout and failure message. A flaky test is a defect in the development process and should be tracked like any other defect.

Practical Habits For Maintainable Test Suites

Integration tests remain valuable when they are readable, deterministic, and aligned with business behaviour. Name them around an observable outcome, such as PostOrder_WithInvalidPostcode_ReturnsValidationProblem, rather than an internal method. Arrange the request and data clearly, perform one meaningful action, and make the important assertions easy to find.

Use builders or factory methods for complex request objects, but do not hide the detail that makes a scenario meaningful. A builder can provide a valid default address while allowing a test to change the postcode, customer ID, or delivery option. Keep test data representative of production without copying real customer records. Australian addresses, local phone formats, and daylight-saving boundaries can expose assumptions that generic sample data misses.

A practical checklist for each new endpoint includes:

Review integration tests when the API contract changes. A renamed response field, new privacy requirement, altered retry policy, or migration can invalidate assumptions even when application code still compiles. Remove tests that merely repeat unit-level implementation details, and add scenarios for incidents, support tickets, and regulatory changes that affect how customer information is collected or retained.

Start with one important endpoint rather than attempting to test the entire API in a single sprint. Create a real test host, exercise a request from authentication through persistence, and make the failure output useful. Then extend the pattern to the paths that handle money, identity, customer data, and external commitments. That approach turns integration testing into a dependable part of .NET delivery rather than a slow suite nobody trusts.