Overview
Overriding services and configuration for tests means changing the application's dependency injection registrations, configuration values, environment name, authentication behavior, database provider, external integrations, or options objects during automated tests.
In .NET and ASP.NET Core applications, production code is usually built around dependency injection and layered configuration. The same application may read from appsettings.json, appsettings.Development.json, environment variables, user secrets, Azure Key Vault, Azure App Configuration, command-line arguments, and in-memory configuration. Services may include repositories, EF Core DbContext, HTTP clients, message publishers, background workers, payment gateways, email senders, authentication handlers, caches, clocks, and file storage clients.
Tests often need different behavior from production:
- use a test database instead of a production database
- replace an email sender with a fake implementation
- replace payment or notification integrations with test doubles
- disable real background jobs
- use deterministic time, IDs, and feature flags
- configure fake authentication for protected endpoints
- use test-specific connection strings and options
- avoid network calls to real external systems
This topic matters because many real-world .NET bugs happen at integration boundaries: dependency injection registration, configuration binding, service lifetime mismatch, authentication setup, EF Core provider differences, or environment-specific behavior. A candidate who understands how to override services and configuration can write tests that are fast, reliable, safe, and realistic.
It is important for interviews because it shows practical experience with ASP.NET Core integration testing, WebApplicationFactory, dependency injection, options pattern, EF Core testing, configuration providers, and test isolation. Interviewers often ask this topic to separate developers who only know isolated unit tests from developers who can test real application behavior safely.
Core Concepts
Why Tests Override Services and Configuration
Tests should exercise the behavior that matters while controlling dependencies that make tests slow, flaky, unsafe, or hard to reproduce.
Common examples include:
- replacing a real SMTP email sender with an in-memory fake
- replacing a real payment gateway with a deterministic stub
- replacing an external HTTP API client with a fake client or mock server
- replacing a production database connection with SQLite, a containerized database, or an isolated test database
- replacing real authentication with a test authentication scheme
- overriding feature flags to test both enabled and disabled paths
- overriding timeout, retry, and circuit-breaker settings to keep tests fast
- overriding file storage paths to use temporary directories
The goal is not to fake everything. The goal is to choose the right test boundary.
For a unit test, replacing most dependencies is normal because the test focuses on one class.
For an integration test, replacing every dependency can hide real configuration and integration problems. A good integration test usually keeps the real application pipeline, routing, model binding, filters, validation, middleware, dependency injection, and serialization, while replacing only unsafe or expensive external dependencies.
Unit Tests vs Integration Tests
A unit test usually creates the class under test directly and passes fake dependencies to its constructor.
var fakeEmailSender = new FakeEmailSender();
var service = new UserRegistrationService(fakeEmailSender);
await service.RegisterAsync(newUser);
Assert.Single(fakeEmailSender.SentMessages);
An integration test usually starts a real test host and sends HTTP requests through the application pipeline.
public class UsersApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public UsersApiTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public async Task Get_User_ReturnsSuccess()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/api/users/1");
response.EnsureSuccessStatusCode();
}
}
The second test is more realistic because it uses routing, middleware, dependency injection, filters, serialization, and the actual HTTP request-response pipeline.
WebApplicationFactory and TestServer
WebApplicationFactory<TEntryPoint> is the common ASP.NET Core testing type used to bootstrap an application in memory for integration tests.
Typical responsibilities include:
- starting the application with a test host
- creating an
HttpClientthat sends requests to the in-memory app - using the real application entry point, usually
Program - allowing test-specific service and configuration overrides
- supporting custom factories shared across many tests
For minimal hosting projects, the test project must be able to access the application entry point. A common approach is to add a public partial Program class in the web project.
var builder = WebApplication.CreateBuilder(args);
// Register services and endpoints here.
var app = builder.Build();
app.MapControllers();
app.Run();
public partial class Program { }
This makes WebApplicationFactory<Program> usable from the test project.
Custom Test Factory
A custom factory centralizes common test setup.
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
public sealed class CustomWebApplicationFactory
: WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.ConfigureAppConfiguration((context, config) =>
{
var testSettings = new Dictionary<string, string?>
{
["FeatureFlags:UseNewCheckout"] = "true",
["ExternalApis:Payments:BaseUrl"] = "https://localhost/fake-payments",
["Email:Enabled"] = "false"
};
config.AddInMemoryCollection(testSettings);
});
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IEmailSender>();
services.AddSingleton<IEmailSender, FakeEmailSender>();
services.RemoveAll<IPaymentGateway>();
services.AddScoped<IPaymentGateway, FakePaymentGateway>();
});
}
}
This is useful when most tests need the same fake services, test environment, and configuration values.
Per-Test Overrides with WithWebHostBuilder
Sometimes a specific test needs a different service or configuration from the default test factory. WithWebHostBuilder creates a modified factory for that test.
[Fact]
public async Task Checkout_WhenPaymentFails_ReturnsBadRequest()
{
var client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IPaymentGateway>();
services.AddScoped<IPaymentGateway, FailingPaymentGateway>();
});
})
.CreateClient();
var response = await client.PostAsJsonAsync("/api/checkout", new
{
ProductId = 10,
Quantity = 1
});
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
This pattern keeps tests isolated. A failure-specific fake payment gateway is used only by that test, not by the entire suite.
ConfigureServices vs ConfigureTestServices
ConfigureServices is a general host builder hook that can add or replace services during host configuration.
ConfigureTestServices is specifically designed for tests and runs after the application has registered its normal services. This makes it convenient for replacing production registrations.
builder.ConfigureTestServices(services =>
{
services.RemoveAll<INotificationSender>();
services.AddSingleton<INotificationSender, FakeNotificationSender>();
});
In interviews, the important idea is that service registration order matters. If multiple registrations exist for the same service type, resolving a single service normally returns the last registration, while resolving IEnumerable<TService> returns all registrations. To avoid ambiguity, tests commonly remove the old registration before adding the replacement.
Removing Existing Service Registrations
When overriding a dependency, do not blindly add another registration if the application may still resolve the old one.
Prefer explicit replacement:
using Microsoft.Extensions.DependencyInjection.Extensions;
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IEmailSender>();
services.AddSingleton<IEmailSender, FakeEmailSender>();
});
For simple services, RemoveAll<TService>() is usually clear.
For more complex cases, such as EF Core or named HTTP clients, you may need to remove specific descriptors.
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (descriptor is not null)
{
services.Remove(descriptor);
}
The key habit is to inspect how the service is registered in production before replacing it in tests.
Overriding EF Core DbContext
A common integration testing requirement is replacing the production database with a test database.
A realistic option is SQLite in-memory with an open connection:
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using System.Data.Common;
builder.ConfigureTestServices(services =>
{
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.RemoveAll<DbConnection>();
services.AddSingleton<DbConnection>(_ =>
{
var connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
return connection;
});
services.AddDbContext<AppDbContext>((serviceProvider, options) =>
{
var connection = serviceProvider.GetRequiredService<DbConnection>();
options.UseSqlite(connection);
});
});
The connection is registered as a singleton and kept open so that the SQLite in-memory database remains alive for the duration of the test host.
Test setup can then create the schema and seed data:
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.EnsureCreatedAsync();
db.Users.Add(new User
{
Id = 1,
Email = "[email protected]",
DisplayName = "Admin"
});
await db.SaveChangesAsync();
Important trade-offs:
- EF Core InMemory is fast but does not behave like a relational database.
- SQLite in-memory is closer to relational behavior but still differs from SQL Server.
- Testcontainers or a real test database gives the most realistic result but is slower and needs more infrastructure.
- A shared database can cause test pollution unless data is isolated carefully.
Overriding Configuration with In-Memory Values
ASP.NET Core configuration is built from multiple providers. Later providers can override earlier providers for the same key. Tests can add an in-memory provider to override settings safely.
builder.ConfigureAppConfiguration((context, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:Default"] = "DataSource=:memory:",
["Features:EnableAuditLogs"] = "false",
["ExternalApis:Inventory:TimeoutSeconds"] = "1"
});
});
This is useful for:
- feature flags
- connection strings
- fake API URLs
- retry settings
- timeout settings
- authentication settings
- background worker settings
Configuration keys use colon-separated paths. For example, this JSON:
{
"ExternalApis": {
"Inventory": {
"TimeoutSeconds": 10
}
}
}
can be overridden with:
["ExternalApis:Inventory:TimeoutSeconds"] = "1"
Overriding the Environment
Some application behavior changes based on environment name. Tests can set a dedicated environment such as Testing.
builder.UseEnvironment("Testing");
Application code can then load environment-specific settings:
// appsettings.Testing.json
{
"Email": {
"Enabled": false
}
}
However, tests should not rely too heavily on hidden environment-specific behavior. It is usually clearer to override important values directly in the test factory.
Good habits:
- use
Testingas a clear environment name - avoid using the real
Developmentenvironment if tests need different behavior - do not accidentally load production secrets or production connection strings
- make test-critical configuration explicit in the test setup
Overriding Options
Many .NET applications bind configuration to strongly typed options classes.
public sealed class PaymentOptions
{
public string BaseUrl { get; set; } = "";
public int TimeoutSeconds { get; set; }
}
Production registration might look like this:
builder.Services
.AddOptions<PaymentOptions>()
.Bind(builder.Configuration.GetSection("Payment"))
.ValidateDataAnnotations()
.ValidateOnStart();
The most realistic test override is often to override the underlying configuration:
builder.ConfigureAppConfiguration((context, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["Payment:BaseUrl"] = "https://localhost/fake-payment",
["Payment:TimeoutSeconds"] = "1"
});
});
For a narrower unit or component test, you can inject IOptions<T> directly:
var options = Options.Create(new PaymentOptions
{
BaseUrl = "https://localhost/fake-payment",
TimeoutSeconds = 1
});
var gateway = new PaymentGateway(options, httpClient);
Important distinction:
- Override configuration when testing the real app startup and options binding.
- Use
Options.Createwhen testing a class directly. - Do not bypass options validation in integration tests if validation is part of production startup behavior.
Overriding Authentication and Authorization
Protected endpoints often require authentication. Integration tests should not usually call the real identity provider. Instead, tests can register a fake authentication handler.
public sealed class TestAuthHandler
: AuthenticationHandler<AuthenticationSchemeOptions>
{
public TestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, "test-user-id"),
new Claim(ClaimTypes.Name, "test-user"),
new Claim(ClaimTypes.Role, "Admin")
};
var identity = new ClaimsIdentity(claims, "Test");
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, "Test");
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
Test registration:
builder.ConfigureTestServices(services =>
{
services
.AddAuthentication("Test")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
"Test",
options => { });
});
The test application must also use the same default authentication scheme for the request. This can be done through test-specific configuration or test-specific service setup depending on how authentication is configured in the application.
Good interview answer:
- Do not disable authorization globally if the purpose is to test protected endpoints.
- Prefer fake authentication with realistic claims.
- Test role, policy, and claim behavior explicitly.
- Keep a small number of end-to-end tests against the real identity provider if that integration is critical.
Overriding HttpClient and External API Clients
Applications often call external APIs through typed clients, named clients, generated clients, or custom gateway interfaces.
A clean design wraps external calls behind an interface:
public interface IInventoryClient
{
Task<bool> IsAvailableAsync(int productId, CancellationToken cancellationToken);
}
Tests can replace the interface:
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IInventoryClient>();
services.AddSingleton<IInventoryClient>(new FakeInventoryClient
{
IsAvailable = true
});
});
For more realistic HTTP-level tests, use a mock HTTP handler or local test server rather than replacing the whole client. This can catch problems in serialization, headers, URLs, and status code handling.
Trade-off:
- Replacing the interface is simple and fast.
- Mocking HTTP responses is more realistic for HTTP client code.
- Calling the real external service is slow, flaky, and unsafe for normal integration tests.
Overriding Background Services
Background services can make integration tests unpredictable because they may start running as soon as the host starts.
Examples include:
- queue consumers
- scheduled jobs
- cache warmers
- outbox dispatchers
- long-running polling services
A common strategy is to disable or replace them in tests.
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IHostedService>();
services.AddSingleton<IHostedService, NoOpHostedService>();
});
This removes all hosted services, which may be too broad. A safer approach is to remove only a specific implementation when possible.
var descriptor = services.SingleOrDefault(d =>
d.ImplementationType == typeof(OrderOutboxWorker));
if (descriptor is not null)
{
services.Remove(descriptor);
}
Best practice is to design background services around small injectable components. Then unit test the component logic directly and integration test only the hosted-service wiring when needed.
Overriding Time, IDs, and Randomness
Tests are more reliable when time, IDs, and random values are deterministic.
Instead of calling DateTime.UtcNow, Guid.NewGuid(), or Random.Shared everywhere, production code can depend on abstractions.
public interface IClock
{
DateTimeOffset UtcNow { get; }
}
public sealed class SystemClock : IClock
{
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}
public sealed class FakeClock : IClock
{
public DateTimeOffset UtcNow { get; set; }
}
Test override:
var fakeClock = new FakeClock
{
UtcNow = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)
};
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IClock>();
services.AddSingleton<IClock>(fakeClock);
});
This makes expiration, scheduling, audit fields, and date filtering easier to test.
Service Lifetimes in Test Overrides
When replacing services, match the production lifetime unless there is a deliberate reason not to.
Common lifetimes:
Transient: new instance each time it is requestedScoped: one instance per request scope in ASP.NET CoreSingleton: one instance for the whole application host
Bad lifetime choices can create bugs:
// Risky: singleton fake captures per-test mutable state and leaks across tests.
services.AddSingleton<IFakeStateStore, FakeStateStore>();
A singleton fake can be useful when the test needs to inspect state after an HTTP request, but it must be reset between tests or scoped to a fresh factory.
Better pattern for shared fake state:
public sealed class EmailSink
{
public List<EmailMessage> Messages { get; } = new();
}
builder.ConfigureTestServices(services =>
{
services.AddSingleton<EmailSink>();
services.RemoveAll<IEmailSender>();
services.AddSingleton<IEmailSender, FakeEmailSender>();
});
Then the test can resolve EmailSink from the factory services and inspect what was sent.
Test Isolation and Shared State
A frequent mistake is sharing one test host, one database, or one fake state object across many tests without resetting it.
Problems include:
- tests pass individually but fail when run together
- test order affects results
- parallel execution causes race conditions
- fake state leaks across tests
- database rows from one test affect another test
Safer strategies include:
- create a new factory per test when isolation is critical
- use unique database names per test
- reset database state before each test
- use transactions when the database provider supports rollback
- disable parallelization only when necessary
- avoid mutable static state in fakes
- keep fake objects thread-safe if tests run in parallel
Example unique database name:
var databaseName = $"TestDb_{Guid.NewGuid():N}";
services.AddDbContext<AppDbContext>(options =>
{
options.UseInMemoryDatabase(databaseName);
});
Configuration Provider Order
Configuration provider order matters. If the same key is provided by multiple sources, the provider added later generally wins.
For tests, adding an in-memory provider near the end is useful because it can override earlier JSON files or environment-specific settings.
builder.ConfigureAppConfiguration((context, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["Features:EnablePayments"] = "false"
});
});
Common mistake:
Environment.SetEnvironmentVariable("Features__EnablePayments", "false");
This can work, but it is less isolated because environment variables are process-wide. If tests run in parallel, one test may affect another.
Prefer AddInMemoryCollection for test-specific settings.
Avoiding Production Resource Access
Tests must never accidentally call production services or production databases.
Safety habits:
- use a dedicated
Testingenvironment - fail fast if a production connection string is detected in tests
- use fake external integrations by default
- use test-specific secrets only when required
- keep test settings outside production configuration
- make destructive tests run only against isolated resources
- avoid using real cloud resources unless the test is explicitly an end-to-end or smoke test
Example guard:
if (connectionString.Contains("prod", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
"Tests must not use a production database connection string.");
}
Choosing What to Override
A good test strategy does not override everything.
Use this decision model:
The best interview answer explains not only how to override services, but also why and where to draw the boundary.
Common Mistakes
Common mistakes include:
- adding a fake service without removing the production registration
- accidentally using production connection strings in tests
- overriding too much and turning integration tests into shallow unit tests
- mocking EF Core
DbSetinstead of testing repository/query behavior with a real provider - using EF Core InMemory and assuming it behaves like SQL Server
- disabling authorization instead of testing policies with fake authenticated users
- using process-wide environment variables in parallel tests
- sharing mutable fake state across tests without resetting it
- ignoring service lifetimes when replacing dependencies
- testing only happy paths and never testing configuration failure
- bypassing options validation in tests even though production uses it
- letting background services run unintentionally during tests
Best Practices
Good habits include:
- keep unit tests and integration tests in separate projects or clearly separated folders
- use
WebApplicationFactory<Program>for ASP.NET Core integration tests - centralize common test setup in a custom factory
- use
WithWebHostBuilderfor per-test overrides - use
ConfigureTestServicesto replace services after production registration - remove old registrations before adding replacements
- prefer in-memory configuration overrides over process-wide environment variables
- use a dedicated
Testingenvironment - keep real middleware, routing, filters, serialization, and model validation in integration tests
- use realistic database testing when persistence behavior matters
- fake external side-effect systems such as email, payments, SMS, and third-party APIs
- keep fake services simple, deterministic, and inspectable
- reset state between tests
- protect against accidental production resource access
- test both success and failure configuration scenarios