Testing & Reliable Design · Lesson 13

Cancellation as a requested outcome

Observe cancellation separately from successful values and unrelated faults.

Define the observable contract

Cancellation is a distinct operation outcome, not simply a successful value chosen to mean stopped. The requesting owner holds a CancellationTokenSource, while the operation receives a token and cooperates by checking it or passing it to supported work. This lesson cancels before starting the operation so the observation is independent of timer speed. It then verifies the token associated with the observed OperationCanceledException and the task's canceled state. No thread is aborted, no network request occurs, and the token's presence is not treated as proof that arbitrary code must stop automatically.

Walk through the real program

The source is canceled before ReadAsync is called. Inside that async method, ThrowIfCancellationRequested observes the owning token before the yield or return can occur. Awaiting the returned operation reaches the cancellation handler rather than printing three. The token comparison prints matching-token:True, and the final task-state observation prints canceled:True. Exceptions inside an async method are represented by its returned task, including this early cancellation path; the caller observes the outcome through await. The sample does not rely on a race between a delay and a later Cancel call.

Why the design is testable

A cancellation test should identify both the lifetime owner and the observation point. A task can be canceled while a related operation continues elsewhere, or a caller can stop waiting without canceling the work itself. Those are different designs. The current method explicitly checks its supplied token and has no separate background activity to clean up. Disposing the source at the end of the using scope releases that source's owned resources; disposal is not the cancellation request and is not the event that makes the task canceled in this program.

A boundary that matters

Catching every Exception and labeling it cancellation hides genuine faults. Even OperationCanceledException should be interpreted according to the operation's token and contract when several independent lifetimes exist. Do not let an unrelated canceled dependency silently masquerade as the user's requested cancellation. Also avoid assuming that a token check at the start guarantees responsiveness throughout a later long synchronous computation. Cooperation depends on actual observation points. A test of one pre-canceled path establishes that path, while in-progress cancellation and cleanup may require separate controlled orchestration cases.

Practice: isolate one behavior

First remove the explicit source.Cancel call. Predict that the operation completes with the successful value three and that its final IsCanceled observation is false. Hint: the catch body is skipped on normal completion, so its token-match line is not printed. Restore the baseline before adding another scenario. Then supply a token from a separately owned source and make the expected ownership distinction explicit instead of accepting any canceled task merely because the test has a handler for that exception family.

Practice: challenge another assumption

For a second exercise, add a controlled pending dependency and cancel while the operation is waiting through a supported token-aware API. Hint: use an explicit gate or registration contract, not an arbitrary small delay. Record where cancellation is observed and verify the intended cleanup or no-effect rule separately. Compare this with a faulted dependency that throws InvalidOperationException. In a real framework project, use its documented asynchronous exception or cancellation assertions and keep every started task awaited so the runner can report the actual outcome.

Review the evidence

The original two true observations concern a specific owned cancellation path. They do not prove arbitrary interruption, rollback, or a universal exception-classification policy. A reliable design names the requesting lifetime, forwards the token through cooperating layers, and distinguishes success, cancellation, and fault in its public contract. A reliable test controls the relevant boundary and checks the outcome without introducing a race. Keep those responsibilities visible when moving from a tiny browser-compatible example to a real service whose work and cleanup can outlive one caller's wait.

  • The source is canceled before the operation begins.
  • The baseline verifies both the owning token and canceled task state.
  • Cancellation, faults, and stopping a wait are distinct outcomes.

A working example

using System;
using System.Linq;
using System.Collections.Generic;
using System.Globalization;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;

using var source = new CancellationTokenSource();
source.Cancel();
Task<int> operation = ReadAsync(source.Token);
try { Console.WriteLine(await operation); }
catch (OperationCanceledException error)
{
    Console.WriteLine($"matching-token:{error.CancellationToken == source.Token}");
}
Console.WriteLine($"canceled:{operation.IsCanceled}");
async Task<int> ReadAsync(CancellationToken token)
{
    token.ThrowIfCancellationRequested();
    await Task.Yield();
    return 3;
}

Expected output

matching-token:True
canceled:True
Try this
Pre-cancel for a deterministic observation and verify the owning token.

Further reading