Move the test clock
The problem
Northstar issues an invoice when a request finds that a subscription's billing period has ended. Testing that boundary requires the application to read a later time.
// Waiting on real time does not move this sample's test clock.
await Task.Delay(TimeSpan.FromSeconds(2));
This delay waits two real seconds, but the sample's test clock stays where it was. Increasing the delay would not cross the billing boundary.
Instead, the test moves its clock past the period end and sends a request. The application reads that time and issues the invoice.
Do it
1. Let the application read the test's clock
Use the sample's default local configuration for this lesson. Follow ClosingTheBillingPeriodIssuesTheInvoiceOnTheTestClock in samples/Northstar.ProtoTest/ClockJourney.cs, or inspect the linked recording.
The setup class starts the API inside the test process. In AddApplication, where the setup class registers the API, this excerpt adds the in-process server:
app.AddAspNetCoreServer<NorthstarProgram>(configureWebHost: webHost =>
ConfigureHostedApplication(webHost, run));
The sample already configures this in Setup.cs. The server supplies a TimeProvider that reads the current test's clock while handling its requests.
Northstar's billing code uses that provider. Code that reads DateTimeOffset.UtcNow directly would still see real time.
2. Ask the application where the period ends
using var subscription = await Proto.Context.Rest().GetAsync("/api/v1/subscription");
var current = subscription
.Should.HaveHttpStatus(HttpStatusCode.OK)
.ReadRequired<SubscriptionResponse>();
The response supplies CurrentPeriodEndUtc. The test uses that boundary rather than assuming how many days the month contains.
3. Move the clock just past that moment
// Move the test clock past the period end; the application reads the same clock.
Proto.Context.Clock.Advance(
current.CurrentPeriodEndUtc - Proto.Context.Clock.GetUtcNow() + TimeSpan.FromSeconds(1));
The delta is the time left in the period plus one second, so the clock lands beyond the boundary. Advance updates the clock without waiting for that duration to pass.
4. Read what the application did
using var invoices = await Proto.Context.Rest()
.GetAsync("/api/v1/invoices", new { status = InvoiceStatuses.Open });
var invoice = invoices
.Should.HaveHttpStatus(HttpStatusCode.OK)
.ReadRequired<CursorPage<InvoiceResponse>>()
.Items
.Single();
This request makes Northstar evaluate the period boundary and issue the invoice. Advancing the clock alone did not execute that billing logic.
The test expects exactly one open invoice. It then checks IssuedAtUtc against current.CurrentPeriodEndUtc, rather than the advanced time one second later. It also checks the open status and a line description containing Growth.
These NUnit assertions are in samples/Northstar.ProtoTest/ClockJourney.cs.
5. Open the trace
To run the journey yourself, use this command from the repository root:
dotnet test samples/Northstar.ProtoTest --filter "FullyQualifiedName~ClosingTheBillingPeriodIssuesTheInvoiceOnTheTestClock"
Expect one passed test in the default local configuration. Its trace appears under samples/Northstar.ProtoTest/bin/Debug/net8.0/TestResults/.
Or download l3-clock-window.prototrace and open it in the
viewer. Select ClosingTheBillingPeriodIssuesTheInvoiceOnTheTestClock. The supplied recording contains:
| Entry | What it shows |
|---|---|
clock.advance on test.execution, "Clock advanced by 30:0:00:01" | the delta, from the test side |
http.request REST GET /api/v1/subscription, 141.9 ms, HTTP 200 | the period the test read |
Northstar.Domain invoice.issue, reported by the application | the application closed the period |
http.request REST GET /api/v1/invoices, 68.4 ms, HTTP 200 | the invoice the test read |
test.execution, 233.7 ms | the test body, excluding setup and teardown |
The recorded test body took 233.7 ms while its clock moved a month and one second.
What happened
The trace records the clock advance as an event on test.execution. The test updates an in-memory clock. The next request lets the application observe the new time.
Each test gets its own clock, which starts at the run's time. Advancing one test's clock does not advance another's. On each request, the in-process server uses the test id to find the right clock.
The clock only changes TimeProvider.GetUtcNow(). Timers and delays still use real time, and an application outside the test process, such as a container, needs its own way to control time.
Check yourself
The clock advance in the trace reads 30:0:00:01. Why does the test compute the delta from the period end instead of advancing a fixed number of days?
clock.advance event on the test execution.The application supplies the period end, so the test does not assume a month length. Moving one second past it makes the next invoice request cross the boundary.
That request issues the invoice. The assertion compares IssuedAtUtc with the original period end, not with the advanced clock value.
Remember
- Each test has its own clock. The in-process request reads it through
TimeProvider. - Advancing the clock does not wait for the requested duration. The trace records the delta.
- The next request triggers Northstar's billing logic. Sleeping does not move this test clock.
Go deeper
- Test time: the clock API, the bridge into the application, and the attributes that skip a clock-dependent test.
- Next lesson: Wait for readiness, not for time.