Skip to content

Integration Testing

This document explains the HostTestFixture<TProgram> class for writing integration tests in Functorium projects.

“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.

This document covers the following topics:

  1. Structure and lifecycle of HostTestFixture<TProgram> - Flow from initialization to cleanup
  2. Service registration verification patterns - How to check DI container and Options binding
  3. Environment-specific configuration file setup - appsettings.{Environment}.json load order and overrides
  4. HTTP API integration testing - Endpoint verification via HttpClient
  5. Extension point usage - ConfigureHost, InitializeAsync overrides

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
  • IClassFixture and 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.

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();
}
}
ConceptDescription
HostTestFixture<TProgram>Base Fixture for host integration testing (TProgram : class)
EnvironmentNameEnvironment name to load (default: "Test")
ServicesDI container (IServiceProvider)
ClientHttpClient for HTTP requests
ConfigureHostHost additional configuration extension point (empty virtual method)
GetTestProjectPathTest project path (3 levels up from AppContext.BaseDirectory)

When writing integration tests, basic test naming conventions, variable naming conventions, AAA pattern, etc. follow the unit testing guide.

RuleReference
Test naming (T1_T2_T3)Test naming conventions
Variable naming (sut, actual, etc.)Variable naming conventions
AAA patternAAA pattern

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!;
}
IClassFixture<T> applied
InitializeAsync() called (ValueTask)
WebApplicationFactory created
UseEnvironment(EnvironmentName)
UseContentRoot(GetTestProjectPath())
ConfigureHost(builder) called
CreateClient() - app starts
Tests execute
DisposeAsync() - HttpClient, WebApplicationFactory cleanup
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.

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";
}
}

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);
}
[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.

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 environment

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, and CollectorEndpoint in the OpenTelemetry settings are required fields.

Override only the settings needed for the test:

{
"Ftp": {
"Host": "ftp.test.local",
"Port": 2121,
"UseTls": true
}
}
<ItemGroup>
<Content Include="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="appsettings.MyTest.json">
<DependentUpon>appsettings.json</DependentUpon>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
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.

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"
});
});
}
}

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, "..", "..", "..", ".."));
}
}

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>>();
}
}

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"
}
}

Symptom:

InvalidOperationException: Fixture not initialized

Cause: 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;
}
}

Symptom: Options values are set to defaults.

Cause:

  1. CopyToOutputDirectory not set in csproj
  2. EnvironmentName does not match the filename

Resolution:

<!-- csproj -->
<Content Include="appsettings.MyTest.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
// Fixture - Verify EnvironmentName matches filename
protected override string EnvironmentName => "MyTest"; // → appsettings.MyTest.json

Symptom:

Grpc.Core.RpcException: Error starting gRPC call

Cause: The test environment attempts to connect to the actual OTLP endpoint.

Resolution: Disable by setting individual endpoints to empty strings:

{
"OpenTelemetry": {
"TracingEndpoint": "",
"MetricsEndpoint": "",
"LoggingEndpoint": ""
}
}

Symptom: Deserialization of responses with System.Text.Json fails for Seq<T> types.

Resolution: Use List<T> instead of Seq<T> in test DTOs.

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:

FeatureWebApplicationFactoryHostTestFixture
Environment configurationManual setupEnvironmentName property
ContentRootManual setupAuto-calculated (GetTestProjectPath)
Extension pointsWithWebHostBuilderConfigureHost method
LifecycleManual managementIAsyncLifetime 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";
}
}

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:

Fixtures/SharedTestFixture.cs
public class SharedTestFixture : HostTestFixture<Program>
{
protected override string EnvironmentName => "Test";
}
// TestA.cs
public class TestA : IClassFixture<SharedTestFixture> { }
// TestB.cs
public 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 usage
var response = await _fixture.Client.GetAsync("/api/health");
// Absolute URL unnecessary
// var response = await _fixture.Client.GetAsync("http://localhost/api/health");