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 That(playlist).All().Satisfy(track => track.PlayCount > 0)
.WithTimeout(TimeSpan.FromSeconds(10));
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.
How WithTimeout applies to Expect.ThatAll and Expect.ThatAny is described in
combining expectations.
Repeated and infinite timeouts
Unlike other options, calling WithTimeout more than once applies the shortest timeout. Timeout.InfiniteTimeSpan
imposes no limit.
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().
The global timeout and the global CancellationToken are the same setting, Settings().TestCancellation, which holds
either a timeout or a provider: the second Set replaces the first. A WithTimeout(…) on an individual expectation
still applies together with a global CancellationToken.
TestCancellation is created with one of:
TestCancellation.FromTimeout(TimeSpan timeout), which cancels theCancellationTokenthat is used internally and forwarded to the delegates after the given timeout.TestCancellation.FromCancellationToken(Func<CancellationToken> cancellationTokenFactory), which uses the returnedCancellationTokeninternally and also forwards it to the delegates.TestCancellation.None(), which applies neither a timeout nor aCancellationToken, e.g. to switch off a globalTestCancellationin the current async flow.
You can also apply a CancellationToken on individual expectations, using the WithCancellation(CancellationToken)
method:
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(1));
IAsyncEnumerable<Track> playlist = // ...
await That(playlist).All().Satisfy(track => track.PlayCount > 0)
.WithCancellation(cts.Token);
A local CancellationToken replaces the global one instead of being applied additionally. If you need both, pass a
linked cancellation token.
How WithCancellation applies to Expect.ThatAll and Expect.ThatAny is described in
combining expectations.
The timeout and the CancellationToken are also forwarded to a delegate that
accepts a CancellationToken parameter.
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 (
WithTimeoutorTestCancellation.FromTimeout), the expectation fails with "did not finish within …" and aTimeoutExceptionas inner exception. - When the
CancellationTokenis canceled (WithCancellationorTestCancellation.FromCancellationToken), the expectation is inconclusive: "could not be verified, because the evaluation was already canceled". It neither passes nor throws theOperationCanceledException, so e.g.DidNotSignal()does not pass because the cancellation ended the wait.
An inconclusive expectation throws the exception that your test framework uses for this outcome, see inconclusive.
Awaited tasks, async enumerables and cancellations from the delegate itself
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.
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.
The enumerator is disposed when the expectation ends. A timeout or a cancellation also stops waiting for a
DisposeAsync that does not complete. The outcome is already decided at that point and does not change: a met
expectation stays met, and a failure is reported as it is.
A timeout or a cancellation only stops reading the items that still have to come from a collection, e.g. from a lazily
evaluated IEnumerable<T>. A collection that is already complete in memory, such as an array or a List<T>, is
evaluated completely like any other value, even when the timeout elapsed while a delegate created it. The same applies
to a collection whose items an earlier expectation on the same subject has already read to the end.
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".
Waiting
Some expectations wait for something to happen. Within(…) limits how long they wait, and without it they behave as
follows:
| Expectation | Without Within(…) |
|---|---|
Satisfies(…), CompliesWith(…) and their negations | does 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() | waits the full DefaultSignalerTimeout (30 s), unless the callback is signaled |
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 That(track).Satisfies(x => x.IsPlayed).Within(2.Seconds());
await That(playlist).CompliesWith(x => x.HasCount().GreaterThanOrEqualTo(4)).Within(2.Seconds());
The condition is checked again every DefaultCheckInterval (defaults to
100ms). Append CheckEvery(…) after Within(…) to change the interval for a single expectation:
using aweXpect.Chronology; // from the aweXpect.Chronology package
Track track = new();
await That(track).Satisfies(x => x.IsPlayed).Within(2.Seconds()).CheckEvery(50.Milliseconds());
Timeouts and cancellation while checking
As for Eventually(), a shorter WithTimeout or global timeout ends the checks with "did not finish
within …", and a cancellation makes the expectation inconclusive.
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 That(() => track.PlayCount).Eventually().IsGreaterThan(5);
await 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. You can overwrite the timeout per expectation with Within and the interval with
CheckEvery:
using aweXpect.Chronology; // from the aweXpect.Chronology package
Track track = new();
await 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.
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.
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 (see the delegates page):
Func<Task<int>> loadPlayCountAsync = () => Task.FromResult(6);
await That(() => loadPlayCountAsync()).Eventually().IsGreaterThan(5);
How timeouts bound the retries
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. An expectation that waits while it
checks the value, e.g. for the next item of an IAsyncEnumerable<T>, is canceled in the same way and fails with the
same message. A synchronous delegate cannot be
interrupted, so an evaluation that returns after that point fails the same way, whatever its result. Only
Within(TimeSpan.Zero), which makes a single evaluation, does not bound it, for a synchronous and an asynchronous
delegate alike; a WithTimeout or a global timeout still does.
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 That(recording).Triggered(nameof(Player.TrackStarted)).Within(3.Seconds());
Expectations with an upper bound (DidNotTrigger, Never(), AtMost(…), Exactly(…)) wait out the whole timeout
and only return early when one event too many is recorded:
using aweXpect.Chronology; // from the aweXpect.Chronology package
IEventRecording<Player> recording = player.Record().Events();
// Waits for 3 seconds and expects that no TrackStarted event is triggered in that time
await 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 That(signaler).Signaled().Within(TimeSpan.FromSeconds(5))
.Because("it should take at most 5 seconds to complete");
An expectation without an upper bound (e.g. AtLeast) succeeds as soon as enough callbacks were signaled.
Expectations with an upper bound, including DidNotSignal(), wait out the whole timeout and only return early when one
signal too many is received. A canceled CancellationToken makes the expectation inconclusive, so use
Within(…) to limit how long to wait.
On a virtual clock
WithTimeProvider(timeProvider) lets an expectation wait, time out and measure on the clock of a TimeProvider
instead of in real time, so that a test of a long wait does not take long. Pass any TimeProvider, e.g. the
FakeTimeProvider from the Microsoft.Extensions.TimeProvider.Testing package, which the code under test can share:
using aweXpect.Chronology; // from the aweXpect.Chronology package
using aweXpect.Core;
using Microsoft.Extensions.Time.Testing; // from the Microsoft.Extensions.TimeProvider.Testing package
FakeTimeProvider timeProvider = new();
Track track = new(timeProvider); // plays on the same clock
async Task ExpectIsPlayed()
=> await That(track).Satisfies(x => x.IsPlayed).Within(2.Seconds())
.WithTimeProvider(timeProvider);
Task expectation = ExpectIsPlayed();
timeProvider.Advance(2.Seconds());
await expectation;
It applies to the waits, timeouts and measured durations of the expectation, also to all expectations combined in
Expect.ThatAll or Expect.ThatAny, but not to code that waits outside of it, e.g. in the subject. Without
WithTimeProvider(…), an expectation runs on TimeProvider.System, the real clock.
The waits and timeouts of the expectation are timers of the TimeProvider. A FakeTimeProvider only fires its timers
when the test advances it, so an expectation that waits only progresses when the test advances the clock: start the
expectation without awaiting it, advance the clock, and then await it. Awaiting it first waits forever, unless a
timeout in real time, e.g. of the test framework, ends the test.
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. A
task is already running when the expectation receives it, so only the duration that
remains is measured.
Delegates that ignore the timeout
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. If it returns
after the timeout elapsed, the expectation fails the same way. ExecutesIn() and Throws().Within(…) report the
duration it took instead, when the timeout is their own upper bound or the duration also violates their limit.