Skip to content

Repository files navigation


Logo


Introduction

UnitTestEx provides .NET testing extensions to the most popular testing frameworks: MSTest, NUnit and Xunit.

The scenarios that UnitTestEx looks to address is the end-to-end unit-style testing of the following whereby the capabilities look to adhere to the AAA pattern of unit testing; Arrange, Act and Assert.


Status

The build and packaging status is as follows.

CI UnitTestEx UnitTestEx.Aspire UnitTestEx.MSTest UnitTestEx.NUnit UnitTestEx.Xunit
CI NuGet version NuGet version NuGet version NuGet version NuGet version

The included change log details all key changes per published version.


API Controller

Leverages the WebApplicationFactory (WAF) as a means to host a test server in process to invoke APIs directly using HTTP requests. This has the benefit of validating the HTTP pipeline and all Dependency Injection (DI) configuration within. External system interactions can be mocked accordingly.

UnitTestEx encapsulates the WebApplicationFactory providing a simple means to arrange the input, execute (act), and assert the response. The following is an example.

using var test = ApiTester.Create<Startup>();
test.ReplaceHttpClientFactory(mcf)
    .Controller<ProductController>()
    .Run(c => c.Get("abc"))
    .AssertOK()
    .Assert(new { id = "Abc", description = "A blue carrot" });

HTTP-triggered Azure Function

Unfortunately, at time of writing, there is no WebApplicationFactory equivalent for Azure functions. UnitTestEx looks to emulate by self-hosting the function, managing Dependency Injection (DI) configuration, and invocation of the specified method. UnitTestEx when invoking verifies usage of HttpTriggerAttribute and ensures a Task<IActionResult> result.

The following is an example.

using var test = FunctionTester.Create<Startup>();
test.ReplaceHttpClientFactory(mcf)
    .HttpTrigger<ProductFunction>()
    .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "person/abc", null), "abc", test.Logger))
    .AssertOK()
    .Assert(new { id = "Abc", description = "A blue carrot" });

Both the Isolated worker model and In-process model are supported.

Additionally, where an HttpRequest is used the passed HttpRequest.PathAndQuery is checked against that defined by the corresponding HttpTriggerAttribute.Route and will result in an error where different. The HttpTrigger.WithRouteChecK and WithNoRouteCheck methods control the path and query checking as needed.


Service Bus-trigger Azure Function

As above, there is currently no easy means to integration (in-process) test Azure functions that rely on the Azure Service Bus. UnitTestEx looks to emulate by self-hosting the function, managing Dependency Injection (DI) configuration, and invocation of the specified method and verifies usage of the ServiceBusTriggerAttribute.

The following is an example of invoking the function method directly passing in a ServiceBusReceivedMessage created using test.CreateServiceBusMessageFromValue (this creates a message as if coming from Azure Service Bus).

using var test = FunctionTester.Create<Startup>();
test.ReplaceHttpClientFactory(mcf)
    .ServiceBusTrigger<ServiceBusFunction>()
    .Run(f => f.Run2(test.CreateServiceBusMessageFromValue(new Person { FirstName = "Bob", LastName = "Smith" }), test.Logger))
    .AssertSuccess();

Both the Isolated worker model and In-process model are supported.


Generic Azure Function Type

To support testing of any generic Type within an Azure Fuction, UnitTestEx looks to simulate by self-hosting the function, managing Dependency Injection (DI) configuration, and invocation of the specified method.

The following is an example.

using var test = FunctionTester.Create<Startup>();
test.ReplaceHttpClientFactory(mcf)
    .Type<ServiceBusFunction>()
    .Run(f => f.Run2(test.CreateServiceBusMessageFromValue(new Person { FirstName = "Bob", LastName = "Smith" }), test.Logger))
    .AssertSuccess();

Generic Type

To test a component that relies on Dependency Injection (DI) directly without the runtime expense of instantiating the underlying host (e.g. ASP.NET Core) the GenericTester enables any Type to be tested.

using var test = GenericTester.Create().ConfigureServices(services => services.AddSingleton<Gin>());
test.Run<Gin, int>(gin => gin.Pour())
    .AssertSuccess()
    .AssertValue(1);

Additionally, where the TEntryPoint is specified and implements ConfigureApplication (.NET8.0 or above) this will be invoked automatically to perform any additional configuration.

using var test = GenericTester.Create<Startup>();
test.Run<Gin, int>(gin => gin.Pour())
    .AssertSuccess()
    .AssertValue(1);

public class Startup
{
    public void ConfigureApplication(IHostApplicationBuilder builder) => builder.Services.AddSingleton<Gin>();
}

DI Mocking

Each of the aforementioned test capabilities support Dependency Injection (DI) mocking. This is achieved by replacing the registered services with mocks, stubs, or fakes. The TesterBase enables using the Mock*, Replace* and ConfigureServices methods.

The underlying Services property also provides access to the IServiceCollection within the underlying test host to enable further configuration as required.


HTTP Client mocking

Where invoking a down-stream system using an HttpClient within a unit test context this should generally be mocked. To enable UnitTestEx provides a MockHttpClientFactory to manage each HttpClient (one or more), and mock a response based on the configured request. This leverages the Moq framework internally to enable. One or more requests can also be configured per HttpClient.

The following is an example.

var mcf = MockHttpClientFactory.Create();
mcf.CreateClient("XXX", new Uri("https://somesys"))
    .Request(HttpMethod.Get, "products/abc").Respond.WithJson(new { id = "Abc", description = "A blue carrot" });

using var test = ApiTester.Create<Startup>();
test.ReplaceHttpClientFactory(mcf)
    .Controller<ProductController>()
    .Run(c => c.Get("abc"))
    .AssertOK()
    .Assert(new { id = "Abc", description = "A blue carrot" });

The ReplaceHttpClientFactory leverages the Replace* capabilities discussed earlier in DI Mocking.

Every mocked request/response pair is also logged (via MockHttpClientHandler) at MockHttpClientFactory.LogLevel - LogLevel.Debug by default; change it via UseLogLevel(LogLevel) (or set LogLevel.None to disable this logging entirely):

var mcf = MockHttpClientFactory.Create().UseLogLevel(LogLevel.Information);

HTTP Client configurations

Any configuration specified as part of the registering the HttpClient services from a Dependency Injection (DI) perspective is ignored by default when creating an HttpClient using the MockHttpClientFactory. This default behavior is intended to potentially minimize any side-effect behavior that may occur that is not intended for the unit testing. For example, a DelegatingHandler may be configured that requests a token from an identity provider which is not needed for the unit test, or may fail due to lack of access from the unit testing environment.

// Startup service (DI) configuration.
services.AddHttpClient("XXX", hc => hc.BaseAddress = new System.Uri("https://somesys")) // This is HttpClient configuration.
    .AddHttpMessageHandler(_ => new MessageProcessingHandler()) // This is HttpMessageHandler configuration.
    .ConfigureHttpClient(hc => hc.DefaultRequestVersion = new Version(1, 2)); // This is further HttpClient configuration.

However, where the configuration is required then the MockHttpClient can be configured explicitly to include the configuration; the following methods enable:

Method Description
WithConfigurations Indicates that the HttpMessageHandler and HttpClient configurations are to be used. *
WithoutConfigurations Indicates that the HttpMessageHandler and HttpClient configurations are not to be used (this is the default state).
WithHttpMessageHandlers Indicates that the HttpMessageHandler configurations are to be used. *
WithoutHttpMessageHandlers Indicates that the HttpMessageHandler configurations are not to be used.
WithHttpClientConfigurations Indicates that the HttpClient configurations are to be used.
WithoutHttpClientConfigurations Indicates that the HttpClient configurations are to be used.
-- --
WithoutMocking Indicates that the underlying HttpClient is not to be mocked; i.e. will result in an actual/real HTTP request to the specified endpoint. This is useful to achieve a level of testing where both mocked and real requests are required. Note that an HttpClient cannot support both, these would need to be tested separately.

Note: * above denotes that an array of DelegatingHandler types to be excluded can be specified; with the remainder being included within the order specified.

// Mock with configurations.
var mcf = MockHttpClientFactory.Create();
mcf.CreateClient("XXX").WithConfigurations()
    .Request(HttpMethod.Get, "products/xyz").Respond.With(HttpStatusCode.NotFound);

// No mocking, real request.
var mcf = MockHttpClientFactory.Create();
mcf.CreateClient("XXX").WithoutMocking();

Times

To verify the number of times that a request/response is performed UnitTestEx support MOQ Times, as follows:

var mcf = MockHttpClientFactory.Create();
var mc = mcf.CreateClient("XXX", new Uri("https://d365test"));
mc.Request(HttpMethod.Post, "products/xyz").Times(Times.Exactly(2)).WithJsonBody(new Person { FirstName = "Bob", LastName = "Jane" })
    .Respond.WithJsonResource("MockHttpClientTest-UriAndBody_WithJsonResponse3.json", HttpStatusCode.Accepted);

Sequeuce

To support different responses per execution MOQ supports sequences. This capability has been extended for UnitTestEx.

var mcf = MockHttpClientFactory.Create();
var mc = mcf.CreateClient("XXX", new Uri("https://d365test"));
mc.Request(HttpMethod.Get, "products/xyz").Respond.WithSequence(s =>
{
    s.Respond().With(HttpStatusCode.NotModified);
    s.Respond().With(HttpStatusCode.NotFound);
});

Delay

A delay (sleep) can be simulated so a response is not always immediated. This can be specified as a fixed value, or randomly generated using a from and to.

var mcf = MockHttpClientFactory.Create();
var mc = mcf.CreateClient("XXX", new Uri("https://d365test"));
mc.Request(HttpMethod.Get, "products/xyz").Respond.Delay(500).With(HttpStatusCode.NotFound);
mc.Request(HttpMethod.Get, "products/kjl").Respond.WithSequence(s =>
{
    s.Respond().Delay(250).With(HttpStatusCode.NotModified);
    s.Respond().Delay(100, 200).With(HttpStatusCode.NotFound);
});

YAML/JSON configuration

The Request/Response configuration can also be specified within an embedded resource using YAML/JSON as required. The mock.unittestex.json JSON schema defines content; where the file is named *.unittestex.yaml or *.unittestex.json then the schema-based intellisense and validation will occur within the likes of Visual Studio.

To reference the YAML/JSON from a unit test the following is required:

var mcf = MockHttpClientFactory.Create();
mcf.CreateClient("XXX", new Uri("https://unit-test")).WithRequestsFromResource("my.mock.unittestex.yaml");

The following represents a YAML example for one-to-one request/responses:

- method: post
  uri: products/xyz
  body: ^
  response:
    status: 202
    body: |
      {"product":"xyz","quantity":1}

- method: get
  uri: people/123
  response:
    headers:
      ETag: Abc123
    body: |
      {
        "first":"Bob",
        "last":"Jane"
      }

The following represents a YAML example for a request/response with sequences:

- method: get
  uri: people/123
  sequence: 
    - body: |
        {
          "first":"Bob",
          "last":"Jane"
        }
    - body: |
        {
          "first":"Sarah",
          "last":"Johns"
        }

Note: Not all scenarios are currently available using YAML/JSON configuration.

The same YAML/JSON schema/file can also be loaded via the shared IHttpMockClient.WithRequestsFromResourceAsync - so a single embedded resource can configure both Tier 1's MockHttpClient above and Tier 2/3's AspireHttpMockClient (see Aspire multi-host testing below) identically:

IHttpMockClient client = mcf.CreateClient("XXX", new Uri("https://unit-test")); // or tester.HttpMock("erp") for Aspire.
await client.WithRequestsFromResourceAsync<MyTestClass>("my.mock.unittestex.yaml");

One intentional difference versus the native WithRequestsFromResource above: where a request entry omits body entirely, this shared loader matches any body (equivalent to body: ^), rather than Tier 1's native "no body at all" semantics - the shared interface has no means to express the latter.


Aspire multi-host testing

Everything above (ApiTester, FunctionTester, GenericTester, etc.) hosts a single system/service in-process via WebApplicationFactory - ideal for intra-domain testing where you want deep, per-component control (DI replacement, mocked HttpClients) of one service in isolation.

Sometimes, though, you genuinely need to prove that two or more real, separately-hosted services interact correctly over the network (inter-domain testing) - for example a .NET Aspire distributed application where a "shopping" API calls a "products" API. For this, the UnitTestEx.Aspire package provides AspireTester, which spins up the entire AppHost - every project, container and executable resource it declares - as separate, real OS processes wired together with Aspire's actual service discovery, exactly as they'd run in production. This is deliberately a second, opt-in tier rather than an extension of the first: pick the tier per test based on what you're actually trying to prove, don't force a hybrid.

This isn't a UnitTestEx-specific compromise - it matches Aspire's own guidance verbatim:

"If your goal is to test a single project in isolation, run components in-memory, or mock external dependencies, consider using WebApplicationFactory<T> instead." — aspire.dev/testing/overview

await using var tester = AspireTester.Create<Projects.MyAppHost>();

await tester.WaitForResourceAsync("shopping");

tester.Http("shopping")
    .Run(HttpMethod.Get, "orders/123")
    .AssertOK()
    .AssertValue(new { id = "123", product = "Widget" });

External-to-the-solution dependencies (an email/notification provider, an identity/auth service, an ERP system, a payment gateway, etc.) still need to be mocked - a real inter-domain test proves your services talk to each other correctly, not that a third-party's sandbox environment is up. UnitTestEx's recommended pattern is to self-host WireMock.Net as an ordinary Aspire project resource (AddProject) - one small console app per external system, added to your own solution - rather than the official WireMock.Net.Aspire package's container resource (AddWireMock). UnitTestEx does not ship this host as a package (there's nothing to install or version); UnitTestEx.Aspire.MockHost is a template you copy into your own solution (referencing UnitTestEx.Aspire, which ships both the JsonElementComparerMatcher type and the WireMockConsole.RunAsync helper used below, so there's no matcher code - or port/custom-matcher/graceful-shutdown boilerplate - to write yourself):

// MockApis/Program.cs
await WireMockConsole.RunAsync(settings => WireMockServer.Start(settings));

WireMockConsole.RunAsync reads the PORT environment variable Aspire assigns, builds an AspireWireMockServerSettings (a plain WireMockServerSettings subclass - WireMock.Net deliberately leaves it unsealed - that adds UnitTestEx-specific configuration as a single, discoverable extension point rather than an ever-growing list of RunAsync parameters) with JsonElementComparerMatcher already registered, invokes your factory to start the actual server (any IWireMockServer - typically WireMockServer.Start), then blocks gracefully until Aspire stops the process (Ctrl+C locally, SIGTERM in orchestration), disposing the server on the way out. It also registers a WireMockRequestResponseLogger (giving Tier 2/3 parity with Tier 1's MockHttpClientHandler request/response logging above) that logs every genuine stubbed request/response pair - not the admin API calls used to configure mappings - through the resource's own console output at AspireWireMockServerSettings.RequestResponseLogLevel (LogLevel.Information by default), so it is captured and drained the same way as any other resource log entry (see Checkpoint/Delay/ErrorWhenLogContains below). Set a different level (or LogLevel.None to disable) directly on the settings your factory receives:

await WireMockConsole.RunAsync(settings =>
{
    settings.RequestResponseLogLevel = LogLevel.Debug;
    return WireMockServer.Start(settings);
});
// AppHost.cs
var email = builder.AddMockHostProject<Projects.MockApis>("email");
var auth  = builder.AddMockHostProject<Projects.MockApis>("auth");
var erp   = builder.AddMockHostProject<Projects.MockApis>("erp");

builder.AddProject<Projects.MyApi>("api")
    .WithMockHostEnvironment("Email__BaseUrl", email, "http")
    .WithMockHostEnvironment("Auth__BaseUrl", auth, "http")
    .WithMockHostEnvironment("Erp__BaseUrl", erp, "http");

AddMockHostProject/WithMockHostEnvironment (both extension methods on UnitTestEx.Aspire, in the Aspire.Hosting namespace alongside Aspire's own AddProject/WithEnvironment) exist because each mock resource is test-only and must never appear in a published manifest - the JSON resource graph aspire publish (or dotnet run --publisher manifest) emits for deployment tooling (e.g. Azure Developer CLI) to turn into real infrastructure; a mock host has no production equivalent, so including it there would make deployment tooling try to provision it as if it were real. AddMockHostProject only actually adds the resource when IDistributedApplicationBuilder.ExecutionContext.IsRunMode is true (returning null in publish mode instead), and WithMockHostEnvironment accepts that potentially-null result directly, no-op'ing rather than requiring an if (email is not null) guard at every call site.

Being just our own process (not a published, off-the-shelf binary), it can register JsonElementComparerMatcher - a custom IMatcher, shipped as part of UnitTestEx.Aspire, that delegates JSON body matching to UnitTestEx's own JsonElementComparer - via WireMockServerSettings.CustomMatcherMappings. This gives Tier 2/3 JSON body matching genuine parity with Tier 1 (semantic value coercion for dates, GUIDs and numbers), rather than being limited to WireMock.Net's own textual JsonMatcher/JsonPartialMatcher. It also drops the Docker/Podman requirement entirely - it's an ordinary .NET console app, so there's nothing to pull or run as a container. The official WireMock.Net.Aspire container resource (AddWireMock) remains fully supported for teams already standardized on that package - AspireTesterBase.HttpMock works against either resource type identically, since request matching is driven by the resource's admin API rather than its hosting mechanism.

Each mock resource is a genuine, isolated WireMock.Net process - one per external system, so stubs configured for "email" can never leak into "auth" or "erp". Within a test, AspireTesterBase.HttpMock(resourceName) returns a fluent AspireHttpMockClient for the named resource:

await using var tester = AspireTester.Create<Projects.MyAppHost>();

await tester.WaitForResourceAsync("api");
await tester.WaitForResourceAsync("erp");

var stub = await tester.HttpMock("erp")
    .Request(HttpMethod.Get, "products/abc")
    .Respond.WithJsonAsync(new { id = "Abc", description = "A blue carrot" });

tester.Http("api")
    .Run(HttpMethod.Get, "Product/abc")
    .AssertOK()
    .AssertValue(new { id = "Abc", description = "A blue carrot" });

await stub.VerifyAsync();

The fluent configuration API is intentionally near-identical to Tier 1's MockHttpClientFactory above (both implement the shared IHttpMockClient/IHttpMockRequest/IHttpMockResponse interfaces) - a helper method written once against these interfaces can configure request/response stubbing identically regardless of which tier it's handed. The main differences are that the terminal With* methods here are asynchronous (AspireHttpMockClient performs a real HTTP call to the WireMock.Net server's admin API to register each mapping) and must be awaited, and the underlying JSON comparison/sequence-exhaustion semantics are WireMock.Net's own (not identical to Tier 1's) - unless you use WithJsonBodyUsingUnitTestExComparer instead of WithJsonBody. As with Tier 1, Request's requestUri does not need a leading / - one is added automatically where absent, since WireMock.Net's admin API rejects a path that doesn't start with one:

var stub = await tester.HttpMock("erp")
    .Request(HttpMethod.Post, "orders")
    .WithJsonBodyUsingUnitTestExComparer(new { id = "Abc", occurredAt = "2024-01-01T00:00:00Z" }) // matches "2024-01-01T00:00:00.000+00:00" too - same instant, same as Tier 1
    .Respond.WithJsonAsync(new { id = "Abc", status = "Accepted" });

Note: WithJsonBodyUsingUnitTestExComparer requires the target resource to have registered JsonElementComparerMatcher (i.e. a self-hosted resource per the template above) - the official WireMock.Net.Aspire container resource has no way to load a custom .NET matcher type. Where WireMock's own strict, non-semantic textual matching is instead wanted against a self-hosted resource, configure JsonElementComparerOptions.ValueComparison to JsonElementComparison.Exact rather than falling back to WithJsonBody.

The same shared interface also brings across Tier 1's YAML/JSON configuration - await tester.HttpMock("erp").WithRequestsFromResourceAsync<MyTestClass>("my.mock.unittestex.yaml") loads the exact same embedded resource schema against a real WireMock.Net resource.

Pre-seeding stubs from AppHost.cs

tester.HttpMock(resourceName) requires an AspireTester - fine for tests, but not when a developer just wants to aspire run/dotnet run the AppHost directly (exploratory/manual use, no test in sight) - any un-stubbed external dependency will simply fail with a connection error. The DistributedApplication.HttpMock(resourceName, endpointName?, jsonComparerOptions?) extension method (also on UnitTestEx.Aspire, alongside AddMockHostProject/WithMockHostEnvironment) exposes the exact same fluent AspireHttpMockClient API directly against a started DistributedApplication - no tester required - so AppHost.cs itself can pre-seed sensible default stubs:

// AppHost.cs
var app = builder.Build();
await app.StartAsync();

await app.WaitForResourceAsync("erp");
await app.HttpMock("erp")
    .Request(HttpMethod.Get, "products/abc")
    .Respond.WithJsonAsync(new { id = "Abc", description = "A blue carrot" });

await app.WaitForShutdownAsync();

This replaces the simpler await app.RunAsync(); one-liner with the equivalent StartAsync/WaitForShutdownAsync pair (standard, supported Aspire usage) so there's a point after start-up, but before the host blocks, to seed mappings. A test can still layer its own stubs over these defaults (or ResetAsync() first to clear them) via tester.HttpMock(...) as normal - the AppHost's stubs are just a starting point, not a constraint on what a test may configure.

Important: this pre-seeding code only ever runs for a real aspire run/dotnet run of the AppHost - it does not run under an AspireTester-driven test. AspireTester/AspireTesterBase build the AppHost via Aspire's own DistributedApplicationTestingBuilder.CreateAsync<TAppHost>(), which intercepts the AppHost's Program.cs at builder.Build() and hands the (still-unbuilt) builder straight back to the test - none of AppHost.cs's own code after that line (the StartAsync/HttpMock/WaitForShutdownAsync block above) is ever reached when driven via a test, so a test relying solely on it for a stub it actually depends on will fail with a genuine (not un-stubbed-but-otherwise-passing, and not flaky/racy) connection or 404 error every time. A test that needs the same stub must register it itself, most naturally via BeforeStart/AfterStart (the latter, once the mock host resource is actually up):

await using var tester = AspireTester.Create<Projects.MyAppHost>()
    .AfterStart(async app =>
    {
        await app.WaitForResourceAsync("erp");
        await app.HttpMock("erp")
            .Request(HttpMethod.Get, "products/abc")
            .Respond.WithJsonAsync(new { id = "Abc", description = "A blue carrot" });
    });

Failing on unexpected resource error logs

A real inter-domain test can pass its own assertions while a background/hosted service (or a request that was never explicitly checked via Http) quietly logs an error or worse elsewhere in the DistributedApplication - ErrorWhenLogContains catches this by continuously watching every resource's forwarded log output for entries at or above a given LogLevel (Error by default) and failing the test the moment one appears:

await using var tester = AspireTester.Create<Projects.MyAppHost>()
    .ErrorWhenLogContains(exclude: ["*a known, benign warning*"]); // Optional: only needed to change the level, or add exclude/include patterns.

await tester.WaitForResourceAsync("shopping");

tester.Http("shopping").Run(HttpMethod.Get, "orders/123").AssertOK();

tester.Checkpoint("Final log check."); // Recommended: drains and checks any activity not otherwise seen by Http/Delay above.

This is enabled by default at LogLevel.Error for every AspireTester - there is no need to call ErrorWhenLogContains at all unless you want to change the minimumLevel, add wildcard (*/?) exclude/include patterns, or opt out entirely via ErrorWhenLogContains(LogLevel.None) (needed for a test that deliberately induces a resource-level error as its own subject, e.g. asserting a 500 response). exclude is the common case - known/expected noise that should not trigger a failure despite otherwise qualifying; a match here always wins. include is the rarer, opposite case - it narrows rather than widens what is checked, requiring an otherwise-qualifying entry to also match at least one include pattern to be reported (leave it null/empty, the default, to check every qualifying entry regardless of text). Each call fully replaces the prior configuration (level, exclude and include alike) rather than merging with it - pass the complete desired state each time. A resource log entry is only checked once drained - by Checkpoint, Delay or Http - so a trailing Test.Checkpoint("Final log check.") at the end of a test is recommended to also catch activity (e.g. shutdown-time logging) that nothing else happens to drain.

Important: include/exclude only ever narrow what counts as a violation of the streaming check above - they are not a "was this text logged" positive assertion, and there is no aggregate, end-of-test tally of include patterns. If an include pattern never matches anything, the test simply never fails because of it; nothing confirms it was actually hit. For that - "assert this text was (or was not) logged, by any resource, at any point" - use AssertLogContains/AssertLogNotContains instead (below).

Asserting expected resource log content

Unlike ErrorWhenLogContains above (a continuous check applied only as entries are subsequently drained), AssertLogContains/AssertLogNotContains are immediate, one-shot checks against every resource log entry captured for the lifetime of the test so far - drained or not, across every resource by default (or scoped to one via the optional resourceName):

await using var tester = AspireTester.Create<Projects.MyAppHost>();

await tester.WaitForResourceAsync("shopping");

tester.Http("shopping").Run(HttpMethod.Get, "orders/123").AssertOK();

tester.AssertLogContains("Order 123 processed successfully."); // Across every resource.
tester.AssertLogContains("Order 123 processed successfully.", "shopping"); // Scoped to just the 'shopping' resource.
tester.AssertLogNotContains("*unexpected*");

Because every resource log entry is retained for the whole test (not just what has been drained), there is no "did I check at the right moment" ambiguity to reason about - call this whenever (and as many times as) needed, typically right after whatever action is expected to have produced the entry; no trailing Checkpoint is required first (though calling it after one is perfectly fine too). Both accept the same wildcard (*/?), case-insensitive "contains" pattern matching as ErrorWhenLogContains's exclude/include.

Resetting captured logs

Every resource log entry captured is retained for the lifetime of the underlying AspireTester instance, not just a single test - normally a non-issue, since each test creates and disposes its own instance. If instead you deliberately reuse one AspireTester/DistributedApplication host across multiple tests (e.g. via a shared fixture, to avoid repeatedly paying its start-up cost), an earlier test's log activity would otherwise still be visible to a later test's ErrorWhenLogContains/AssertLogContains/AssertLogNotContains checks. Call ResetLogs() to discard everything captured so far - typically as the first line of such a test:

tester.ResetLogs(); // Discards all previously captured resource log entries (e.g. from a prior test sharing this host).

tester.Http("shopping").Run(HttpMethod.Get, "orders/123").AssertOK();

tester.AssertLogContains("Order 123 processed successfully."); // Only sees activity logged since the reset above.

This is deliberately explicit rather than automatic - unlike Tier 1, which resets its equivalent captured state automatically per Act (there being a single, well-defined Act boundary to hang it off), Tier 2/3 has no such boundary, and background/hosted-service activity occurring between calls is exactly what this capture exists to surface; an automatic reset would risk silently discarding it. ResetLogs() only clears previously captured log data - it does not reset any ErrorWhenLogContains configuration (level, exclude, include).

Note: Aspire-hosted resources are real OS processes (containers, where you opt into one), so AspireTester tests are inherently slower than the in-process Tier 1 testers - use them where the inter-process interaction itself is what needs proving.

Note: Since Tier 2/3 resources are real, reachable URLs (not in-process fakes), a UI/frontend resource hosted in the AppHost can be driven directly with Playwright - this is an ordinary consequence of the resources being real processes, not a UnitTestEx-specific feature; see Microsoft's own Aspire + Playwright guide for the pattern.


Expectations

By default UnitTestEx provides out-of-the-box Assert* capabilities that are applied after execution to verify the test results. However, by adding the UnitTestEx.Expectations namespace in a test additional Expect* capabilities will be enabled (where applicable). These allow expectations to be defined prior to the execution which are automatically asserted on execution.

The following is an example.

using var test = ApiTester.Create<Startup>();
test.Controller<PersonController>()
    .ExpectStatusCode(System.Net.HttpStatusCode.BadRequest)
    .ExpectErrors(
        "First name is required.",
        "Last name is required.")
    .Run(c => c.Update(1, new Person { FirstName = null, LastName = null }));

appsettings.unittest.json

UnitTestEx supports the addition of a appsettings.unittest.json within the test project that will get loaded automatically when executing tests. This enables settings to be added or modified specifically for the unit testing external to the referenced projects being tested.

Additionally, this can also be used to change the default JSON Serializer for the tests. Defaults to UnitTestEx.Json.JsonSerializer (leverages System.Text.Json). By adding the following setting the default JSON serializer will be updated at first test execution and will essentially override for all tests. To change serializer for a specific test then use the test classes to specify explicitly.

{
  "DefaultJsonSerializer": "UnitTestEx.MSTest.Test.NewtonsoftJsonSerializer, UnitTestEx.MSTest.Test"
}

Examples

As UnitTestEx is intended for testing, look at the tests for further details on how to leverage:

Note: There may be some slight variations in how the tests are constructed per test capability, this is to account for any differences between the frameworks themselves. For the most part the code should be near identical.


Other repos

These other Avanade repositories leverage UnitTestEx to provide unit testing capabilities:

  • CoreEx - Enriched capabilities for building business services by extending the core capabilities of .NET.
  • Beef - Business Entity Execution Framework to enable industralisation of API development.

License

UnitTestEx is open source under the MIT license and is free for commercial use.


Contributing

One of the easiest ways to contribute is to participate in discussions on GitHub issues. You can also contribute by submitting pull requests (PR) with code changes. Contributions are welcome. See information on contributing, as well as our code of conduct.


Security

See our security disclosure policy.


Who is Avanade?

Avanade is the leading provider of innovative digital and cloud services, business solutions and design-led experiences on the Microsoft ecosystem, and the power behind the Accenture Microsoft Business Group.

About

UnitTestEx provides .NET testing extensions to the most popular testing frameworks (MSTest, NUnit and Xunit) specifically to improve the testing experience with ASP.NET controller, and Azure Function, execution including underlying HttpClientFactory mocking.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

28 stars

Watchers

3 watching

Forks

Releases

Used by

Contributors

Languages