Skip to main content

Generated expectations

An expectation that only checks a condition on its subject doesn't need a constraint class of its own: mark a static method that decides the condition with [GenerateExpectation] from aweXpect.Core.Extending, and the source generator in the aweXpect.Core package writes the extension methods, the constraint and its failure messages. It runs in every project that references aweXpect.Core, directly or through aweXpect, so an extension package can use it as well as a test project.

using aweXpect.Core.Extending;

public static partial class ThatTrack
{
[GenerateExpectation("Is{Not}LongerThan", "is {not} longer than {minimum}")]
private static bool IsLongerThan(Track track, TimeSpan minimum)
=> track.Duration > minimum;
}
Track track = new("Love Me Do", new TimeSpan(0, 2, 22));

await Expect.That(track).IsLongerThan(TimeSpan.FromMinutes(2));
await Expect.That(track).IsNotLongerThan(TimeSpan.FromMinutes(3));

The method​

  • The method is static and returns bool, Task<bool> or ValueTask<bool>, or a PredicateResult instead of the bool when it cannot always answer. Its first parameter is the subject, the other parameters become the parameters of the expectation.

  • A CancellationToken parameter marked with [EvaluationCancellationToken] receives the token of the evaluation and is no parameter of the expectation. It can be at any position after the subject, e.g. before a params parameter. A CancellationToken parameter that is not marked is a parameter of the expectation like any other.

    [GenerateExpectation("Is{Not}OneOf", "is {not} one of {titles}")]
    private static async Task<bool> IsOneOf(Track track,
    [EvaluationCancellationToken] CancellationToken cancellationToken, params string[] titles)
    => titles.Contains(await LookUpTitle(track, cancellationToken));
  • It is declared in a top-level, non-generic, static partial class that is not file-local, which receives the generated extension methods. The method itself can be private.

  • The generated extension methods are public, unless the class, the subject or a parameter type is internal: then they are internal as well.

  • A reference type parameter that is not nullable is checked in the generated method, so null throws an ArgumentNullException where the caller passes it.

Name and texts​

The name is the name of the expectation. A {Not} in it, or a NegatedName, also generates the negated expectation, e.g. "Is{Not}LongerThan" generates IsLongerThan and IsNotLongerThan.

The expectationText follows the message conventions. {not} is removed from the expectation and becomes "not" in its negation. Any other placeholder, e.g. {minimum}, names a parameter, whose formatted value replaces it.

When "Not" and "not" in place of {Not} and {not} don't read well, give the negated name in NegatedName and the negated text in NegatedExpectationText, with the same placeholders. Every expectation can be negated with DoesNotComplyWith, even one without a negated name, so the text needs either {not} or a NegatedExpectationText:

[GenerateExpectation("HasTitle", "has title {title}",
NegatedName = "DoesNotHaveTitle", NegatedExpectationText = "does not have title {title}")]
private static bool HasTitle(Track track, string title)
=> track.Title == title;

The result after "but" shows the formatted subject. To describe it differently, name a static method that returns the text after the subject in Result. It takes the subject, optionally followed by the other parameters:

[GenerateExpectation("Is{Not}LongerThan", "is {not} longer than {minimum}", Result = nameof(DescribeDuration))]
private static bool IsLongerThan(Track track, TimeSpan minimum)
=> track.Duration > minimum;

private static string DescribeDuration(Track track)
=> $"was {track.Duration:m\\:ss} long";
Expected that track
is longer than 3:00,
but it was 2:22 long

To describe the subject itself, the method can end with a string and an ExpectationGrammars parameter. It then receives how the subject is referred to, e.g. "it", and returns the whole text after "but":

private static string DescribeDuration(Track track, string it, ExpectationGrammars grammars)
=> $"{it}{grammars.SubjectVerb(it, " was ", " were ")}{track.Duration:m\\:ss} long";

The XML documentation is derived from the expectation text. Summary, NegatedSummary, Remarks and NegatedRemarks replace or extend it with XML documentation of your own.

null subjects​

The generated expectations follow the rule for null subjects, and the type of the subject parameter decides how:

Subject parameterGenerated expectations
a reference type, e.g. Trackon IThat<Track?>; null fails the expectation and its negation
a value type, e.g. TimeSpanon IThat<TimeSpan> and on IThat<TimeSpan?>, where null fails both
a nullable type, e.g. Track? or TimeSpan?on IThat<Track?> or IThat<TimeSpan?>; the method decides about null
a nullable type with [DisallowNull]on the nullable type; null fails the expectation and its negation

Set NullableOverload = false to generate no overload on the nullable value type, e.g. when a separate class of yours already holds the expectations on it.

An expectation that fails for a null subject is marked with [GuaranteesNotNull]. When the method decides about null, [NotNullWhen] on its subject parameter tells which of the two expectations guarantees a subject that is not null, like on string.IsNullOrEmpty:

[GenerateExpectation("Is{Not}Untitled", "is {not} untitled")]
private static bool IsUntitled([NotNullWhen(false)] Track? track)
=> track is null || track.Title.Length == 0;

Here IsNotUntitled() fails for a null subject, so the subject cannot be null after it.

Questions that cannot be answered​

When the method cannot decide for some subjects, e.g. a track without a duration, it returns a PredicateResult. A bool converts implicitly, so only that case needs PredicateResult.FailureBothWays with the reason, which follows "it" in the failure message:

[GenerateExpectation("Is{Not}LongerThan", "is {not} longer than {minimum}")]
private static PredicateResult IsLongerThan(Track track, TimeSpan minimum)
=> track.Duration <= TimeSpan.Zero
? PredicateResult.FailureBothWays("had no duration")
: track.Duration > minimum;
Expected that track
is not longer than 3:00,
but it had no duration

The expectation and its negation fail alike, as negating a question that cannot be answered does not make it true. The Result method still describes a subject that the method decided about.

Collections​

[GenerateCollectionExpectation] generates an expectation on the items of a collection from the same kind of method, e.g. AreLongerThan for That(tracks).All().AreLongerThan(…). It takes the name, the expectation text on a single item and its plural:

[GenerateCollectionExpectation("AreLongerThan", "is longer than {minimum}", "are longer than {minimum}")]
private static bool IsLongerThan(Track track, TimeSpan minimum)
=> track.Duration > minimum;

Together with [GenerateExpectation], the text on a single item can be left out, as it is the expectation text of [GenerateExpectation] without {not}:

[GenerateExpectation("Is{Not}LongerThan", "is {not} longer than {minimum}")]
[GenerateCollectionExpectation("AreLongerThan", "are longer than {minimum}")]
private static bool IsLongerThan(Track track, TimeSpan minimum)
=> track.Duration > minimum;
await Expect.That(tracks).All().AreLongerThan(TimeSpan.FromMinutes(2));
await Expect.That(tracks).AtLeast(2).AreLongerThan(TimeSpan.FromMinutes(2));
await Expect.That(tracks).None().AreLongerThan(TimeSpan.FromMinutes(5));
  • The method follows the rules above, and its first parameter is the item.
  • The expectation text describes a single item, e.g. "is longer than … for all items". The plural text is used where the quantifier is the subject, e.g. "has values of which at least 2 are longer than …". The quantifier carries the negation, so neither text contains {not}.
  • The result counts the items, e.g. "but only 1 of 3 were", with "did" instead of "were" when the text does not start with a form of "be". The failure message lists the matching or not matching items and the collection.
  • The expectation extends the elements of every kind of collection that can hold the item: an IEnumerable<T>, an ImmutableArray<T> and, on .NET 8 or later, an IAsyncEnumerable<T>. A string item extends the elements of the collections of strings.
  • A null item does not match, unless the item parameter is nullable: then the method decides. [DisallowNull] and NullableOverload work like for a subject.
  • An item that the method cannot answer fails the expectation and its negation, e.g. "but for the item at index 1, it had no duration", unless the items before already determine the outcome.
  • Summary and Remarks replace or extend the XML documentation, which is derived from the plural text.

Diagnostics​

A method that the generator cannot turn into an expectation is reported as an error, see the expectation generator.