Skip to main content

Swap a dependency for one test

In Level 5 one outbox test replaced the API's event publisher with a failing one, and the rest of the path stayed real. This lesson teaches the seam behind it: what a substitution changes, what it costs, and the cases where the framework refuses.

Level 6, lesson 4About 10 minutes
By the end
  • Replace a service in the application under test for one test with context.Override.
  • Read the substitution and the dedicated server it builds in the trace.
  • Name the cases where a substitution skips or throws, and why.
Before you start
  • Write an integration (lesson 3).
  • The sample cloned. The substitution run needs nothing else installed.

The scenario​

The outbox tests of the OpenCSMS product swap IEventPublisher for one test: the replacement fails a bounded number of publishes and delegates every later attempt to the real publisher. Nothing else in that path is faked.

The seam is context.Override. It needs an application the run hosts in-process, because the replacement is registered in the application's own container. Where there is no container the framework says so instead of pretending: the attribute form skips, the body form throws. This lesson runs a substitution in the sample and reads both outcomes.

The seam​

The fault injection lesson used it in one line:

Proto.Context.Override<IEventPublisher>(publisher);

The same seam has an attribute form and a type form:

[ReplaceService<IEmailSender>(typeof(RecordingEmailSender))]
[FailDependency<IEmailSender>]

A test can declare substitutions on the class, on the method, and in the body. They combine into one set. The later registration wins. The test then runs against a dedicated server built with that union before it starts. The run's shared server is never reconfigured, one test's replacement cannot leak into the next, and parallel tests that substitute differently each get their own instance. A failed dependency is the error-path twin: resolving it throws instead of returning a fake.

Run one in the sample​

The sample's application computes every stamp from TimeProvider, so a frozen provider is a substitution whose effect is visible in the response. Add this test:

FrozenClockJourney.cs3 notes
1[Application(NorthstarTargets.Api)]
2[NorthstarMember]
3public sealed class FrozenClockJourney
4{
5[ProtoTest]
6[SignedInAs]
7public async Task TheApplicationUsesTheSubstitutedProvider()
8{
9var frozen = new DateTimeOffset(2030, 1, 15, 12, 0, 0, TimeSpan.Zero);
10Proto.Context.Override<TimeProvider>(() => new FrozenTimeProvider(frozen));
11
12var name = $"frozen-{Proto.Context.TestId}";
13using var created = await Proto.Context.Rest()
14.Body(new CreateProjectRequest(name))
15.PostAsync("/api/v1/projects");
16
17var project = created
18.Should.HaveHttpStatus(HttpStatusCode.Created)
19.ReadRequired<ProjectResponse>();
20Assert.That(
21project.CreatedAtUtc,
22Is.EqualTo(frozen),
23"the application stamped the project from the substituted provider");
24}
25
26private sealed class FrozenTimeProvider(DateTimeOffset now) : TimeProvider
27{
28public override DateTimeOffset GetUtcNow() => now;
29}
30}
  1. Apply it before the first request

    The dedicated server is built when the override lands and application services are resolved after it. The factory form builds the instance lazily; the instance and type forms exist too.

  2. The test did not change

    It still sends one request through the composed client. Only the application's own provider changed.

  3. The assertion proves the app resolved it

    The stamp comes from the application, so it can only equal the frozen value if the replacement reached it.

Save the file above as FrozenClockJourney.cs in the sample project. It is your own test, not a committed class: the sample's committed clock coverage lives in ClockJourney. Then run it with dotnet test samples/Northstar.ProtoTest --filter "FullyQualifiedName~FrozenClockJourney".

What the run records​

The substitution is a traced operation, and the dedicated server says what it was built with. From a run of the sample with the test above:

RecordReading
service.substitute · TimeProvider, 53.3 ms, with service.type, service.server and service.replacementone operation per replacement, linked to the server entity
server entity change substituted: aspnetcore.server.substituted: true, aspnetcore.server.substitutions: System.TimeProviderthe dedicated instance records what it carries
the response artifact: createdAtUtc: 2030-01-15T12:00:00+00:00the application really resolved the frozen provider
Initialize · ASP.NET Core server · Northstar under the substitution, reused: falsethe dedicated instance is started for this test, not the shared one
the run's shared server initialization in setup: 291.2 ms; the dedicated start inside the substitution: 53.3 msthe substituting test pays a second start

Durations vary with the machine; the names and the states do not.

Where it cannot substitute​

The seam is only as wide as the in-process server it threads through. The attribute form gates and skips; the body form throws:

CaseWhat happens
The selected application is hosted in-processthe substitution runs; the test gets a dedicated server
[ReplaceService] or [FailDependency] with Server = "Api"gates on that named in-process server
Neither, with a selected applicationgates on the selected application, falling back to Default
The selected application is published, BaseUrl setthe attribute skips with a reason naming the application; another live server does not keep the gate open
A container or loopback applicationno service container to reach; the attribute skips
CaseWhat happens
Body Override, application publishedthrows: Application 'Api' runs at '<address>', so its services cannot be substituted, naming the configured ProtoTest:Applications:Api:BaseUrl key
Body Override, container or loopback applicationthrows, naming the missing AddAspNetCoreServer registration
The test resolved application services firstthat scope stays on the shared server; apply the override before the first resolution
A failed dependency resolved while the server startsthe setup fails with the application's own exception; prefer failing services resolved per request

The replacement lives as a singleton of the dedicated server and is released with the test.

To see the skip in the sample, add the attribute form and run it against an address that hosts nothing:

[ProtoTest]
[SignedInAs]
[ReplaceService<TimeProvider>(typeof(FrozenProvider))]
public Task TheSubstitutionIsRefusedOutOfProcess() => Task.CompletedTask;

public sealed class FrozenProvider : TimeProvider
{
public override DateTimeOffset GetUtcNow() => new(2030, 1, 15, 12, 0, 0, TimeSpan.Zero);
}
$env:ProtoTest__TargetUrl = "http://127.0.0.1:5099"
dotnet test samples/Northstar.ProtoTest --filter "FullyQualifiedName~TheSubstitutionIsRefusedOutOfProcess"
Remove-Item Env:ProtoTest__TargetUrl

The runner reports one skipped test with the reason: this test substitutes TimeProvider on the Default application, which this run does not host in-process, so register it with AddAspNetCoreServer or name the in-process server in a mixed run. The probe carries no [Application], so the gate falls back to Default. Nothing ran and nothing failed, which is the honest outcome for a substitution that cannot be served.

Checkpoint​

A suite hosts Api in-process and points Web at a published address. A test carries [ReplaceService&lt;T&gt;(...)] with no Server set and selects Web. What does the runner report, and why does the live Api server not change it?

Verify
Read Substituting services per test on the ASP.NET Core page, then run the attribute form with ProtoTest__TargetUrl=http://127.0.0.1:5099. The runner prints the skip and its reason.

What you learned​

  • A substitution replaces a service for one test; the run's shared server never sees it and the next test starts clean.
  • The test runs against a dedicated server built with the union of its substitutions, so it pays a second server start.
  • Substitution needs the in-process server: attributes gate and skip, and Override throws naming the address or the missing registration.

Keep exploring​