Skip to main content

Time and cancellation

Some expectations take time: they await a task or an asynchronous delegate, enumerate an IAsyncEnumerable<T>, wait for a condition, an event or a callback, or measure how long a delegate runs. A timeout or a CancellationToken limits how long such an expectation may take, and every expectation reports them the same way.

Timeout​

You can set a global timeout that is applied for all expectations, e.g. in an assembly-level setup (see global defaults):

using aweXpect.Customization;

// Sets a global timeout of 10 seconds
Customize.aweXpect.Global.Settings().TestCancellation
.Set(TestCancellation.FromTimeout(TimeSpan.FromSeconds(10)));

Like all customization options, the setter returns an IDisposable that removes the timeout again on Dispose(). Without Global, the timeout only applies to the current async flow, e.g. to a single test.

You can also apply a timeout on individual expectations, using the WithTimeout(TimeSpan) method:

IAsyncEnumerable<Track> playlist = // ...
await Expect.That(playlist).All().Satisfy(track => track.PlayCount > 0)
.WithTimeout(TimeSpan.FromSeconds(10));
note

The tighter timeout wins. A local timeout that is longer than the global one, or than the limit of the expectation itself (e.g. ExecutesIn().AtMost(…) or Throws().Within(…)), does not loosen it, and calling WithTimeout more than once applies the shortest timeout. Timeout.InfiniteTimeSpan imposes no limit, and a negative timeout is rejected when the expectation is built.

CancellationToken​

You can set a global provider for getting a CancellationToken that is applied for all expectations:

// Uses the CancellationToken from the test context
Customize.aweXpect.Global.Settings().TestCancellation
.Set(TestCancellation.FromCancellationToken(() => TestContext.Current.CancellationToken));

The setter again returns an IDisposable that removes the provider on Dispose().

You can also apply a CancellationToken on individual expectations, using the WithCancellation(CancellationToken) method:

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1));
IAsyncEnumerable<Track> playlist = // ...
await Expect.That(playlist).All().Satisfy(track => track.PlayCount > 0)
.WithCancellation(cts.Token);
note

A local CancellationToken replaces the global one instead of being applied additionally. If you need both, pass a linked cancellation token.

The timeout and the CancellationToken are also forwarded to a delegate that accepts a CancellationToken parameter.

Waiting​

Some expectations wait for something to happen. Within(…) limits how long they wait, and without it they behave as follows:

ExpectationWithout Within(…)
Satisfies(…), CompliesWith(…) and their negationsdoes not wait
Eventually()retries until the expectations are met, at most DefaultEventuallyTimeout (30 s)
Triggered(…), DidNotTrigger(…)does not wait, only the events recorded so far count
Signaled()waits until the callback is signaled, at most DefaultSignalerTimeout (30 s)
DidNotSignal()always waits the full DefaultSignalerTimeout (30 s)

Both defaults can be changed in the settings. When a default applied, the failure message names the wait, e.g. has never recorded the callback within 0:30 or eventually is equal to 2 within 0:30.

A condition​

When an object changes in the background, Within(…) lets Satisfies or CompliesWith wait until the object meets the condition, and DoesNotSatisfy or DoesNotComplyWith until it no longer does:

using aweXpect.Chronology; // from the aweXpect.Chronology package

Track track = new() { IsPlayed = false };
List<Track> playlist = new();
// Start a background task that plays the track and fills the playlist

await Expect.That(track).Satisfies(x => x.IsPlayed).Within(2.Seconds());
await Expect.That(playlist).CompliesWith(x => x.HasCount().GreaterThanOrEqualTo(4)).Within(2.Seconds());

Eventually​

Some values only become correct after a short delay, e.g. because a background task is still running. Instead of waiting for a fixed amount of time, Eventually() re-evaluates a delegate until the expectations are met. Because only the subject is re-evaluated, all expectations work as usual, including And, Or and Because:

Track track = new();
// Start a background task that plays the track

await Expect.That(() => track.PlayCount).Eventually().IsGreaterThan(5);
await Expect.That(() => track.Title).Eventually().IsNotNull().And.StartsWith("Let");

The delegate is re-evaluated every DefaultCheckInterval (defaults to 100ms) until the timeout configured in DefaultEventuallyTimeout (defaults to 30s) expires. The last wait is shortened so that it never exceeds the timeout, which means that an interval that is longer than the timeout results in exactly two evaluations. You can overwrite the timeout per expectation with Within and the interval with CheckEvery, in either order:

using aweXpect.Chronology; // from the aweXpect.Chronology package

Track track = new();

await Expect.That(() => track.PlayCount).Eventually().Within(5.Seconds()).CheckEvery(50.Milliseconds())
.IsGreaterThan(3)
.WithTimeout(2.Seconds());

When the play count never exceeds 3, the retries end after 2 seconds, because the timeout is shorter than Within.

Within(Timeout.InfiniteTimeSpan) retries until the expectations are met or the expectation is canceled. A negative timeout or an interval that is not positive is rejected, and each of them can only be specified once.

An exception thrown by the delegate counts as an unmet expectation and is retried. When the timeout expires while the delegate is still throwing, the expectation fails and the last exception is reported as the cause of the failure.

WithTimeout does not change the timeout of the retries, but cancels the evaluation like everywhere else, so the tighter timeout wins: a WithTimeout or a global TestCancellation.FromTimeout that is shorter than Within ends the retries and fails the expectation with "did not finish within …", and a longer one does not extend them. A cancellation before the timeout expires (via WithCancellation or TestCancellation.FromCancellationToken) makes the expectation inconclusive instead of failed.

The timeout also bounds each evaluation: an evaluation that is still running when the timeout is used up is abandoned, even if the delegate ignores its CancellationToken, which is canceled at that point, and the expectation fails with "did not finish within …" and a TimeoutException as inner exception. The last evaluation, which is made when the timeout is used up, still gets one check interval (at most the timeout) to finish. A synchronous delegate cannot be interrupted, so for it the timeout is only checked between evaluations.

In addition to Func<T>, the asynchronous variant Func<Task<T>> is supported, and on .NET 8 or later also Func<ValueTask<T>>; each of them also accepts a CancellationToken. Returning the task directly also works with a language version older than C# 13 (see the delegates page):

Func<Task<int>> loadPlayCountAsync = () => Task.FromResult(6);

await Expect.That(() => loadPlayCountAsync()).Eventually().IsGreaterThan(5);

Events​

Within(…) waits up to the given time for the expected events and finishes successfully as soon as they are recorded:

using aweXpect.Chronology; // from the aweXpect.Chronology package
using aweXpect.Recording;

class Player
{
public event EventHandler? TrackStarted;
public void Play(string title) => TrackStarted?.Invoke(this, EventArgs.Empty);
}
Player player = new();
IEventRecording<Player> recording = player.Record().Events();

_ = Task.Delay(1.Seconds()).ContinueWith(_ => player.Play("Let It Be"));

await Expect.That(recording).Triggered(nameof(Player.TrackStarted)).Within(3.Seconds());

More precisely, it stops as soon as the outcome can no longer change, and otherwise waits for the whole timeout. For an expectation with an upper bound (DidNotTrigger, Never(), AtMost(2.Times()), Exactly(1.Times())) that means the opposite: it waits out the timeout to be sure no further event arrives, and returns early only when one event too many is recorded:

IEventRecording<Player> recording = player.Record().Events();

// Waits for 3 seconds and expects that no TrackStarted event is triggered in that time
await Expect.That(recording).DidNotTrigger(nameof(Player.TrackStarted)).Within(3.Seconds());

Callbacks​

Within(…) limits how long to wait for a callback to be signaled:

using aweXpect.Signaling;

Signaler<string> signaler = new();

await Expect.That(signaler).Signaled().Within(TimeSpan.FromSeconds(5))
.Because("it should take at most 5 seconds to complete");

Only expectations without an upper bound (e.g. AtLeast) can complete as soon as enough callbacks were signaled. All others, including DidNotSignal(), have to wait for the timeout to expire, because only then is the number of signals final.

A CancellationToken (WithCancellation) also ends the wait, but the signals received until then decide nothing, so the expectation is then inconclusive instead of failed or successful. Use Within(…) to limit how long to wait.

Execution time​

ExecutesIn() applies its upper bound as timeout: the maximum of AtMost, the end of the Between range, or the expected time plus the tolerance. A tighter timeout, e.g. from WithTimeout(…), still applies. A delegate that accepts a CancellationToken is canceled once the upper bound elapsed, and the expectation fails with "did not finish within …" instead of hanging. AtLeast has no upper bound and therefore applies no timeout. The duration of Throws().Within(…) is applied as timeout the same way.

The task of an asynchronous delegate is abandoned once the timeout elapsed, even if the delegate ignores or does not accept a CancellationToken, and the expectation fails the same way. A synchronous delegate cannot be interrupted and runs to completion, however long that takes; neither WithTimeout nor WithCancellation changes that. A task is already running when the expectation receives it, so only the duration that remains is measured.

Outcome​

A timeout and a cancellation are reported the same way by every expectation, whether it awaits a Task<T> subject, an asynchronous Whose member or delegate, enumerates an IAsyncEnumerable<T>, waits for a Signaler or for recorded events, or retries with Within(…) or Eventually():

  • When a timeout elapses (WithTimeout or TestCancellation.FromTimeout), the expectation fails with "did not finish within …" and a TimeoutException as inner exception.
  • When the CancellationToken is canceled (WithCancellation or TestCancellation.FromCancellationToken), the expectation is inconclusive: "could not be verified, because the evaluation was already canceled". It neither passes nor throws the OperationCanceledException, so e.g. DidNotSignal() does not pass because the cancellation ended the wait.
  • An OperationCanceledException that a delegate throws for its own reasons, while neither the timeout elapsed nor the CancellationToken was canceled, is an ordinary exception: e.g. DoesNotThrow() fails with "did throw an OperationCanceledException".

Awaited tasks​

A timeout or a cancellation also stops waiting for a task that the expectation awaits, such as a Task<T> subject or the task returned by an asynchronous delegate, even if it ignores the CancellationToken. A synchronous delegate cannot be abandoned and runs to completion. The outcome is the same whether the task was abandoned or reacted to the cancellation itself. With Eventually(), the timeout bounds each evaluation in the same way.

Async enumerables​

The CancellationToken of the expectation, which includes the timeout, is passed to an IAsyncEnumerable<T> subject, and the expectation stops waiting for the next item once it is canceled, even if the enumerable ignores the token. After that, the enumerable is not advanced any further.

An expectation that needs an item after the cancellation never reads the cancellation as the end of the enumerable, regardless of whether it occurs while waiting for an item or between two items. Expectations like HasCount or IsEmpty list the items received so far.