Swap a dependency for one test
The problem
You want to test what happens when one dependency misbehaves, or returns a fixed value, and keep everything else real. The outbox tests of the OpenCSMS product do this: they replace the event publisher for one test with one that fails a few times, then delegates to the real publisher. Nothing else on that path is faked.
The seam for this is Override. It needs the application hosted in-process, because the replacement goes into the application's own service container.
Do it
1. See the three forms
The fault injection lesson used the body form in one line:
Proto.Context.Override<IEventPublisher>(publisher);
There are also attribute forms, one that names a replacement type and one that makes the service fail:
[ReplaceService<IEmailSender>(typeof(RecordingEmailSender))]
[FailDependency<IEmailSender>]
2. Run a substitution in the sample
The sample's application computes every timestamp from TimeProvider, so a frozen provider shows its effect in the response. Save this 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.
1[Application(NorthstarTargets.Api)]2[NorthstarMember]3public sealed class FrozenClockJourney4{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));1112var name = $"frozen-{Proto.Context.TestId}";13using var created = await Proto.Context.Rest()14.Body(new CreateProjectRequest(name))15.PostAsync("/api/v1/projects");1617var project = created18.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}2526private sealed class FrozenTimeProvider(DateTimeOffset now) : TimeProvider27{28public override DateTimeOffset GetUtcNow() => now;29}30}
- Apply it before the first request
The override builds a dedicated server for this test.
- The request did not change
Only the application's own provider changed.
- The assertion proves it
The stamp equals the frozen value only if the replacement reached the application.
dotnet test samples/Northstar.ProtoTest --filter "FullyQualifiedName~FrozenClockJourney".The test passes. If the substitution had not reached the application, CreatedAtUtc would be the real time.
3. Read the substitution in the trace
From a run of the sample with the test above:
| Record | Reading |
|---|---|
service.substitute · TimeProvider, 53.3 ms | one operation per replacement |
Server change substituted, aspnetcore.server.substitutions: System.TimeProvider | the dedicated server records what it carries |
The response artifact: createdAtUtc: 2030-01-15T12:00:00+00:00 | the application used the frozen provider |
Initialize · ASP.NET Core server · Northstar, reused: false | a new server started for this test |
Durations vary with the machine. The names and the states do not.
4. Make it refuse
Add the attribute form below and run it against an address that hosts nothing, so the run cannot hold the application in-process:
[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. The reason names TimeProvider, the Default application, and the AddAspNetCoreServer registration it would need. A substitution that cannot be served skips instead of running against the wrong service.
What happened
An override does not touch the run's shared server. ProtoTest builds a dedicated server for that test with its substitutions, and releases it when the test ends. One test's replacement cannot leak into the next, and parallel tests that substitute differently each get their own server. The cost is a second server start.
When the application does not run inside the test process, the attribute form skips and the body form, Override, throws with the configuration key or registration it needs.
Check yourself
Two tests run in parallel. One overrides TimeProvider with a frozen clock. Which time does the other test's application use, and why?
reused: false server under the substitution with the shared server in the run's setup.The real time. The override runs on a dedicated server built for that one test. The shared server, which the other test uses, never sees the replacement.
Remember
- A substitution replaces a service for one test. The shared server never sees it, and the next test starts clean.
- The test runs against a dedicated server, so it pays a second server start.
- It needs the in-process server. Attributes skip when they cannot be served, and
Overridethrows.
Next: run the evidence loop with an agent.
Go deeper
- Substituting services per test: the full contract, named servers (
Server = "Api"), failed dependencies and every limit.