Skip to content

Integration Testing in C# — Complete Guide

DodaTech Updated 2026-06-28 6 min read

In this tutorial, you will learn about Integration Testing in C#. We cover key concepts, practical examples, and best practices to help you master this topic.

Hook

Unit tests verify individual components in isolation, but integration tests confirm that your application works as a whole. C# developers use WebApplicationFactory to spin up real HTTP servers in tests, execute requests, and verify the complete request-response cycle against databases and external services.

Learning Path

graph LR
  A[Integration Testing] --> B[WebApplicationFactory]
  A --> C[TestContainers]
  B --> D[Custom Web Host]
  B --> E[In-Memory DB]
  C --> F[Docker Dependencies]
  style A fill:#4a90d9,color:#fff
  style B fill:#4a90d9,color:#fff
  style C fill:#4a90d9,color:#fff
  style D fill:#4a90d9,color:#fff
  style E fill:#4a90d9,color:#fff
  style F fill:#4a90d9,color:#fff

WebApplicationFactory

The WebApplicationFactory class creates a test server that hosts your application in memory.

// Install: dotnet add package Microsoft.AspNetCore.Mvc.Testing

using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;

public class BasicIntegrationTests
    : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;

    public BasicIntegrationTests(WebApplicationFactory<Program> factory)
    {
        _factory = factory;
    }

    [Fact]
    public async Task Get_Endpoint_ReturnsSuccess()
    {
        // Arrange
        var client = _factory.CreateClient();

        // Act
        var response = await client.GetAsync("/api/books");

        // Assert
        response.EnsureSuccessStatusCode();
        Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType);
    }

    [Fact]
    public async Task Get_Endpoint_ReturnsExpectedData()
    {
        var client = _factory.CreateClient();

        var response = await client.GetAsync("/api/books/1");
        var content = await response.Content.ReadAsStringAsync();

        Assert.Contains("Laptop", content);
    }
}

Customizing the Web Host

Override services in the test to use test-specific implementations.

public class CustomWebApplicationFactory
    : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            // Remove the real DbContext registration
            var descriptor = services.SingleOrDefault(
                d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));

            if (descriptor != null)
                services.Remove(descriptor);

            // Add in-memory database for testing
            services.AddDbContext<AppDbContext>(options =>
            {
                options.UseInMemoryDatabase("TestDb");
            });

            // Remove real email service
            services.RemoveAll<IEmailService>();
            services.AddScoped<IEmailService, MockEmailService>();
        });

        builder.UseEnvironment("Testing");
    }
}

public class MockEmailService : IEmailService
{
    public List<(string To, string Subject)> SentEmails { get; } = new();

    public Task SendEmailAsync(string to, string subject, string body)
    {
        SentEmails.Add((to, subject));
        return Task.CompletedTask;
    }
}

Integration Test with Database

Test the full stack with an in-memory database.

public class BookApiTests : IClassFixture<CustomWebApplicationFactory>
{
    private readonly HttpClient _client;
    private readonly CustomWebApplicationFactory _factory;

    public BookApiTests(CustomWebApplicationFactory factory)
    {
        _factory = factory;
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task CreateBook_ShouldPersistAndReturnCreated()
    {
        var newBook = new { Title = "Integration Testing with C#", Author = "Jane Doe", Price = 39.99m };
        var json = JsonSerializer.Serialize(newBook);
        var content = new StringContent(json, Encoding.UTF8, "application/json");

        var response = await _client.PostAsync("/api/books", content);

        Assert.Equal(HttpStatusCode.Created, response.StatusCode);

        var responseBody = await response.Content.ReadAsStringAsync();
        var created = JsonSerializer.Deserialize<Book>(responseBody,
            new JsonSerializerOptions { PropertyNameCaseInsensitive = true });

        Assert.NotNull(created);
        Assert.Equal("Integration Testing with C#", created.Title);
        Assert.True(created.Id > 0);

        // Verify it was actually persisted
        var getResponse = await _client.GetAsync($"/api/books/{created.Id}");
        Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
    }

    [Fact]
    public async Task GetNonExistentBook_ShouldReturn404()
    {
        var response = await _client.GetAsync("/api/books/99999");
        Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
    }
}

TestContainers

For true integration tests, use TestContainers to spin up Docker containers.

// Install: dotnet add package TestContainers.Containers
//         dotnet add package TestContainers.MsSql

using Testcontainers.MsSql;

public class DatabaseIntegrationTests : IAsyncLifetime
{
    private MsSqlContainer _msSqlContainer = null!;

    public async Task InitializeAsync()
    {
        _msSqlContainer = new MsSqlBuilder()
            .WithImage("mcr.microsoft.com/mssql/server:2022-latest")
            .WithPassword("Test_Password123!")
            .Build();

        await _msSqlContainer.StartAsync();
    }

    [Fact]
    public async Task DatabaseConnection_ShouldWork()
    {
        var connectionString = _msSqlContainer.GetConnectionString();
        using var connection = new SqlConnection(connectionString);
        await connection.OpenAsync();

        using var command = connection.CreateCommand();
        command.CommandText = "SELECT 1";
        var result = await command.ExecuteScalarAsync();

        Assert.Equal(1, result);
    }

    public async Task DisposeAsync()
    {
        await _msSqlContainer.DisposeAsync();
    }
}

Testing Authentication

Test endpoints that require authentication.

public class AuthenticatedTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;

    public AuthenticatedTests(WebApplicationFactory<Program> factory)
    {
        _factory = factory;
    }

    private HttpClient CreateAuthenticatedClient()
    {
        var client = _factory.CreateClient();

        // Generate a test JWT token
        var token = GenerateTestToken("admin", new[] { "admin", "user" });
        client.DefaultRequestHeaders.Authorization =
            new AuthenticationHeaderValue("Bearer", token);

        return client;
    }

    private string GenerateTestToken(string username, string[] roles)
    {
        var claims = new List<Claim>
        {
            new(ClaimTypes.Name, username)
        };
        claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));

        var key = new SymmetricSecurityKey(
            Encoding.UTF8.GetBytes("test-secret-key-at-least-16-chars!"));
        var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

        var token = new JwtSecurityToken(
            issuer: "test-issuer",
            audience: "test-audience",
            claims: claims,
            expires: DateTime.Now.AddHours(1),
            signingCredentials: creds);

        return new JwtSecurityTokenHandler().WriteToken(token);
    }

    [Fact]
    public async Task AdminEndpoint_ShouldReturnOk_ForAdminUser()
    {
        var client = CreateAuthenticatedClient();
        var response = await client.GetAsync("/api/admin/dashboard");
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
    }
}

Common Mistakes

  1. Using production databases in tests: Always use a separate database (in-memory, TestContainers, or dedicated test instance). Never point tests at production.

  2. Not cleaning up test data: Use database transactions or EnsureDeleted() between tests to prevent data leakage between test cases.

  3. Ignoring test ordering issues: Tests should be independent and run in any order. Avoid shared mutable state.

  4. Testing through the UI only: Integration tests at the HTTP API level are faster and more reliable than UI-level end-to-end tests.

  5. Missing assertions on response content: Status code checks are not enough. Validate response bodies, headers, and side effects.

Practice Questions

  1. Write an integration test that creates a user, authenticates, and accesses a protected resource.

  2. Create a custom WebApplicationFactory that overrides the authentication handler to always authenticate as a test user.

  3. Write integration tests for a file upload endpoint that verifies file storage and metadata.

  4. Challenge: Build a test suite that uses TestContainers for PostgreSQL and runs Migration scripts before each test class.

FAQ

What is the difference between unit tests and integration tests?

Unit tests test individual components in isolation with mocked dependencies. Integration tests test the interaction between real components, including databases and external services.

How do I manage test databases?

Use EF Core's in-memory provider for speed, or TestContainers for database compatibility. Always recreate the database for each test run.

Can integration tests replace unit tests?

No. Both are needed. Integration tests catch wiring issues but are slower. Unit tests provide fast, focused feedback during development.

How do I handle external API calls in integration tests?

Use WireMock or MockHttpServer to stub external HTTP calls. Alternatively, use a dedicated test environment with sandbox APIs.

What is the performance overhead of WebApplicationFactory?

The factory creates the host once per test class. Individual tests run against the same host for fast execution.

Mini Project: Full API Integration Test Suite

Write comprehensive integration tests for a book management API.

using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using System.Net;
using System.Net.Http.Json;
using Xunit;

public class BookApiIntegrationTests
    : IClassFixture<WebApplicationFactory<Program>>, IAsyncLifetime
{
    private readonly WebApplicationFactory<Program> _factory;
    private readonly HttpClient _client;
    private AppDbContext _context = null!;

    public BookApiIntegrationTests(WebApplicationFactory<Program> factory)
    {
        _factory = factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureServices(services =>
            {
                services.RemoveAll<AppDbContext>();
                services.AddDbContext<AppDbContext>(options =>
                    options.UseInMemoryDatabase($"TestDb_{Guid.NewGuid()}"));
            });
        });

        _client = _factory.CreateClient();
    }

    public async Task InitializeAsync()
    {
        _context = _factory.Services.GetRequiredService<AppDbContext>();
        // Seed data
        _context.Books.Add(new Book { Id = 1, Title = "Test Book", Author = "Author", Price = 10m });
        await _context.SaveChangesAsync();
    }

    [Fact]
    public async Task GetAllBooks_ShouldReturnSeededData()
    {
        var books = await _client.GetFromJsonAsync<List<Book>>("/api/books");
        Assert.NotNull(books);
        Assert.Single(books);
    }

    [Fact]
    public async Task CreateBook_WithInvalidData_ShouldReturn400()
    {
        var response = await _client.PostAsJsonAsync("/api/books", new { });
        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
    }

    [Fact]
    public async Task FullLifecycle_ShouldWork()
    {
        // Create
        var createResponse = await _client.PostAsJsonAsync("/api/books",
            new { Title = "New Book", Author = "Me", Price = 25m });
        var created = await createResponse.Content.ReadFromJsonAsync<Book>();
        Assert.NotNull(created);

        // Read
        var getResponse = await _client.GetAsync($"/api/books/{created.Id}");
        Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);

        // Update
        var updateResponse = await _client.PutAsJsonAsync($"/api/books/{created.Id}",
            new { Id = created.Id, Title = "Updated", Author = "Me", Price = 30m });
        Assert.Equal(HttpStatusCode.NoContent, updateResponse.StatusCode);

        // Delete
        var deleteResponse = await _client.DeleteAsync($"/api/books/{created.Id}");
        Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode);

        // Verify gone
        var getAgain = await _client.GetAsync($"/api/books/{created.Id}");
        Assert.Equal(HttpStatusCode.NotFound, getAgain.StatusCode);
    }

    public async Task DisposeAsync()
    {
        await _context.DisposeAsync();
        _client.Dispose();
    }
}

Integration testing with C# and ASP.NET Core gives you confidence that your entire application works correctly. The WebApplicationFactory makes it easy to spin up test hosts, override dependencies, and verify the complete request lifecycle in your .NET applications.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro