Dependency Injection – Testable Test Architecture
Consider a page object class whose constructor contains _driver = new ChromeDriver(). It works fine until the team decides to run tests against Firefox. Now every page object needs changing. Then a developer wants to write a unit test for the page object's data-extraction logic – but can't, because constructing the class spins up a real browser. Then the CI pipeline needs a headless driver. Then a junior engineer accidentally introduces a second new ChromeDriver() in a base class and nobody notices until the test run opens two browser windows per test. Each of these problems has the same root cause: the code that uses the WebDriver is also the code that creates it. When a class creates its own dependencies, it owns every detail of how those dependencies work – their configuration, their lifecycle, their implementation. Changing any of that means changing the class.
Dependency injection (DI) is the practice of providing a class with its dependencies from the outside rather than having the class construct them internally. It's one of the most impactful architectural patterns in software development, and it has specific relevance for test automation: it makes framework code testable by itself, enables clean lifecycle management for expensive resources like browser sessions and database connections, and makes swapping real services for lightweight fakes a matter of configuration rather than code surgery. This lesson covers the mechanics of DI with Microsoft.Extensions.DependencyInjection, the standard .NET container: service registration, lifetime selection, scope management, and how to wire a test framework where page objects, API clients, and shared services are injected rather than constructed by hand.
DI is one of those patterns that feels like overhead until the moment you need it – then it feels indispensable. The goal of this lesson is to get you to that moment of clarity before the pain of the alternative becomes your teacher.
The Problem DI Solves
The core problem has a name: tight coupling. When class A creates an instance of class B inside its constructor or methods, A is tightly coupled to B's concrete implementation. A can't be tested without B. A can't be given a different implementation of B without modifying A's source code. Every change to B's constructor signature ripples through every class that creates it.
In test automation this surfaces in predictable ways. A LoginPage class that constructs its own IWebDriver can't be instantiated in a unit test. A test base class that news up a database connection can't be pointed at a different connection string without subclassing or configuration hacks. A reporting helper that creates its own HttpClient can't be switched to a mock endpoint for offline development. The pattern repeats: create-your-own-dependency, lose-flexibility.
New Is Glue
The phrase "new is glue" captures the problem concisely. Every new in a constructor is a hard-coded binding to a specific implementation. Consider two versions of a reporting service:
// Version 1: new is glue. The reporter owns its client.
// Cannot be unit-tested. Cannot use a mock HTTP server. Cannot be configured differently.
public class TestReporter
{
private readonly HttpClient _client;
public TestReporter()
{
// Hardcoded: real HTTP, real base address, real timeout.
// None of this is overridable without modifying this class.
_client = new HttpClient
{
BaseAddress = new Uri("https://reporting.internal.example.com"),
Timeout = TimeSpan.FromSeconds(10)
};
}
public async Task ReportResultAsync(TestResult result) =>
await _client.PostAsJsonAsync("/api/results", result);
}
// Version 2: dependency injected. The reporter uses a client it was given.
// Can be tested with a mock HttpClient. Can be configured via DI registration.
// The class doesn't know or care how its client was constructed.
public class TestReporter
{
private readonly HttpClient _client;
public TestReporter(HttpClient client)
{
_client = client;
}
public async Task ReportResultAsync(TestResult result) =>
await _client.PostAsJsonAsync("/api/results", result);
}
The Dependency Inversion Principle
DI is the mechanism that enables the Dependency Inversion Principle (the D in SOLID): high-level modules should not depend on low-level modules; both should depend on abstractions. In practice, this means page objects depend on IWebDriver, not on ChromeDriver. API clients depend on HttpClient (or an interface wrapping it), not on a specific endpoint. Test data services depend on ITestDataRepository, not on a specific database connection.
The abstraction – the interface – becomes the contract. The DI container wires up which concrete class fulfils that contract in each context. Production tests get ChromeDriver; unit tests get a mock. CI gets headless Edge; local development gets a visible Firefox. The page object code doesn't change between these contexts because it depends on the abstraction, not the implementation.
The shift in thinking is subtle but profound: instead of "my class creates what it needs", the mindset becomes "my class declares what it needs, and the container provides it". That single shift eliminates an entire category of coupling problems – and it's the foundation everything else in this lesson builds on.
Interfaces and Constructor Injection
Constructor injection is the dominant form of dependency injection – the pattern where a class declares everything it needs through its constructor parameters. It's the preferred form because it makes dependencies explicit and mandatory: you cannot construct a LoginPage without providing an IWebDriver. Contrast this with optional patterns like property injection, where a dependency can be null and the class must guard against it everywhere.
Designing for Injection
The process starts with extracting interfaces for every replaceable dependency. Not every class needs an interface – value objects, data records, and framework types like string don't need abstraction. But services – things that do work, communicate over a network, or interact with external resources – should be abstracted:
// The abstraction: what does a page object need from the browser?
// It needs to navigate, find elements, and get the current URL.
// It does NOT need to know if it's Chrome, Firefox, or a mock.
// IWebDriver from Selenium is already this abstraction – we don't need a new one.
// The page object depends on the abstraction, not the implementation
public class LoginPage
{
private readonly IWebDriver _driver;
private readonly ILogger _logger;
// Constructor injection: both dependencies declared, both mandatory.
// The container will provide them; the class doesn't know where they came from.
public LoginPage(IWebDriver driver, ILogger<LoginPage> logger)
{
_driver = driver;
_logger = logger;
}
public void NavigateTo(string baseUrl)
{
_logger.LogInformation("Navigating to login page at {Url}", baseUrl);
_driver.Navigate().GoToUrl($"{baseUrl}/login");
}
public void Login(string username, string password)
{
_driver.FindElement(By.Id("username")).SendKeys(username);
_driver.FindElement(By.Id("password")).SendKeys(password);
_driver.FindElement(By.Id("submit")).Click();
}
}
// Custom interface example: test data access
// This abstraction hides whether data comes from a database, a JSON file, or an in-memory stub
public interface ITestDataRepository
{
Task<User> GetUserByEmailAsync(string email);
Task<IList<Product>> GetProductsByCategoryAsync(string category);
Task InsertOrderAsync(Order order);
}
// The production implementation talks to a real database
public class SqlTestDataRepository : ITestDataRepository
{
private readonly string _connectionString;
public SqlTestDataRepository(string connectionString) =>
_connectionString = connectionString;
public async Task<User> GetUserByEmailAsync(string email)
{
using var connection = new SqlConnection(_connectionString);
// ... execute query
}
// ...
}
Why Constructor Over Property Injection
Property injection allows dependencies to be set after construction, which means the class might be used in a state where a required dependency is missing. The class must either null-check everywhere or silently fail. Constructor injection makes the missing dependency a compile-time or instantiation error – impossible to miss. The rule of thumb: use constructor injection for required dependencies, and property injection only for optional features or framework constraints that prevent constructor injection (like certain NUnit fixtures with parameterless constructor requirements).
When Not to Extract an Interface
Interface extraction is a cost. It adds a file, a type, and indirection that a reader must follow. Don't extract an interface just because DI is involved. The right trigger is replaceability: will this implementation ever need to be swapped for another? Will it need to be mocked in tests? If a class is a pure data container, a utility with no external dependencies, or a framework type like IWebDriver that already is an interface – don't create another abstraction around it. In test automation, the most valuable interfaces are around services that touch external systems: browsers, databases, APIs, file systems, and configuration providers.
Constructor injection works because it aligns with how objects should be designed anyway: a class whose invariants require certain dependencies should enforce that those dependencies are provided at construction time. DI just adds the infrastructure to automate the wiring.
Service Lifetimes for Test Isolation
One of the most consequential decisions when registering a service is its lifetime: how long the DI container keeps the same instance alive before creating a new one. Getting this wrong in test automation leads to state leakage between tests – one test's database inserts visible to the next, one test's logged-in browser session bleeding into another, or a cached service holding stale configuration. The three lifetimes map directly to test isolation requirements.
Transient – New Instance Every Time
Transient services are created fresh on every request from the container. They carry no state between uses and are the safest default for stateless services. For test automation, transient registration is appropriate for lightweight helpers, formatters, or utilities that have no shared state – things like a screenshot file namer, a test data builder, or a URL resolver:
// Transient: a new instance is created every time LoginPage is resolved.
// Correct for stateless page objects that don't accumulate state between uses.
services.AddTransient<LoginPage>();
// Transient is also right for stateless utilities with no mutable fields
services.AddTransient<IScreenshotNamer, TimestampedScreenshotNamer>();
Singleton – Shared for the Container's Lifetime
Singleton services are created once and reused for the lifetime of the container. In a test suite, the container's lifetime is the lifetime of the test run. Singletons are appropriate for services that are expensive to create, safe to share across tests, and explicitly designed to be stateless or thread-safe. WebDriver factories, configuration objects, and test result aggregators fit this pattern:
// Singleton: one instance per container lifetime (one per test run).
// Appropriate for expensive-to-create, thread-safe services.
services.AddSingleton<TestConfiguration>();
services.AddSingleton<IWebDriverFactory, WebDriverFactory>();
// WARNING: Singleton page objects are almost always wrong.
// Page objects hold state (current URL, element references) that changes per test.
// A singleton LoginPage would share state across all tests – a data leakage disaster.
// services.AddSingleton<LoginPage>(); // ← Do NOT do this
Scoped – One Instance Per Test
Scoped services are created once per scope and reused within that scope. In test automation, the natural scope is a single test. Creating a new scope for each test – and disposing it after – gives you per-test isolation without the overhead of recreating everything from scratch. The WebDriver itself is the canonical scoped service: one browser session per test, cleaned up when the test ends:
// Scoped: one instance per scope (one per test when scopes are managed per-test).
// IWebDriver is registered as scoped: one browser per test, disposed after.
services.AddScoped<IWebDriver>(sp =>
{
// The factory decides which browser based on configuration
var factory = sp.GetRequiredService<IWebDriverFactory>();
var config = sp.GetRequiredService<TestConfiguration>();
return factory.Create(config.Browser);
});
// Page objects are also scoped: they share the test's driver instance
// without needing to know how the driver was created or configured.
services.AddScoped<LoginPage>();
services.AddScoped<DashboardPage>();
services.AddScoped<CheckoutPage>();
Captive Dependency – The Lifetime Mismatch Bug
A captive dependency occurs when a longer-lived service holds a reference to a shorter-lived one. The most common mistake: registering a singleton service that takes a scoped dependency in its constructor. The singleton is created once, capturing the scoped instance from the first scope. When subsequent scopes are created, the singleton still holds the old scoped instance – effectively turning the "scoped" service into a singleton and leaking state between tests. Microsoft.Extensions.DependencyInjection detects this at runtime in development mode (ValidateScopes = true) and throws an InvalidOperationException. Enable scope validation in test infrastructure and treat any captive dependency error as a critical bug.
Lifetime selection is the architectural decision that most directly controls test isolation. When in doubt: start with transient for stateless services, scoped for per-test resources, and singleton only for genuinely shared, thread-safe infrastructure. The cost of getting it wrong isn't a compile error – it's intermittent test failures that are extremely difficult to diagnose because they depend on execution order.
Configuring the DI Container
Microsoft.Extensions.DependencyInjection is the standard DI container included in .NET and used throughout the ASP.NET Core ecosystem. It's available as a standalone NuGet package for use in test projects and console applications. The API has two parts: IServiceCollection for registrations, and IServiceProvider for resolutions.
Building a Service Collection
Every DI setup follows the same structure: create a ServiceCollection, register services with their lifetimes, build the provider. The provider is immutable – registrations can't be changed after BuildServiceProvider() is called:
using Microsoft.Extensions.DependencyInjection;
// ServiceCollection is the mutable registration surface
var services = new ServiceCollection();
// Registration methods:
// AddSingleton – one instance for the container's lifetime
// AddScoped – one instance per scope
// AddTransient – new instance every time
// Interface → Implementation mapping
services.AddSingleton<IWebDriverFactory, ChromeDriverFactory>();
services.AddScoped<IWebDriver>(sp =>
sp.GetRequiredService<IWebDriverFactory>().Create());
// Concrete class registration (no interface needed)
services.AddSingleton<TestConfiguration>();
// Factory delegate for complex construction logic
services.AddScoped<ITestDataRepository>(sp =>
{
var config = sp.GetRequiredService<TestConfiguration>();
return new SqlTestDataRepository(config.ConnectionString);
});
// Build the immutable provider
IServiceProvider provider = services.BuildServiceProvider(
new ServiceProviderOptions
{
// Validate that scoped services aren't consumed by singletons
ValidateScopes = true,
// Validate that every registered service can actually be resolved
ValidateOnBuild = true
});
Resolving Services
Services are resolved through the provider using GetRequiredService<T>() or GetService<T>(). The difference matters: GetRequiredService throws InvalidOperationException if the service isn't registered; GetService returns null. In test infrastructure, GetRequiredService is almost always correct – a missing registration is a configuration bug, not a case to handle silently:
// GetRequiredService: throws if not registered. Correct for mandatory dependencies.
var loginPage = provider.GetRequiredService<LoginPage>();
var config = provider.GetRequiredService<TestConfiguration>();
// GetService: returns null if not registered. Use only for optional features.
var optionalReporter = provider.GetService<ITestReporter>(); // may be null
// Resolving IEnumerable<T>: gets all registered implementations of an interface.
// Useful for plugin patterns – multiple reporters, multiple cleanup handlers, etc.
var allReporters = provider.GetRequiredService<IEnumerable<ITestReporter>>();
Scopes and Scope Disposal
Scoped services are only meaningful when a scope is active. Creating and disposing a scope is the mechanism that controls scoped service lifetimes in test code. The scope's disposal triggers disposal of all scoped IDisposable services created within it – including the IWebDriver and any database connections:
// Create a scope for a single test run
using var scope = provider.CreateScope();
IServiceProvider scopedServices = scope.ServiceProvider;
// Within this scope, IWebDriver resolves to the same instance every time
var driver1 = scopedServices.GetRequiredService<IWebDriver>();
var driver2 = scopedServices.GetRequiredService<IWebDriver>();
// driver1 and driver2 are the same instance (scoped behavior)
// When the using block exits, scope.Dispose() is called.
// This triggers Dispose() on all scoped IDisposable services:
// – IWebDriver.Quit() + Dispose()
// – database connections closed
// – any other IDisposable scoped services cleaned up
// No manual driver.Quit() needed in test teardown.
ValidateOnBuild Catches Misconfigurations Early
Setting ValidateOnBuild = true in ServiceProviderOptions causes the container to attempt resolving every registered service at build time and throw if any dependency can't be satisfied. Without it, missing registrations surface only when the affected service is first requested – potentially late in a test run, obscuring the root cause. In test infrastructure code, always enable ValidateOnBuild = true. The small overhead at startup is negligible; the debugging time saved is not.
The Microsoft.Extensions.DependencyInjection API is intentionally minimal – it does registration and resolution, and nothing else. That's a feature: the container is a configuration detail, not a pervasive framework that the rest of the code needs to know about. Well-designed DI code only touches the container at the application's composition root – the one place where all the wiring is assembled. Everywhere else, dependencies arrive through constructors.
Wiring a Test Framework with DI
The patterns from the previous sections combine into a test infrastructure layer where the container owns all service creation and lifecycle management. The test classes themselves become thin: they declare which services they need, and the base class provides them from the scope. No manual new ChromeDriver(), no driver.Quit() in teardown, no shared static state.
The Composition Root
The composition root is the single place in the application where the container is configured. For a test framework, it's typically a static builder method in a dedicated class. Centralising it means there's exactly one place to change when a service implementation is swapped:
// Composition root: all DI registrations in one place.
// Test code never creates services directly; it resolves them from the container.
public static class TestServices
{
public static IServiceProvider Build(TestConfiguration config)
{
var services = new ServiceCollection();
// Configuration is a singleton – one config object for the entire run
services.AddSingleton(config);
// Logging
services.AddLogging(builder =>
builder.AddConsole().SetMinimumLevel(LogLevel.Warning));
// Browser factory: singleton (stateless, creates drivers on demand)
services.AddSingleton<IWebDriverFactory>(sp =>
new WebDriverFactory(config.Browser, config.Headless));
// WebDriver: scoped (one per test, disposed with the scope)
services.AddScoped<IWebDriver>(sp =>
sp.GetRequiredService<IWebDriverFactory>().Create());
// Page objects: scoped (share the test's driver, discarded after each test)
services.AddScoped<LoginPage>();
services.AddScoped<DashboardPage>();
services.AddScoped<CheckoutPage>();
services.AddScoped<ProductCatalogPage>();
// API client: scoped (separate client per test for isolation)
services.AddHttpClient<IApiClient, ApiClient>(client =>
{
client.BaseAddress = new Uri(config.ApiBaseUrl);
client.Timeout = TimeSpan.FromSeconds(config.ApiTimeoutSeconds);
});
// Test data repository: scoped (fresh connection per test)
services.AddScoped<ITestDataRepository>(sp =>
new SqlTestDataRepository(config.ConnectionString));
// Reporting: singleton (accumulates results across all tests)
services.AddSingleton<ITestReporter, ConsoleTestReporter>();
return services.BuildServiceProvider(new ServiceProviderOptions
{
ValidateScopes = true,
ValidateOnBuild = true
});
}
}
Test Base Class with Per-Test Scopes
A test base class manages the scope lifecycle: create a scope in setup, resolve services from it, and dispose it in teardown. Each test gets its own scope and therefore its own IWebDriver, its own repository connection, and its own page objects – without any explicit resource management in the test methods themselves:
// Shared provider: built once for the test run, shared across all tests
[SetUpFixture]
public class TestRunSetup
{
public static IServiceProvider Provider { get; private set; } = null!;
[OneTimeSetUp]
public void BuildContainer()
{
var config = TestConfiguration.LoadFromEnvironment();
Provider = TestServices.Build(config);
}
[OneTimeTearDown]
public void DisposeContainer()
{
if (Provider is IDisposable disposable)
disposable.Dispose();
}
}
// Base class: manages per-test scope and exposes resolved services
[TestFixture]
public abstract class TestBase
{
private IServiceScope _scope = null!;
// Convenience properties – resolved lazily within the test's scope
protected IWebDriver Driver => _scope.ServiceProvider.GetRequiredService<IWebDriver>();
protected ITestDataRepository Repository => _scope.ServiceProvider.GetRequiredService<ITestDataRepository>();
protected IApiClient ApiClient => _scope.ServiceProvider.GetRequiredService<IApiClient>();
// Resolve any service from the scope – for page objects and other services
protected T Get<T>() where T : notnull =>
_scope.ServiceProvider.GetRequiredService<T>();
[SetUp]
public virtual void CreateScope()
{
// New scope per test: all scoped services (driver, pages, repository)
// are created fresh and isolated from other tests
_scope = TestRunSetup.Provider.CreateScope();
}
[TearDown]
public virtual void DisposeScope()
{
// Disposing the scope disposes all scoped IDisposable services:
// driver.Quit(), database connections closed, etc.
_scope.Dispose();
}
}
// Test class: just declares what it needs and uses it
[TestFixture]
public class CheckoutTests : TestBase
{
[Test]
public void VerifyCheckoutFlow_WithValidCart_CompletesOrder()
{
var loginPage = Get<LoginPage>();
var checkoutPage = Get<CheckoutPage>();
// Driver, pages, and repository all come from the test's scope.
// No new ChromeDriver(). No driver.Quit() in teardown.
loginPage.NavigateTo(Get<TestConfiguration>().BaseUrl);
loginPage.Login("[email protected]", "password123");
checkoutPage.AddItemToCart("ProductSku-001");
checkoutPage.Checkout();
Assert.That(checkoutPage.OrderConfirmationVisible(), Is.True);
}
}
AddHttpClient and IHttpClientFactory
services.AddHttpClient<TClient>() does more than register an HttpClient. It wires up IHttpClientFactory under the hood, which manages a pool of HttpMessageHandler instances that are reused across requests. This prevents socket exhaustion – a real problem in test suites that create and discard HttpClient instances per test without AddHttpClient. The factory also handles handler lifetime rotation, which prevents connections from being held open past their DNS TTL. For any test framework that makes HTTP calls, AddHttpClient is the correct registration pattern, not services.AddScoped<HttpClient>.
The resulting architecture separates concerns cleanly: test classes describe what to test, the base class manages when resources are created and destroyed, and the composition root decides which implementations are used. Changing any layer doesn't require touching the others – which is exactly the flexibility that makes large test suites maintainable.
Swapping Services for Test Doubles
The full payoff of interface-based DI comes when you need to test parts of the test framework itself, or when you want to run a subset of tests against a controlled environment. Because services are resolved through interfaces, replacing a real implementation with a test double – a mock, stub, or fake – is a matter of registering a different class for the same interface.
Unit Testing Framework Components
Consider a TestReporter that posts results to an external API. In the test suite for the reporting logic itself, you want to verify that the reporter calls the right endpoints with the right payload – without making real HTTP calls. A mock HttpClient (or a fake handler) replaces the real one:
// A handler that captures requests without making real network calls.
// Used when unit-testing components that depend on HttpClient.
public class CapturingMessageHandler : HttpMessageHandler
{
public List<HttpRequestMessage> CapturedRequests { get; } = new();
private readonly HttpStatusCode _responseStatus;
private readonly string _responseBody;
public CapturingMessageHandler(
HttpStatusCode status = HttpStatusCode.OK,
string body = "{}")
{
_responseStatus = status;
_responseBody = body;
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
CapturedRequests.Add(request);
return Task.FromResult(new HttpResponseMessage(_responseStatus)
{
Content = new StringContent(_responseBody)
});
}
}
// Unit test for the reporter using the capturing handler
[Test]
public async Task ReportResult_ShouldPostCorrectPayload()
{
var handler = new CapturingMessageHandler(HttpStatusCode.OK);
var client = new HttpClient(handler) { BaseAddress = new Uri("https://reports.test/") };
var reporter = new TestReporter(client); // inject the test double
await reporter.ReportResultAsync(new TestResult
{
TestName = "VerifyCheckoutFlow",
Passed = true,
Duration = TimeSpan.FromSeconds(3.2)
});
Assert.That(handler.CapturedRequests, Has.Count.EqualTo(1));
var request = handler.CapturedRequests[0];
Assert.That(request.RequestUri!.PathAndQuery, Is.EqualTo("/api/results"));
Assert.That(request.Method, Is.EqualTo(HttpMethod.Post));
}
Replacing Services in the Container
When a specific test class needs a different service implementation than the rest of the suite, create a derived service collection that overrides the registration. The container resolves the last registration for an interface when duplicates exist, so registering a mock after the real registration substitutes it cleanly:
// Building a container variant with a mock data repository.
// Useful for tests that don't need a database – offline or unit-level tests.
public static IServiceProvider BuildWithMockRepository(
TestConfiguration config,
ITestDataRepository mockRepository)
{
var services = new ServiceCollection();
// Register everything from the standard setup
// (extract the registration logic to a shared method for reuse)
RegisterCoreServices(services, config);
// Override the repository with the provided mock.
// AddScoped after the real registration replaces it for this container.
services.AddScoped<ITestDataRepository>(_ => mockRepository);
return services.BuildServiceProvider(new ServiceProviderOptions
{
ValidateScopes = true
});
}
// Test that uses an in-memory fake instead of a real database
[Test]
public async Task DashboardPage_ShouldDisplayUserOrders()
{
// Arrange: fake repository with predictable data – no database required
var fakeRepo = new InMemoryTestDataRepository();
fakeRepo.Seed(new User { UserId = 1, Email = "[email protected]" });
fakeRepo.Seed(new Order { OrderId = 42, UserId = 1, Status = "Completed" });
using var scope = BuildWithMockRepository(config, fakeRepo).CreateScope();
var dashboard = scope.ServiceProvider.GetRequiredService<DashboardPage>();
// Act: test the page's display logic without hitting a database
var orders = await dashboard.GetOrderSummaryAsync(userId: 1);
// Assert
Assert.That(orders, Has.Count.EqualTo(1));
Assert.That(orders[0].Status, Is.EqualTo("Completed"));
}
Using Moq with DI
Moq is the most widely used mocking library for .NET, and it integrates naturally with DI. A mock object produced by Moq implements the target interface, so it can be registered in the container like any other implementation:
// Moq: create a mock that implements ITestDataRepository
var mockRepo = new Mock<ITestDataRepository>();
// Set up the mock to return a specific user when queried
mockRepo
.Setup(r => r.GetUserByEmailAsync("[email protected]"))
.ReturnsAsync(new User
{
UserId = 99,
Email = "[email protected]",
FirstName = "Admin",
IsActive = true
});
// The mock.Object is the ITestDataRepository implementation
services.AddScoped<ITestDataRepository>(_ => mockRepo.Object);
// After the test, verify the mock was called as expected
mockRepo.Verify(
r => r.GetUserByEmailAsync("[email protected]"),
Times.Once,
"Expected the repository to be queried for the admin user exactly once.");
Fakes vs Mocks for Test Infrastructure
Mocks (Moq, NSubstitute) are ideal when you need to verify that a specific method was called with specific arguments. Fakes (hand-rolled in-memory implementations) are better when the component under test calls the dependency multiple times with varying arguments, or when the behaviour needs to be stateful – like an in-memory repository that stores and retrieves data across multiple calls. For test data repositories and state-holding services, a simple InMemoryTestDataRepository implementation is often more maintainable than a Moq setup with elaborate Returns chains. For single-call verification scenarios like HTTP reporters, mocks win on conciseness.
The ability to swap implementations through the container is the concrete, practical benefit that justifies the upfront investment in DI. Every interface you extract and every service you register is a seam where the framework can be controlled in tests. Those seams, accumulated over a well-designed test framework, are what allow the framework itself to be tested, evolved, and maintained without heroic effort.
Key Takeaways
- Tight coupling kills testability. When a class creates its own dependencies with
new, it can't be tested in isolation and can't be configured differently in different environments. Dependency injection breaks this by providing dependencies from outside rather than constructing them internally. - Constructor injection is the dominant pattern. Declare all required dependencies as constructor parameters. This makes dependencies explicit, mandatory, and visible to the container. Reserve property injection for optional dependencies or framework constraints that prevent constructor injection.
- Extract interfaces for replaceable services. Services that touch external systems – browsers, databases, APIs, file systems – should be abstracted behind interfaces. Value objects, utilities, and types that are already abstractions (
IWebDriver) don't need further wrapping. - Service lifetime controls test isolation. Singleton for stateless, shared infrastructure (configuration, driver factories). Scoped for per-test resources (WebDriver, database connections, page objects). Transient for stateless utilities with no shared state. Getting lifetimes wrong causes state leakage between tests.
- Captive dependency is a lifetime mismatch bug. A singleton that holds a scoped dependency effectively promotes that dependency to singleton lifetime.
ValidateScopes = trueandValidateOnBuild = trueinServiceProviderOptionscatch this at startup rather than in intermittent test failures. - The composition root wires everything together. All DI registrations belong in one place. Test classes never call
newfor services. The test base class manages scope creation and disposal per test, ensuring clean resource lifecycle without manual teardown in individual test methods. - Scope disposal triggers IDisposable cleanup. When a scope is disposed, all scoped
IDisposableservices are disposed automatically – includingIWebDriver, database connections, and HTTP clients. Explicitdriver.Quit()calls in test teardown are no longer needed when lifecycle is managed through scopes. - Interface-based DI enables test doubles. Any registered service can be replaced with a mock, fake, or stub by registering an alternative implementation in the container. This makes the test framework itself testable and allows offline or isolated test runs without real external dependencies.
Further Reading
- Dependency Injection in .NET (Microsoft) The official .NET documentation for Microsoft.Extensions.DependencyInjection: service registration, lifetime selection, scope management, and factory delegates – the complete reference for the container used in this lesson.
- Make HTTP Requests Using IHttpClientFactory (Microsoft) Deep documentation on IHttpClientFactory: how it manages handler pools, why it prevents socket exhaustion, and how to configure named and typed clients – essential context for test frameworks that make HTTP calls.
- Moq – The Most Popular .NET Mocking Library The Moq GitHub repository with full documentation for Setup, Returns, Verify, and advanced mocking patterns. Covers argument matchers, callback capture, and mock sequences for comprehensive test double verification.
- Unit Testing with .NET Test (Microsoft) Practical guide to structuring .NET test projects, including how to organise test projects alongside source projects, configure test settings, and apply DI patterns in xUnit and NUnit test fixtures.