Putting It All Together – Building a Test Utility Library
Consider the difference between a workshop full of individual tools and a craftsperson who knows exactly which tool to reach for, when to combine two of them, and when to build a new one from scratch. Nine lessons of advanced C# have stocked that workshop – delegates, generics, LINQ, async patterns, exception handling, file I/O, reflection, dependency injection, and configuration management are all available and individually understood. But knowing each tool in isolation is only half the job. The other half is knowing how to combine them into infrastructure that's greater than the sum of its parts – infrastructure a team can adopt without reading the implementation, maintain without fearing breakage, and extend without unintended side effects.
This lesson builds a production-ready test utility library called TestAutomation.Utilities from scratch. It contains a generic retry engine, flexible async wait helpers, a type-safe test data builder, fluent assertion extensions, and a dependency injection registration module. Each component draws on techniques from earlier lessons in this block, and the lesson makes those connections explicit – showing not just how to build each piece, but why these particular patterns compose so well together.
The goal isn't to produce code to copy wholesale into a project. It's to demonstrate how architectural intent – deliberate folder structure, minimal public surface area, defensive input validation, and composable APIs – transforms individual techniques into test infrastructure with genuine staying power.
Designing for Adoption
A utility library that nobody adopts has no value. Before writing a line of implementation, the design decisions that determine whether a library feels natural or foreign are worth settling. The fundamental question is: what does the consuming test look like? Work backward from that ideal API surface to the implementation, not forward from whatever happens to be easy to write.
The Project Structure
The library lives in its own project, separate from the test assemblies that consume it. This separation matters: it enforces the boundary between reusable infrastructure and test-specific logic, makes the library independently versionable, and signals clearly what belongs to which concern. The folder structure below is intentionally flat – a deeply nested hierarchy adds navigation overhead without adding clarity at this scale:
TestAutomation.Utilities/
├── Core/
│ ├── Guard.cs ← Input validation utilities
│ ├── Retry.cs ← Generic retry engine
│ └── Wait.cs ← Async polling helpers
├── Data/
│ ├── Builder.cs ← Generic test data builder
│ └── TestAssertionException.cs
├── Extensions/
│ ├── AssertionExtensions.cs ← Fluent collection assertions
│ └── StringExtensions.cs ← String test helpers
└── DependencyInjection/
└── ServiceCollectionExtensions.cs
API Design Principles
Three principles guide the design of every component. First, minimal ceremony – common scenarios should work without configuration, and defaults should be sensible for test automation contexts. Second, discoverability – the API should reveal its intent through names alone; calling code should read like prose. Third, composability – components shouldn't know about each other unless explicitly necessary, which keeps each unit independently testable and replaceable.
Namespace Conventions
Each folder maps to a namespace under TestAutomation.Utilities. Consumers add targeted usings for what they need, with the DI module reserved for the composition root:
// Typical test file – core utilities and data building
using TestAutomation.Utilities.Core;
using TestAutomation.Utilities.Data;
using TestAutomation.Utilities.Extensions;
// Composition root only – wires everything together
using TestAutomation.Utilities.DependencyInjection;
Keep the Public Surface Small
Resist the urge to make helper methods public "in case someone finds them useful." Every public member is a commitment – if consuming code depends on it, changing or removing it is a breaking change. Mark implementation details as internal by default. The library's public API should be a deliberate, minimal contract, not a grab-bag of everything that happened to get written.
The structure above is intentionally flat. A deeply nested hierarchy of subfolders adds navigation overhead without adding clarity at this scale – components are better served by clear naming than by elaborate categorisation. Revisit the structure when the library has grown substantially, not before.
Core – Guard and Retry
Every component in the library validates its inputs before using them. A NullReferenceException three call frames deep is harder to diagnose than an ArgumentNullException with a clear parameter name thrown immediately. The Guard class centralises this validation into a single, readable call site and keeps every public entry point honest about its preconditions.
The Guard Class
Guard methods return the validated value, enabling fluent assignment in constructors without separate null checks. The is null pattern works correctly for both reference types and nullable value types:
namespace TestAutomation.Utilities.Core;
/// <summary>Provides pre-condition validation for public API entry points.</summary>
public static class Guard
{
/// <summary>Throws ArgumentNullException if value is null.</summary>
public static T NotNull<T>(T value, string paramName) where T : class
{
if (value is null)
throw new ArgumentNullException(paramName);
return value;
}
/// <summary>Throws ArgumentException if value is null or empty.</summary>
public static string NotNullOrEmpty(string? value, string paramName)
{
if (string.IsNullOrEmpty(value))
throw new ArgumentException(
$"Parameter '{paramName}' must not be null or empty.", paramName);
return value!;
}
/// <summary>Throws ArgumentOutOfRangeException if value is not positive.</summary>
public static int Positive(int value, string paramName)
{
if (value <= 0)
throw new ArgumentOutOfRangeException(
paramName, value, $"'{paramName}' must be greater than zero.");
return value;
}
}
The Retry Engine
The retry engine draws on more techniques from this block than any other component: generic type parameters for the return value, delegates and lambdas for the action and predicate arguments, async/await throughout, and exception filtering via when clauses. Configuration lives in a RetryOptions record that ships with sensible defaults and named presets for the scenarios that appear most often in test automation:
namespace TestAutomation.Utilities.Core;
/// <summary>Configuration options for retry behaviour.</summary>
public sealed record RetryOptions
{
/// <summary>Maximum number of attempts, including the first. Default: 3.</summary>
public int MaxAttempts { get; init; } = 3;
/// <summary>Delay before the second attempt. Doubles with each failure when
/// UseExponentialBackoff is true. Default: 1 second.</summary>
public TimeSpan InitialDelay { get; init; } = TimeSpan.FromSeconds(1);
/// <summary>When true, the delay doubles after each failure. Default: true.</summary>
public bool UseExponentialBackoff { get; init; } = true;
/// <summary>Predicate determining whether an exception warrants a retry.
/// When null, all exceptions trigger a retry.</summary>
public Func<Exception, bool>? ShouldRetry { get; init; }
// Named presets for common scenarios
public static RetryOptions Default => new();
public static RetryOptions Fast => new() { MaxAttempts = 3, InitialDelay = TimeSpan.FromMilliseconds(250), UseExponentialBackoff = false };
public static RetryOptions ForFlaky => new() { MaxAttempts = 5, InitialDelay = TimeSpan.FromMilliseconds(500), UseExponentialBackoff = true };
}
The Retry class provides two static methods: one for operations returning a value (Task<T>) and one for operations that don't. The void overload delegates to the generic one to avoid duplicating the retry logic. The loop runs for MaxAttempts - 1 iterations, each followed by a delay on failure; the final attempt executes after the loop and lets any exception propagate without an unnecessary last-iteration delay:
namespace TestAutomation.Utilities.Core;
/// <summary>Provides configurable retry for async operations.</summary>
public static class Retry
{
/// <summary>Executes an async operation with configurable retry behaviour.</summary>
public static async Task<T> ExecuteAsync<T>(
Func<Task<T>> action,
RetryOptions? options = null,
CancellationToken cancellationToken = default)
{
Guard.NotNull(action, nameof(action));
options ??= RetryOptions.Default;
var shouldRetry = options.ShouldRetry ?? (_ => true);
for (int attempt = 1; attempt < options.MaxAttempts; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
return await action().ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw; // Never retry a cancellation
}
catch (Exception ex) when (shouldRetry(ex))
{
var multiplier = options.UseExponentialBackoff ? Math.Pow(2, attempt - 1) : 1.0;
var delay = TimeSpan.FromMilliseconds(
options.InitialDelay.TotalMilliseconds * multiplier);
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
}
}
// Final attempt – let any exception propagate without a preceding delay
cancellationToken.ThrowIfCancellationRequested();
return await action().ConfigureAwait(false);
}
/// <summary>Executes a void async operation with configurable retry behaviour.</summary>
public static Task ExecuteAsync(
Func<Task> action,
RetryOptions? options = null,
CancellationToken cancellationToken = default)
{
Guard.NotNull(action, nameof(action));
return ExecuteAsync(
async () => { await action().ConfigureAwait(false); return true; },
options,
cancellationToken);
}
}
The ShouldRetry Predicate in Practice
The ShouldRetry delegate is the Func pattern from the delegates lesson applied to a real problem. A Selenium test project would pass a predicate that returns true for StaleElementReferenceException and false for everything else, keeping retry behaviour precisely scoped. An API test project might retry only on HttpRequestException with a 5xx status. The engine stays generic; the decision about what constitutes a retryable failure stays with the caller who understands the domain.
The pattern of "loop for N-1 iterations, then one final attempt outside the loop" is worth recognising across different retry implementations. It avoids the awkward question of whether to sleep on the last failure and keeps the delay logic clearly tied to the concept of preparing for the next attempt – not reacting to the last one.
Core – Flexible Wait Helpers
Retry handles known failure modes – operations that sometimes fail but usually succeed on a subsequent attempt. Wait handles a different problem: operations that are fundamentally asynchronous from the test's perspective, where the system needs time to reach a desired state and polling is the correct detection mechanism. The two utilities are distinct because the correct response to a retry failure is to try again after a delay, while the correct response to a wait condition not yet being true is to keep polling until it is – or until the timeout expires and the test knows to fail.
The Wait Utility
The Wait class polls a condition delegate at a configurable interval. The condition receives a CancellationToken linked to the overall timeout, allowing long-running condition checks to abort early if time runs out. Transient exceptions during polling are swallowed – the system is in an intermediate state and the next poll may succeed; only OperationCanceledException propagates immediately:
namespace TestAutomation.Utilities.Core;
/// <summary>Provides configurable async polling for conditions that take time to become true.</summary>
public static class Wait
{
private static readonly TimeSpan DefaultPollingInterval = TimeSpan.FromMilliseconds(250);
/// <summary>Polls until the condition returns a non-null value, or the timeout expires.</summary>
/// <exception cref="TimeoutException">Thrown when the condition is not satisfied in time.</exception>
public static async Task<T> ForAsync<T>(
Func<CancellationToken, Task<T?>> condition,
TimeSpan timeout,
TimeSpan? pollingInterval = null,
CancellationToken cancellationToken = default)
where T : class
{
Guard.NotNull(condition, nameof(condition));
var interval = pollingInterval ?? DefaultPollingInterval;
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(timeout);
while (!cts.Token.IsCancellationRequested)
{
try
{
var result = await condition(cts.Token).ConfigureAwait(false);
if (result is not null)
return result;
}
catch (OperationCanceledException) { throw; }
catch { /* Swallow transient exceptions; next poll may succeed */ }
try
{
await Task.Delay(interval, cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) { break; }
}
throw new TimeoutException(
$"Condition was not satisfied within {timeout.TotalSeconds:F1} seconds.");
}
/// <summary>Polls until the bool condition returns true, or the timeout expires.</summary>
/// <exception cref="TimeoutException">Thrown when the condition is not satisfied in time.</exception>
public static async Task ForConditionAsync(
Func<CancellationToken, Task<bool>> condition,
TimeSpan timeout,
TimeSpan? pollingInterval = null,
CancellationToken cancellationToken = default)
{
Guard.NotNull(condition, nameof(condition));
var interval = pollingInterval ?? DefaultPollingInterval;
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(timeout);
while (!cts.Token.IsCancellationRequested)
{
try
{
if (await condition(cts.Token).ConfigureAwait(false))
return;
}
catch (OperationCanceledException) { throw; }
catch { /* Swallow transient exceptions; retry on next poll */ }
try
{
await Task.Delay(interval, cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) { break; }
}
throw new TimeoutException(
$"Condition was not satisfied within {timeout.TotalSeconds:F1} seconds.");
}
}
Using the Wait Helper
The API reads naturally because the parameters map directly to how a developer thinks about the problem – what to wait for, how long to wait, and how often to check:
// Wait for an order to reach a specific status (returns the matched object)
var processedOrder = await Wait.ForAsync(
condition: async ct =>
{
var order = await orderRepository.GetByIdAsync(orderId, ct);
return order?.Status == "Processed" ? order : null;
},
timeout: TimeSpan.FromSeconds(15),
pollingInterval: TimeSpan.FromMilliseconds(500));
// Wait for a boolean condition – message queue drained, page element visible, etc.
await Wait.ForConditionAsync(
condition: async ct =>
{
var count = await messageQueue.GetPendingCountAsync(ct);
return count == 0;
},
timeout: TimeSpan.FromSeconds(30));
Wait vs Thread.Sleep – The Fundamental Difference
A fixed Thread.Sleep(5000) always waits five seconds regardless of whether the condition was true after one second or never became true at all. A polling wait checks frequently and returns as soon as the condition is satisfied – producing faster tests when the system responds quickly and a clear timeout message when it doesn't. Fixed sleeps accumulate: a suite with fifty three-second sleeps adds 2.5 minutes of pure waiting to every run. Replace them with polling waits and that time drops dramatically while diagnostic clarity improves.
The Wait and Retry utilities are complementary rather than competing. Use Retry when an operation is expected to succeed but may need multiple attempts. Use Wait when the system needs time to reach a state the test can then act on. Some scenarios call for both: retry the operation, then wait for the result to propagate through the system before asserting.
Data – The Generic Builder
Test data creation is one of the highest-friction activities in a test suite. The naive approach – creating test objects inline with hardcoded values – scatters magic strings throughout the code, buries test intent inside construction details, and makes every model change painful. The Builder pattern addresses all three problems: it centralises object construction, exposes intent through method names, and adapts to model changes through its generic implementation rather than through individual test edits.
The Builder Implementation
The Builder<T> class uses generics with a new() constraint ensuring T can be instantiated with a parameterless constructor. Expression-based property setting uses the same reflection techniques from the earlier lesson: the expression tree is inspected to extract a PropertyInfo, which becomes a deferred action applied at Build() time. Build() creates a fresh instance on every call, making it safe to reuse a builder configuration across multiple objects:
using System.Linq.Expressions;
using System.Reflection;
namespace TestAutomation.Utilities.Data;
/// <summary>Fluent builder for creating test objects with configurable property values.</summary>
/// <typeparam name="T">The type to build. Must have a public parameterless constructor.</typeparam>
public sealed class Builder<T> where T : class, new()
{
private readonly List<Action<T>> _overrides = new();
/// <summary>Sets a property value using a strongly-typed expression.</summary>
public Builder<T> With<TValue>(
Expression<Func<T, TValue>> property,
TValue value)
{
Guard.NotNull(property, nameof(property));
if (property.Body is not MemberExpression { Member: PropertyInfo propInfo })
throw new ArgumentException(
"Expression must target a writable property.", nameof(property));
_overrides.Add(instance => propInfo.SetValue(instance, value));
return this;
}
/// <summary>Applies a configuration action directly to the built instance.</summary>
public Builder<T> With(Action<T> configure)
{
_overrides.Add(Guard.NotNull(configure, nameof(configure)));
return this;
}
/// <summary>Creates a new instance with all configured overrides applied.</summary>
public T Build()
{
var instance = new T();
foreach (var apply in _overrides)
apply(instance);
return instance;
}
/// <summary>Creates multiple independent instances, each with the same overrides applied.</summary>
public IReadOnlyList<T> BuildMany(int count)
{
Guard.Positive(count, nameof(count));
return Enumerable.Range(0, count).Select(_ => Build()).ToList();
}
}
Builder Usage in Tests
The value becomes clear in test code. Every property that matters for a scenario is explicitly named; everything else receives a sensible default from the model. Adding a new required field to the model requires updating the default value in one place – existing tests continue to compile and run unchanged:
// Create a single test user with specific properties for an admin scenario
var adminUser = new Builder<TestUser>()
.With(u => u.Email, "[email protected]")
.With(u => u.Role, "Admin")
.With(u => u.IsActive, true)
.With(u => u.CreatedDate, DateTime.UtcNow.AddDays(-30))
.Build();
// Create multiple products sharing the same category and stock configuration
var products = new Builder<TestProduct>()
.With(p => p.CategoryId, electronicsId)
.With(p => p.IsActive, true)
.With(p => p.StockQuantity, 100)
.BuildMany(5); // Five independent instances
// Use the configure-action overload for complex object graph setup
var order = new Builder<TestOrder>()
.With(o =>
{
o.UserId = adminUser.UserId;
o.OrderDate = DateTime.UtcNow;
o.Status = "Pending";
o.OrderItems = products.Take(3).Select(p => new OrderItem
{
ProductId = p.ProductId,
Quantity = 2,
UnitPrice = p.Price
}).ToList();
})
.Build();
Expression Trees and Reflection Performance
The With<TValue> overload extracts the PropertyInfo at the point of the With() call (using reflection on the expression tree), then stores a closure that calls SetValue at Build() time. For test automation workloads the reflection overhead is negligible compared to any I/O the test performs. If profiling ever reveals the builder as a hotspot, the PropertyInfo objects can be cached in a static ConcurrentDictionary<string, PropertyInfo> keyed by expression. For the vast majority of real-world test suites, that optimisation would add complexity without measurable benefit.
The generic builder is one of the highest-value components a test utility library can provide because it eliminates a whole category of maintenance friction. When the product model evolves – a new required field, a renamed property, a type change – the builder absorbs the structural change. Individual tests describe what they need, not how to construct it, and that separation pays dividends every time the model changes.
Extensions – Fluent Assertions
NUnit, xUnit, and MSTest provide assertion primitives, but they don't cover the full surface area of what test automation frameworks need. Verifying that an API response contains all expected records, that a dataset is ordered correctly, or that a collection has no duplicates requires multi-step LINQ code each time it appears. Extension methods on IEnumerable<T> encapsulate those patterns once, giving every test a concise, readable assertion vocabulary that produces specific failure messages without additional diagnostic code.
The TestAssertionException
A lightweight exception type distinguishes library assertion failures from application exceptions in test output. It carries a descriptive message written for the developer who reads it after a CI failure, not for the test framework internals:
namespace TestAutomation.Utilities.Data;
/// <summary>Thrown when a custom assertion check fails.
/// Test frameworks treat any unhandled exception from a test as a failure.</summary>
public sealed class TestAssertionException : Exception
{
public TestAssertionException(string message) : base(message) { }
public TestAssertionException(string message, Exception inner)
: base(message, inner) { }
}
Collection Assertion Extensions
The most common collection assertions in integration and API tests are membership checks, ordering checks, uniqueness checks, and conditional satisfaction. Each uses LINQ for evaluation and produces a specific failure message that identifies exactly which items or positions failed:
using TestAutomation.Utilities.Data;
namespace TestAutomation.Utilities.Extensions;
/// <summary>Extension methods providing test-focused assertions on collection types.</summary>
public static class AssertionExtensions
{
/// <summary>Asserts that the collection contains all items in the expected set.</summary>
public static void ShouldContainAll<T>(
this IEnumerable<T> actual,
IEnumerable<T> expected,
string? message = null)
{
var actualSet = actual.ToHashSet();
var missing = expected.Where(e => !actualSet.Contains(e)).ToList();
if (missing.Count > 0)
throw new TestAssertionException(
message ?? $"Collection is missing {missing.Count} expected item(s): " +
string.Join(", ", missing));
}
/// <summary>Asserts that the collection is ordered ascending by the specified key.</summary>
public static void ShouldBeOrderedBy<T, TKey>(
this IEnumerable<T> actual,
Func<T, TKey> keySelector,
string? message = null)
where TKey : IComparable<TKey>
{
var list = actual.ToList();
for (int i = 0; i < list.Count - 1; i++)
{
if (keySelector(list[i]).CompareTo(keySelector(list[i + 1])) > 0)
throw new TestAssertionException(
message ?? $"Collection is not ordered at index {i}: " +
$"'{keySelector(list[i])}' should not precede '{keySelector(list[i + 1])}'.");
}
}
/// <summary>Asserts that all items in the collection satisfy the given condition.</summary>
public static void ShouldAllSatisfy<T>(
this IEnumerable<T> actual,
Func<T, bool> condition,
string conditionDescription,
string? message = null)
{
var failures = actual.Where(item => !condition(item)).ToList();
if (failures.Count > 0)
throw new TestAssertionException(
message ?? $"{failures.Count} item(s) did not satisfy '{conditionDescription}'.");
}
/// <summary>Asserts that the collection contains no duplicate elements.</summary>
public static void ShouldBeDistinct<T>(
this IEnumerable<T> actual,
string? message = null)
{
var list = actual.ToList();
var dupes = list.Count - list.Distinct().Count();
if (dupes > 0)
throw new TestAssertionException(
message ?? $"Collection has {dupes} duplicate(s).");
}
}
String Assertion Extensions
A companion set addresses string checks that appear frequently in API response validation – pattern matching and case-insensitive containment are the two that save the most boilerplate:
using System.Text.RegularExpressions;
using TestAutomation.Utilities.Data;
namespace TestAutomation.Utilities.Extensions;
/// <summary>Extension methods for string validation in tests.</summary>
public static class StringExtensions
{
/// <summary>Asserts that the string matches the given regular expression pattern.</summary>
public static void ShouldMatch(
this string? actual,
string pattern,
string? message = null)
{
if (actual is null || !Regex.IsMatch(actual, pattern))
throw new TestAssertionException(
message ?? $"String '{actual ?? "(null)"}' did not match pattern '{pattern}'.");
}
/// <summary>Asserts that the string contains the expected substring (case-insensitive).</summary>
public static void ShouldContainIgnoreCase(
this string? actual,
string expected,
string? message = null)
{
if (actual is null || !actual.Contains(expected, StringComparison.OrdinalIgnoreCase))
throw new TestAssertionException(
message ?? $"String '{actual ?? "(null)"}' does not contain '{expected}'.");
}
}
Complement the Framework, Don't Replace It
These extensions aren't a replacement for the assertion library bundled with the test framework – they complement it. Use Assert.That or Assert.Equal for straightforward value comparisons. Use these extensions for collection and string scenarios the framework doesn't cover concisely. Mixing both in the same test is entirely appropriate; they serve different purposes and both produce clear failure messages.
Method names are the API contract for extension-based assertions. They should read as natural language: ShouldContainAll, ShouldBeOrderedBy, ShouldAllSatisfy. Every developer on the team already knows how to call extension methods; making custom assertions look and feel like LINQ means there's no learning curve to adopting them.
Wiring the Library Together
A utility library that requires manual instantiation and connection of each component creates adoption friction. Dependency injection resolves this: a single registration method in the composition root wires everything together, and every component becomes available through constructor injection without further setup. This is the DI and configuration management patterns from the final two lessons of this block, applied at the library level.
The DI Registration Extension
The AddTestUtilities extension method on IServiceCollection accepts the validated settings object and registers every service the library provides. The settings object was loaded and validated before calling this method – the library doesn't own that responsibility:
using Microsoft.Extensions.DependencyInjection;
using TestAutomation.Utilities.Core;
namespace TestAutomation.Utilities.DependencyInjection;
/// <summary>Extension methods for registering TestAutomation.Utilities services.</summary>
public static class ServiceCollectionExtensions
{
/// <summary>Registers all TestAutomation.Utilities services with the DI container.</summary>
/// <param name="settings">Pre-loaded and validated test settings.</param>
public static IServiceCollection AddTestUtilities(
this IServiceCollection services,
TestSettings settings)
{
Guard.NotNull(services, nameof(services));
Guard.NotNull(settings, nameof(settings));
// Configuration – registered as singletons, available everywhere
services.AddSingleton(settings);
services.AddSingleton(settings.Environment);
services.AddSingleton(settings.Browser);
services.AddSingleton(settings.Timeouts);
// Retry options – the default instance; consumers can override per-call
services.AddSingleton(RetryOptions.Default);
return services;
}
}
The Test Base Class
A test base class builds the service provider once per test session (static) and creates a fresh scope per test instance. Scoped services – a WebDriver, a database connection, an HTTP client – are created and disposed correctly per test without any manual lifecycle management in individual tests:
using Microsoft.Extensions.DependencyInjection;
using TestAutomation.Utilities.DependencyInjection;
namespace MyProject.Tests;
/// <summary>Base class providing test utilities and project-specific services via DI.</summary>
public abstract class TestBase : IDisposable
{
// Built once for the test session – configuration is loaded and validated here
private static readonly IServiceProvider _provider = BuildProvider();
// Fresh scope per test – scoped services get new instances per test
private readonly IServiceScope _scope = _provider.CreateScope();
protected IServiceProvider Services => _scope.ServiceProvider;
protected T Get<T>() where T : notnull
=> Services.GetRequiredService<T>();
public void Dispose() => _scope.Dispose();
private static IServiceProvider BuildProvider()
{
var settings = TestSettings.LoadSettings(); // validates at startup; throws early on bad config
return new ServiceCollection()
.AddTestUtilities(settings)
// Add project-specific services: page objects, API clients, repositories, etc.
.BuildServiceProvider(new ServiceProviderOptions
{
ValidateScopes = true,
ValidateOnBuild = true
});
}
}
A Test Using Every Component
The payoff becomes visible when all the components work together in a single test. The construction ceremony disappears. The intent of each step is clear. Failure messages are specific. The test reads as a specification of the expected behaviour, not as a guide to the underlying infrastructure:
using NUnit.Framework;
using TestAutomation.Utilities.Core;
using TestAutomation.Utilities.Data;
using TestAutomation.Utilities.Extensions;
namespace MyProject.Tests;
[TestFixture]
public class OrderHistoryTests : TestBase
{
[Test]
public async Task CompletedOrders_AppearInHistorySortedByDate()
{
// Arrange – fluent test data construction with the generic builder
var user = new Builder<TestUser>()
.With(u => u.Email, "[email protected]")
.With(u => u.IsActive, true)
.Build();
await Get<IUserRepository>().CreateAsync(user);
// Act – place orders with retry for transient API failures
var orderIds = new List<int>();
foreach (var date in new[] { DateTime.Today.AddDays(-2), DateTime.Today.AddDays(-1), DateTime.Today })
{
var orderId = await Retry.ExecuteAsync(
() => Get<IOrderService>().PlaceOrderAsync(user.UserId, date),
RetryOptions.Fast);
orderIds.Add(orderId);
}
// Wait for the async processing pipeline to finish before asserting
await Wait.ForConditionAsync(
async ct =>
{
var pending = await Get<IOrderRepository>().GetPendingCountAsync(user.UserId, ct);
return pending == 0;
},
timeout: TimeSpan.FromSeconds(15));
// Assert – retrieve history and apply fluent collection assertions
var history = await Get<IOrderRepository>().GetHistoryAsync(user.UserId);
var expectedOrders = orderIds.Select(id => history.First(o => o.Id == id)).ToList();
history.ShouldContainAll(expectedOrders);
history.ShouldBeOrderedBy(o => o.PlacedDate);
history.ShouldAllSatisfy(o => o.Status == "Completed", "Status == Completed");
}
}
The Library Grows With the Team
Add a pattern to the library when it appears in three or more tests – that repetition is the signal it deserves a shared home. Remove or simplify something when tests work around it rather than with it – that friction is the signal the API doesn't fit how the team thinks about the problem. A library of ten well-understood utilities that every team member uses confidently is more valuable than a library of fifty utilities that nobody fully knows.
The complete picture – a retry engine, wait helpers, data builders, assertion extensions, and a DI registration module – shows how a library built from focused, composable components is easier to understand, test, and extend than a monolithic helper class. Each component does one thing. The composition root connects them. Individual tests interact with each component at exactly the level of abstraction they need, and the seams between components are clean enough to swap one out without touching the others.
Key Takeaways
- Design from the consuming test outward. Decide what the test code should look like when using the library, then work backward to the implementation. APIs designed from the implementation outward tend to reflect implementation concerns rather than test concerns – and that mismatch shows up as adoption friction.
- Guard validates inputs at every public entry point. Throwing
ArgumentNullExceptionwith a clear parameter name immediately is far easier to diagnose than aNullReferenceExceptionburied in the call stack. Guard methods that return the validated value enable fluent assignment in constructors without separate null-check lines. - Retry and Wait solve fundamentally different problems. Use
Retryfor operations that may transiently fail and should be attempted again. UseWaitfor states the system needs time to reach. Both acceptCancellationTokenand are configured through plain options objects rather than fluent chains of configuration calls. - Builder<T> with expression-based property setting is type-safe and refactoring-safe. When a property is renamed in the model, the compiler flags every builder call that referenced the old name. There are no magic strings, no silent runtime failures. The
new()constraint on the generic parameter ensures the builder can always construct a fresh instance. - Extension methods on IEnumerable<T> create a natural assertion vocabulary. Methods named
ShouldContainAll,ShouldBeOrderedBy, andShouldAllSatisfyread as specifications and produce failure messages that identify exactly what was wrong – which items were missing, which index broke the ordering, how many items failed the condition. - A single AddTestUtilities() call removes the adoption threshold. A library that requires manual wiring of its components before each test will be used inconsistently or abandoned. Registering everything once in the composition root and injecting through constructors means the library has zero adoption friction after the first setup.
- Keep the library small and let it grow organically. Extract a pattern when it appears three or more times. Remove something when the team works around it. A focused library that everyone understands completely is worth more than a comprehensive one that nobody fully trusts.
Further Reading
- Framework Design Guidelines (Microsoft) The authoritative guide to designing .NET libraries: naming conventions, type and member design, extensibility mechanisms, and exception design. Directly applicable to building internal test utility libraries that feel like first-class .NET citizens.
- Expression Trees in C# (Microsoft) Deep coverage of how expression trees represent code as data structures, how to traverse and compile them at runtime, and how they underpin LINQ providers and the strongly-typed reflection patterns used in the Builder's property-setting implementation.
- Polly – .NET Resilience and Transient-Fault-Handling Library The industry-standard resilience library for .NET: retry, circuit breaker, timeout, bulkhead isolation, and fallback policies with a fluent API. A natural next step beyond the custom retry engine built in this lesson, particularly for frameworks with complex or multi-policy resilience requirements.
- Fluent Assertions – Introduction A mature, comprehensive assertion library for .NET test frameworks that extends the custom assertion pattern from this lesson across hundreds of scenarios: collections, strings, exceptions, HTTP responses, dates, and more. Worth evaluating before building extensive custom assertion extensions.