Integration Testing
This document explains the HostTestFixture<TProgram> class for writing integration tests in Functorium projects.
Introduction
Section titled “Introduction”“How do you verify that services registered in the DI container are actually resolved correctly?”
“How do you confirm that Options binding matches appsettings.json exactly?”
“What is needed to reproduce the full pipeline of a Host project in a test environment?”
Unit tests verify the behavior of individual classes, but areas where multiple layers are combined — such as DI registration, configuration binding, and HTTP pipelines — can only be verified through integration tests. HostTestFixture<TProgram> wraps WebApplicationFactory to enable concise writing of such integration tests.
What You Will Learn
Section titled “What You Will Learn”This document covers the following topics:
- Structure and lifecycle of
HostTestFixture<TProgram>- Flow from initialization to cleanup - Service registration verification patterns - How to check DI container and Options binding
- Environment-specific configuration file setup -
appsettings.{Environment}.jsonload order and overrides - HTTP API integration testing - Endpoint verification via
HttpClient - Extension point usage -
ConfigureHost,InitializeAsyncoverrides
Prerequisites
Section titled “Prerequisites”A basic understanding of the following concepts is needed to understand this document:
- Unit testing guide - Test naming conventions, AAA pattern
- ASP.NET Core DI (Dependency Injection) concepts
IClassFixtureand xUnit lifecycle
Core principle:
HostTestFixture<TProgram>reproduces the actual Host project’s DI container and configuration pipeline as-is in the test environment. Service registration, Options binding, and HTTP endpoints can be verified with a single Fixture.
Summary
Section titled “Summary”Key Code
Section titled “Key Code”Basic test Fixture definition:
public class MyTestFixture : HostTestFixture<Program>{ protected override string EnvironmentName => "Test";}Writing a test class:
public class MyIntegrationTests : IClassFixture<MyTestFixture>{ private readonly MyTestFixture _fixture;
public MyIntegrationTests(MyTestFixture fixture) { _fixture = fixture; }
[Fact] public void Service_ShouldBeRegistered() { var service = _fixture.Services.GetService<IMyService>(); service.ShouldNotBeNull(); }}Key Concepts
Section titled “Key Concepts”| Concept | Description |
|---|---|
HostTestFixture<TProgram> | Base Fixture for host integration testing (TProgram : class) |
EnvironmentName | Environment name to load (default: "Test") |
Services | DI container (IServiceProvider) |
Client | HttpClient for HTTP requests |
ConfigureHost | Host additional configuration extension point (empty virtual method) |
GetTestProjectPath | Test project path (3 levels up from AppContext.BaseDirectory) |
Test Writing Rules
Section titled “Test Writing Rules”When writing integration tests, basic test naming conventions, variable naming conventions, AAA pattern, etc. follow the unit testing guide.
| Rule | Reference |
|---|---|
| Test naming (T1_T2_T3) | Test naming conventions |
Variable naming (sut, actual, etc.) | Variable naming conventions |
| AAA pattern | AAA pattern |
HostTestFixture Structure
Section titled “HostTestFixture Structure”Class Definition
Section titled “Class Definition”Source location: Src/Functorium.Testing/Arrangements/Hosting/HostTestFixture.cs
public class HostTestFixture<TProgram> : IAsyncDisposable, IAsyncLifetime where TProgram : class{ private WebApplicationFactory<TProgram>? _factory;
protected virtual string EnvironmentName => "Test";
public IServiceProvider Services => _factory?.Services ?? throw new InvalidOperationException("Fixture not initialized");
public HttpClient Client { get; private set; } = null!;}Lifecycle
Section titled “Lifecycle”IClassFixture<T> applied ↓InitializeAsync() called (ValueTask) ↓WebApplicationFactory created ↓UseEnvironment(EnvironmentName) ↓UseContentRoot(GetTestProjectPath()) ↓ConfigureHost(builder) called ↓CreateClient() - app starts ↓Tests execute ↓DisposeAsync() - HttpClient, WebApplicationFactory cleanupConfiguration File Load Order
Section titled “Configuration File Load Order”1. TProgram project's appsettings.json (default settings)2. Test project's appsettings.json (overrides)3. Test project's appsettings.{EnvironmentName}.json (merged)Now that we understand the Fixture’s structure and lifecycle, let us write actual test code.
Writing Tests
Section titled “Writing Tests”Basic Structure
Section titled “Basic Structure”Note the pattern of implementing IClassFixture<T> and receiving the Fixture via constructor injection.
using Functorium.Testing.Arrangements.Hosting;
namespace MyProject.Tests.Integration;
[Trait(nameof(IntegrationTest), IntegrationTest.Category)]public class MyServiceIntegrationTests : IClassFixture<MyServiceIntegrationTests.MyTestFixture>{ private readonly MyTestFixture _fixture;
public MyServiceIntegrationTests(MyTestFixture fixture) { _fixture = fixture; }
[Fact] public void Host_ShouldStartSuccessfully() { _fixture.Services.ShouldNotBeNull(); }
[Fact] public void MyService_ShouldBeRegistered() { var service = _fixture.Services.GetService<IMyService>(); service.ShouldNotBeNull(); }
[Fact] public void MyOptions_ShouldBeValidatedAndBound() { var options = _fixture.Services .GetRequiredService<IOptionsMonitor<MyOptions>>() .CurrentValue;
options.ShouldNotBeNull(); options.PropertyName.ShouldBe("ExpectedValue"); }
// Fixture definition (nested class) public class MyTestFixture : HostTestFixture<Program> { protected override string EnvironmentName => "MyTest"; }}Service Verification Patterns
Section titled “Service Verification Patterns”DI registration check:
[Fact]public void Service_ShouldBeRegistered(){ var service = _fixture.Services.GetService<IMyService>(); service.ShouldNotBeNull();}Options binding check:
[Fact]public void Options_ShouldBeBound(){ var options = _fixture.Services .GetRequiredService<IOptionsMonitor<MyOptions>>() .CurrentValue;
options.PropertyA.ShouldBe("ExpectedA"); options.PropertyB.ShouldBe(123);}HTTP API Testing
Section titled “HTTP API Testing”[Fact]public async Task GetEndpoint_ShouldReturnSuccess(){ var response = await _fixture.Client.GetAsync("/api/health"); response.StatusCode.ShouldBe(HttpStatusCode.OK);}
[Fact]public async Task PostEndpoint_ShouldCreateResource(){ var content = new StringContent( JsonSerializer.Serialize(new { Name = "Test" }), Encoding.UTF8, "application/json");
var response = await _fixture.Client.PostAsync("/api/items", content); response.StatusCode.ShouldBe(HttpStatusCode.Created);}Now that we have learned the test writing patterns, let us examine how to apply different configurations per test environment.
Environment-Specific Configuration
Section titled “Environment-Specific Configuration”Configuration File Structure
Section titled “Configuration File Structure”Tests/MyProject.Tests.Integration/├── appsettings.json # Default settings (valid values for all Options)├── appsettings.MyTest.json # MyTest environment (per-test overrides)└── appsettings.AnotherTest.json # AnotherTest environmentappsettings.json (Default)
Section titled “appsettings.json (Default)”Set valid default values for all Options:
{ "OpenTelemetry": { "ServiceName": "MyProject.Tests.Integration", "ServiceNamespace": "MyProject", "CollectorEndpoint": "http://127.0.0.1:18889", "CollectorProtocol": "Grpc", "SamplingRate": 1.0, "EnablePrometheusExporter": false, "TracingEndpoint": "", "MetricsEndpoint": "", "LoggingEndpoint": "" }, "AllowedHosts": "*"}Note:
ServiceName,ServiceNamespace, andCollectorEndpointin theOpenTelemetrysettings are required fields.
appsettings.{Environment}.json (Override)
Section titled “appsettings.{Environment}.json (Override)”Override only the settings needed for the test:
{ "Ftp": { "Host": "ftp.test.local", "Port": 2121, "UseTls": true }}csproj Configuration
Section titled “csproj Configuration”<ItemGroup> <Content Include="appsettings.json"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </Content> <Content Include="appsettings.MyTest.json"> <DependentUpon>appsettings.json</DependentUpon> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </Content></ItemGroup>Specifying Environment in Fixture
Section titled “Specifying Environment in Fixture”public class FtpTestFixture : HostTestFixture<Program>{ // Loads appsettings.FtpTest.json protected override string EnvironmentName => "FtpTest";}Precautions When Referencing Host Projects
Section titled “Precautions When Referencing Host Projects”When referencing host projects from integration test projects, add ExcludeAssets=analyzers to prevent duplicate SourceGenerator execution:
<ProjectReference Include="..\..\Src\MyHost\MyHost.csproj" ExcludeAssets="analyzers" />When the default configuration is insufficient, you can customize Host behavior through the Fixture’s extension points.
Extension Points
Section titled “Extension Points”ConfigureHost Override
Section titled “ConfigureHost Override”When additional Host configuration is needed, override ConfigureHost to replace services or add configuration.
public class CustomTestFixture : HostTestFixture<Program>{ protected override string EnvironmentName => "CustomTest";
protected override void ConfigureHost(IWebHostBuilder builder) { builder.ConfigureServices(services => { // Replace with test service services.AddSingleton<IExternalService, MockExternalService>(); });
builder.ConfigureAppConfiguration((context, config) => { // Additional configuration source config.AddInMemoryCollection(new Dictionary<string, string?> { ["Custom:Setting"] = "TestValue" }); }); }}GetTestProjectPath Override
Section titled “GetTestProjectPath Override”When the test project path differs:
public class CustomPathFixture : HostTestFixture<Program>{ protected override string GetTestProjectPath() { // Default: 3 levels up from AppContext.BaseDirectory (bin/Debug/net10.0) var baseDirectory = AppContext.BaseDirectory; return Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "..")); }}InitializeAsync Override
Section titled “InitializeAsync Override”Adding initialization logic:
public class ExtendedTestFixture : HostTestFixture<Program>{ public ILogger TestLogger { get; private set; } = null!;
public override async ValueTask InitializeAsync() { await base.InitializeAsync();
// Additional initialization TestLogger = Services.GetRequiredService<ILogger<ExtendedTestFixture>>(); }}Troubleshooting
Section titled “Troubleshooting”Options Validation Failure
Section titled “Options Validation Failure”Symptom:
OptionsValidationException: Option Validation failed for 'MyOptions.Property': Property is required.Cause: The default appsettings.json does not contain valid values for the corresponding Options.
Resolution:
// Add valid default values to appsettings.json{ "MyOptions": { "Property": "ValidDefaultValue" }}Fixture Initialization Failure
Section titled “Fixture Initialization Failure”Symptom:
InvalidOperationException: Fixture not initializedCause: Services was accessed before InitializeAsync completed.
Resolution: Verify that IClassFixture is correctly implemented:
public class MyTests : IClassFixture<MyTestFixture>{ private readonly MyTestFixture _fixture;
public MyTests(MyTestFixture fixture) { _fixture = fixture; }}Configuration File Not Loaded
Section titled “Configuration File Not Loaded”Symptom: Options values are set to defaults.
Cause:
CopyToOutputDirectorynot set in csprojEnvironmentNamedoes not match the filename
Resolution:
<!-- csproj --><Content Include="appsettings.MyTest.json"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory></Content>// Fixture - Verify EnvironmentName matches filenameprotected override string EnvironmentName => "MyTest"; // → appsettings.MyTest.jsonOTLP Connection Failure
Section titled “OTLP Connection Failure”Symptom:
Grpc.Core.RpcException: Error starting gRPC callCause: The test environment attempts to connect to the actual OTLP endpoint.
Resolution: Disable by setting individual endpoints to empty strings:
{ "OpenTelemetry": { "TracingEndpoint": "", "MetricsEndpoint": "", "LoggingEndpoint": "" }}Seq Serialization Error
Section titled “Seq Serialization Error”Symptom: Deserialization of responses with System.Text.Json fails for Seq<T> types.
Resolution: Use List<T> instead of Seq<T> in test DTOs.
SourceGenerator Duplicate Error
Section titled “SourceGenerator Duplicate Error”Symptom: Mediator SourceGenerator and others run in duplicate across host and test projects.
Resolution: Add ExcludeAssets=analyzers to the host project reference:
<ProjectReference Include="..\..\Src\MyHost\MyHost.csproj" ExcludeAssets="analyzers" />Q1. What is the difference between HostTestFixture and WebApplicationFactory?
Section titled “Q1. What is the difference between HostTestFixture and WebApplicationFactory?”HostTestFixture wraps WebApplicationFactory and provides the following:
| Feature | WebApplicationFactory | HostTestFixture |
|---|---|---|
| Environment configuration | Manual setup | EnvironmentName property |
| ContentRoot | Manual setup | Auto-calculated (GetTestProjectPath) |
| Extension points | WithWebHostBuilder | ConfigureHost method |
| Lifecycle | Manual management | IAsyncLifetime implementation |
Q2. How do I use different environments per test?
Section titled “Q2. How do I use different environments per test?”Define a separate Fixture for each test class:
public class FtpTests : IClassFixture<FtpTests.FtpTestFixture>{ public class FtpTestFixture : HostTestFixture<Program> { protected override string EnvironmentName => "FtpTest"; }}
public class OtelTests : IClassFixture<OtelTests.OtelTestFixture>{ public class OtelTestFixture : HostTestFixture<Program> { protected override string EnvironmentName => "OpenTelemetryTest"; }}Q3. How do I inject mock services?
Section titled “Q3. How do I inject mock services?”Override ConfigureHost to replace services:
public class MockTestFixture : HostTestFixture<Program>{ protected override void ConfigureHost(IWebHostBuilder builder) { builder.ConfigureServices(services => { var descriptor = services.SingleOrDefault( d => d.ServiceType == typeof(IExternalService)); if (descriptor != null) services.Remove(descriptor);
services.AddSingleton<IExternalService, MockExternalService>(); }); }}Q4. Can the same Fixture be shared across multiple test classes?
Section titled “Q4. Can the same Fixture be shared across multiple test classes?”Yes, separate the Fixture class into its own file:
public class SharedTestFixture : HostTestFixture<Program>{ protected override string EnvironmentName => "Test";}
// TestA.cspublic class TestA : IClassFixture<SharedTestFixture> { }
// TestB.cspublic class TestB : IClassFixture<SharedTestFixture> { }Using xUnit Collection Fixture, multiple test classes can share a single host instance:
[CollectionDefinition("IntegrationTests")]public class IntegrationTestCollection : ICollectionFixture<SharedTestFixture> { }
[Collection("IntegrationTests")]public class TestA { }
[Collection("IntegrationTests")]public class TestB { }Q5. What is the BaseAddress when testing APIs with HttpClient?
Section titled “Q5. What is the BaseAddress when testing APIs with HttpClient?”The BaseAddress of HostTestFixture.Client is the test server’s address. Use relative paths for requests:
// Correct usagevar response = await _fixture.Client.GetAsync("/api/health");
// Absolute URL unnecessary// var response = await _fixture.Client.GetAsync("http://localhost/api/health");References
Section titled “References”- Unit testing guide - Test naming conventions, AAA pattern, and other basic test writing rules
- Testing library - Functorium.Testing library guide
- Microsoft.AspNetCore.Mvc.Testing
- xUnit Class Fixtures