Course outline

ASP.NET Core / Advanced: Verification and Reliable Operations / Lesson 13

Testing and Evidence Boundaries

Choose unit, component, integration, provider, and deployment tests from the contract each must establish.

About 5 min readContent reviewed
On this page

Lesson goals

  • Derive independent boundary and failure expectations.
  • Verify rejected operations leave effects untouched.
  • Distinguish in-memory component execution from hosted HTTP and SQL tests.

A test needs a claim and an independent oracle

Begin with the behavior being promised, not the implementation line you want covered. Define status, payload, effects, ownership, and failure policy. An oracle should be a known result or independent property, not a copy of the production expression. Coverage measures executed code and cannot tell whether meaningful expectations were checked.

Use small synthetic fixtures with explicit providers, clocks, identities, and configuration. Avoid tests that silently read an existing configured application’s environment, credentials, or database. A deterministic failure is easier to diagnose than a broad end-to-end test whose external dependencies change independently.

Check that rejection happens before an effect

The first complete program invokes a local request delegate with invalid input and a counted synthetic effect. It requires 400 and zero committed calls. This verifies that delegate’s explicit guard; it does not exercise Minimal API binding, filters, or MVC automatic validation. The distinction makes the negative assertion useful without overstating integration coverage.

Include valid input, exact boundaries, missing input, malformed values, and the effect-failure path. A status-only test can pass after an unintended update. For provider-backed behavior, use an authorized isolated provider test rather than treating a List or dictionary as proof of transaction and constraint semantics.

csharp Verify a local rejection-before-effect contract
using System;
using System.Globalization;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;

int effects = 0;
RequestDelegate handler = context =>
{
    string? token = context.Request.Query["quantity"];
    if (!int.TryParse(token, NumberStyles.None, CultureInfo.InvariantCulture, out int value) || value is < 1 or > 10)
    { context.Response.StatusCode = 400; return Task.CompletedTask; }
    effects++;
    context.Response.StatusCode = 202;
    return Task.CompletedTask;
};
var context = new DefaultHttpContext();
context.Request.QueryString = new QueryString("?quantity=0");
await handler(context);
if (context.Response.StatusCode != 400 || effects != 0) throw new InvalidOperationException("rejection contract failed");
Console.WriteLine($"status={context.Response.StatusCode} effects={effects}");
Expected output
status=400 effects=0

The explicit delegate rejects zero before incrementing the effect counter; no binding pipeline or database runs.

A host integration test includes the actual composed application

WebApplicationFactory and TestServer can support isolated framework integration with the appropriate trusted test packages. Such tests can exercise routing, binding, filters, auth scheme fixtures, serialization, and response behavior. They still need deliberate dependency replacement and do not automatically validate a production proxy, TLS, real database, or cloud deployment.

The course does not install those packages or start a test host. Unexecuted integration fragments must stay non-runnable. A direct controller/result test is valuable, but the public contract also needs wrong-method, missing-route, unauthenticated, forbidden, and malformed-request cases at the framework boundary when that environment is available.

Test completion and disposal as observable properties

The second complete program injects an owned fake with a completion flag and observes its asynchronous use before disposal. It asserts both the useful result and the end of borrowing. This is a lifecycle unit, not evidence about EF or a network connection. A fake should expose the property under test without mirroring all implementation details.

Use explicit completion signals for tests that need scheduling control. A longer sleep does not fix a race or prove a worker stopped. Bounded test deadlines guard against hangs, while the implementation must still provide stop/join and cleanup. Parallel tests should not share mutable global fixtures.

csharp Observe completion before owned disposal
using System;
using System.Threading.Tasks;

var dependency = new FakeDependency();
int value;
using (dependency) { value = await dependency.ReadAsync(); }
if (value != 7 || !dependency.Completed || !dependency.Disposed) throw new InvalidOperationException("lifetime contract failed");
Console.WriteLine($"value={value} completed={dependency.Completed} disposed={dependency.Disposed}");

internal sealed class FakeDependency : IDisposable
{
    public bool Completed { get; private set; }
    public bool Disposed { get; private set; }
    public async Task<int> ReadAsync() { await Task.Yield(); if (Disposed) throw new ObjectDisposedException(nameof(FakeDependency)); Completed = true; return 7; }
    public void Dispose() => Disposed = true;
}
Expected output
value=7 completed=True disposed=True

The fake finishes reading 7 before disposal and the assertions verify both lifecycle flags.

Report what was tested and what remains outside it

Preserve source, framework versions, project options, inputs, expected outputs, and dependency substitutions. Separate compile checks, direct component runs, hosted integration, provider tests, and deployment verification in the report. A green local suite cannot certify every trust boundary or operational failure case.

Retain regressions for meaningful repaired behavior and run checks appropriate to changes. Broaden tests when new failures or unresolved risks justify it, rather than repeating expensive old workloads for appearances. Review the validation statement as carefully as the implementation so consumers do not mistake a narrow useful test for universal proof.

Common errors and fixes

A negative test checks status but ignores committed state.

Why it happens
The effect boundary is not part of the oracle.
How to fix it
Assert the intended rejection and absence of side effects.
Prevent it next time
Use a counted or controlled synthetic dependency.

A direct action test is labeled full API integration.

Why it happens
The tested layer is overstated.
How to fix it
Record exactly which framework components executed.
Prevent it next time
Keep separate integration coverage for public behavior.

Flaky cancellation tests add sleeps.

Why it happens
Timing guesses replace completion ownership.
How to fix it
Use explicit signals and bounded test guards.
Prevent it next time
Assert cleanup after observed completion.

Check your understanding

1. A test invokes a controller action and finds NotFoundResult. Which claims can it support and which require more?

Show worked solution

It supports the action branch and returned intent for that synthetic input. It does not establish route matching, binding, ApiController filters, auth, negotiated serialization, or actual 404 response delivery. Keep the unit test and add the appropriate isolated framework integration for those public behaviors. A useful narrow test should be described narrowly, not discarded or inflated.

2. Why should a fake repository test not be used to approve a new EF concurrency-token migration?

Show worked solution

The fake does not execute the target engine’s schema, provider translation, update predicates, transaction isolation, or conflict behavior. Use a reviewed migration and authorized isolated provider tests for those claims, while keeping domain conflict-policy unit tests separately. Passing one layer cannot supply missing evidence from another.

Key takeaways

  • A meaningful test states its claim, oracle, and effects.
  • Direct components, host integration, providers, and deployment need different evidence.
  • Async tests must observe completion before cleanup.

Official references