Skip to main content

Events

Describes the possible expectations for verifying events.

ExpectationNegatedSummary
TriggeredDidNotTriggerthe recording recorded the event
TriggeredPropertyChangedDidNotTriggerPropertyChangedPropertyChanged was raised for any property
TriggeredPropertyChangedForDidNotTriggerPropertyChangedForPropertyChanged was raised for the property

The events are recorded first. The samples on this page use the following recording:

using aweXpect.Recording;

class TrackStartedEventArgs(string title = "") : EventArgs
{
public string Title { get; } = title;
}
class Player
{
public event EventHandler<TrackStartedEventArgs>? TrackStarted;
public void Play(string title)
=> TrackStarted?.Invoke(this, new TrackStartedEventArgs(title));
}
Player player = new Player();

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

Recording​

.Record().Events() in the aweXpect.Recording namespace starts a recording of all events of the subject, or of the events with the given names:

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

player.Play("Let It Be");

await That(recording).Triggered(nameof(Player.TrackStarted));

An expectation stops the recording, unless you keep it running with .UntilDisposed().

Events that cannot be recorded

Without a registration from the source generator, the handler is bound reflectively. Such a handler must take at most four parameters, must return nothing and must take no parameter by reference. Recording all events skips an event whose handler does not fit, so that the other events of the subject are still recorded, and an expectation on the skipped event throws a NotSupportedException with the reason; recording it by name throws right away.

Triggering​

You can verify that a recording recorded an event:

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

player.Play("Let It Be");

await That(recording).Triggered(nameof(Player.TrackStarted));

You can also verify that a recording did not record an event:

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

// Perform an action on the player that must not start a track

await That(recording).DidNotTrigger(nameof(Player.TrackStarted));

Triggered expects the event at least once, and DidNotTrigger is equivalent to Triggered(…).Never(). A count negates the expectation, so DidNotTrigger(nameof(Player.TrackStarted)).AtLeast(2) expects the event to be triggered less than twice.

Without Within(…), only the events recorded so far count. To wait for events that are triggered in the background, see waiting for events.

Counting​

You can verify that an event was recorded a specific number of times:

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

player.Play("Let It Be");
player.Play("Yesterday");

await That(recording).Triggered(nameof(Player.TrackStarted)).Between(1).And(2);

The same occurrence constraints as for Contains are available: AtLeast(2), AtMost(3), Between(1).And(4), Exactly(0), MoreThan(1), LessThan(3), Once(), Twice() and Never(). AtLeast, AtMost, MoreThan and LessThan can also be followed by Once() or Twice(), as in AtLeast().Once(), AtMost().Twice(), MoreThan().Once() and LessThan().Twice():

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

player.Play("Let It Be");

await That(recording).Triggered(nameof(Player.TrackStarted)).AtMost().Twice();

Filtering​

You can filter the recorded events based on their parameters, and count only the matching ones:

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

player.Play("Let It Be");
player.Play("Yesterday");

await That(recording).Triggered(nameof(Player.TrackStarted))
.WithParameter<TrackStartedEventArgs>(e => e.Title == "Yesterday");
await That(recording).Triggered(nameof(Player.TrackStarted))
.WithParameter<TrackStartedEventArgs>(e => e.Title == "Yesterday").Once();

This matches an event when any of its parameters is of the given type and satisfies the predicate. To check the parameter at a specific zero-based position instead, pass the position first:

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

player.Play("Yesterday");

await That(recording).Triggered(nameof(Player.TrackStarted))
.WithParameter<TrackStartedEventArgs>(1, e => e.Title == "Yesterday");

An event whose parameter at that position is missing or of another type does not match.

null parameters

A parameter that is null is passed to the predicate, unless the given type is a non-nullable value type; without a position, null parameters are ignored. The predicates for the sender and for the EventArgs below also receive a value that is null.

When you follow the event best practices, you can also filter the recorded events based on the sender (the first parameter) or on their EventArgs (the second parameter):

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

player.Play("Let It Be");

await That(recording).Triggered(nameof(Player.TrackStarted))
.WithSender(s => s == player)
.Because("the sender is the first parameter");
IEventRecording<Player> recording = player.Record().Events();

player.Play("Let It Be");

await That(recording).Triggered(nameof(Player.TrackStarted))
.With<TrackStartedEventArgs>(e => e.Title.StartsWith("Let"))
.Because("the EventArgs are the second parameter");

Special events​

You can verify that an object triggered the INotifyPropertyChanged.PropertyChanged event, for any property or for a specific one:

AlbumViewModel album = // ...implements INotifyPropertyChanged
using IDisposableEventRecording<AlbumViewModel> recording = album.Record().Events().UntilDisposed();

album.Rename("Let It Be... Naked");

await That(recording).TriggeredPropertyChanged();
await That(recording).TriggeredPropertyChangedFor(x => x.Title);

The expression has to access a property directly on the subject, as in x => x.Title. For any other name, use the overload with the property name, e.g. TriggeredPropertyChangedFor("Title").

The negated expectations verify that the event was not triggered:

AlbumViewModel album = // ...implements INotifyPropertyChanged
using IDisposableEventRecording<AlbumViewModel> recording = album.Record().Events().UntilDisposed();

// do something that must not change the album

await That(recording).DidNotTriggerPropertyChanged();
await That(recording).DidNotTriggerPropertyChangedFor(x => x.Title);

An event with a null or empty property name counts as a change of every property.

null or empty property names

As defined by the INotifyPropertyChanged contract, an event that was triggered with a null or empty property name notifies that all properties changed: it satisfies TriggeredPropertyChangedFor for every property name and lets DidNotTriggerPropertyChangedFor fail for every property name. A whitespace-only name is a name like any other. Expecting the null or the empty property name itself, e.g. TriggeredPropertyChangedFor((string?)null), matches only the events that notify that all properties changed, but no named one, and without distinguishing the two spellings, which the contract allows interchangeably. The expectation then reads "for all properties".

Lifetime of a recording​

An expectation stops the recording: it detaches the handlers from the subject as soon as it is evaluated. Until then, every constraint of that one expectation sees the recorded events, including the ones that arrive while it waits with Within(…), because .And and .Or combine into a single expectation. A further expectation on the same recording throws an InvalidOperationException, so that it cannot silently answer from the events that were recorded until then:

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

player.Play("Let It Be");
await That(recording).Triggered(nameof(Player.TrackStarted)).Once();

player.Play("Yesterday");
// ↓ throws, because the previous expectation already stopped the recording
await That(recording).Triggered(nameof(Player.TrackStarted)).Twice();
Expect.ThatAll(…) and Expect.ThatAny(…)

The expectations within one Expect.ThatAll(…) or Expect.ThatAny(…), including nested ones, share the recording: it is stopped when the whole combination was evaluated. They are evaluated one after the other, so the events that arrive while one of them waits with Within(…) also count for the following ones, as with .And.

.UntilDisposed() keeps the recording running across multiple expectations and hands its lifetime to you:

using IDisposableEventRecording<Player> recording = player.Record().Events().UntilDisposed();

player.Play("Let It Be");
await That(recording).Triggered(nameof(Player.TrackStarted)).Once();

player.Play("Yesterday");
await That(recording).Triggered(nameof(Player.TrackStarted)).Twice();

Disposing detaches the handlers, so an event that is triggered afterwards is not recorded any more.

Stopped or disposed recordings

Call .UntilDisposed() before the first expectation: on a recording that an expectation already stopped, it throws an InvalidOperationException. An expectation on a disposed recording throws as well.