DEV_NET_CORE
GET_STARTED
.NETTesting strategy and integration testing

Overriding services and configuration in .NET tests

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.

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

Code
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 HttpClient that 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.

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

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

Code
[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.

Code
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:

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

Code
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:

Code
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:

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

Code
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:

Code
{
  "ExternalApis": {
    "Inventory": {
      "TimeoutSeconds": 10
    }
  }
}

can be overridden with:

Code
["ExternalApis:Inventory:TimeoutSeconds"] = "1"

Overriding the Environment

Some application behavior changes based on environment name. Tests can set a dedicated environment such as Testing.

Code
builder.UseEnvironment("Testing");

Application code can then load environment-specific settings:

Code
// 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 Testing as a clear environment name
  • avoid using the real Development environment 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.

Code
public sealed class PaymentOptions
{
    public string BaseUrl { get; set; } = "";
    public int TimeoutSeconds { get; set; }
}

Production registration might look like this:

Code
builder.Services
    .AddOptions<PaymentOptions>()
    .Bind(builder.Configuration.GetSection("Payment"))
    .ValidateDataAnnotations()
    .ValidateOnStart();

The most realistic test override is often to override the underlying configuration:

Code
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:

Code
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.Create when 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.

Code
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:

Code
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:

Code
public interface IInventoryClient
{
    Task<bool> IsAvailableAsync(int productId, CancellationToken cancellationToken);
}

Tests can replace the interface:

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

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

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

Code
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:

Code
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 requested
  • Scoped: one instance per request scope in ASP.NET Core
  • Singleton: one instance for the whole application host

Bad lifetime choices can create bugs:

Code
// 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:

Code
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:

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

Code
builder.ConfigureAppConfiguration((context, config) =>
{
    config.AddInMemoryCollection(new Dictionary<string, string?>
    {
        ["Features:EnablePayments"] = "false"
    });
});

Common mistake:

Code
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 Testing environment
  • 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:

Code
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:

DependencyUsually override?Reason
DatabaseSometimesUse real provider for higher confidence; use test DB for safety
Email senderYesPrevent real emails
Payment gatewayYesPrevent real charges
External APIsUsuallyAvoid network flakiness and third-party dependency
AuthenticationUsuallyAvoid real identity provider in normal integration tests
Authorization policiesUsually noKeep real policies to test protected behavior
SerializationNoKeep real serialization to catch contract issues
Routing and middlewareNoKeep real pipeline in integration tests
Options bindingUsually noOverride config, but keep real binding and validation
Background servicesOftenAvoid nondeterministic side effects

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 DbSet instead 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 WithWebHostBuilder for per-test overrides
  • use ConfigureTestServices to replace services after production registration
  • remove old registrations before adding replacements
  • prefer in-memory configuration overrides over process-wide environment variables
  • use a dedicated Testing environment
  • 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

Interview Practice

PreviousEF Core InMemory Provider Caveats and When SQLite, Docker Databases, or Testcontainers Are SaferNext UpTest doubles, mocking boundaries, and integration risks in .NET