Skip to main content

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.

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 override builds a dedicated server for this test.

  2. The request did not change

    Only the application's own provider changed.

  3. The assertion proves it

    The stamp equals the frozen value only if the replacement reached the application.

Run it with 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:

RecordReading
service.substitute · TimeProvider, 53.3 msone operation per replacement
Server change substituted, aspnetcore.server.substitutions: System.TimeProviderthe dedicated server records what it carries
The response artifact: createdAtUtc: 2030-01-15T12:00:00+00:00the application used the frozen provider
Initialize · ASP.NET Core server · Northstar, reused: falsea 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?

Verify
Compare the reused: false server under the substitution with the shared server in the run's setup.

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 Override throws.

Next: run the evidence loop with an agent.

Go deeper​