String
Describes the possible expectations for strings.
| Expectation | Negated | Summary |
|---|---|---|
IsEqualTo | IsNotEqualTo | equal to the expected string, or matching a pattern |
IsOneOf | IsNotOneOf | equal to one of the expected strings |
IsNull | IsNotNull | null |
IsEmpty | IsNotEmpty | the empty string |
IsNullOrEmpty | IsNotNullOrEmpty | null or the empty string |
IsNullOrWhiteSpace | IsNotNullOrWhiteSpace | null, empty or only whitespace |
HasLength | negated comparison | has the expected number of characters |
HasLineCount | negated comparison | has the expected number of lines |
HasLines | its lines meet collection expectations | |
StartsWith | DoesNotStartWith | starts with the expected string |
EndsWith | DoesNotEndWith | ends with the expected string |
Contains | DoesNotContain | contains the expected substring, optionally a number of times |
IsUpperCased | IsNotUpperCased | every letter with an upper-case form is upper-case |
IsLowerCased | IsNotLowerCased | every letter with a lower-case form is lower-case |
IsParsableInto<T> | IsNotParsableInto<T> | can be parsed into T |
Equality
You can verify that the string is equal to another one or not:
string title = "Abbey Road";
await That(title).IsEqualTo("Abbey Road");
await That(title).IsNotEqualTo("Let It Be");
The comparison is ordinal and can be configured with the string options or compare against a pattern.
The failure message points at the first difference. When multi-line values differ in several places, a Differences
context lists the changed lines with their line numbers in the actual and the expected value. Each difference is
surrounded by one unchanged line, and differences that are close together are shown as one block:
Differences:
2 2 host = localhost
- 3 port = 8080
+ 3 port = 8081
4 4 timeout = 30
5 5 [client]
6 6 retries = 3
- 7 verbose = true
- 8 log = file
+ 7 log = console
Tabs and other invisible characters are shown as escape sequences.
Which differences are listed
Lines that only differ in what the string options ignore are not listed. At most
MaximumNumberOfCollectionItems differences are listed (see formatting).
With a custom comparer (Using(…)), which compares whole values, no differences are listed.
String options
The expectations that compare the string with an expected string (IsEqualTo, IsOneOf, StartsWith, EndsWith,
Contains and their negations) take the following options:
| Option | Effect |
|---|---|
IgnoringCase() | compares with StringComparison.OrdinalIgnoreCase |
IgnoringNewlineStyle() | treats \r\n, \n and \r as equal |
IgnoringIndentation() | removes the leading whitespace from every line (see below) |
IgnoringLeadingWhiteSpace() | ignores whitespace at the start of the strings |
IgnoringTrailingWhiteSpace() | ignores whitespace at the end of the strings |
NormalizingWhiteSpace() | collapses whitespace runs to a space and trims (see below) |
AsBlock() | matches an indented block of lines (see below) |
Using(comparer) | compares with a custom IEqualityComparer<string> |
string title = "Abbey Road";
await That(title).IsEqualTo("ABBEY ROAD").IgnoringCase();
await That("Abbey\r\nRoad").IsEqualTo("Abbey\nRoad").IgnoringNewlineStyle();
await That(" Abbey\n Road").IsEqualTo("Abbey\nRoad").IgnoringIndentation();
await That(title).IsEqualTo(" Abbey Road").IgnoringLeadingWhiteSpace();
await That(title).IsEqualTo("Abbey Road \t").IgnoringTrailingWhiteSpace();
await That("Abbey\n\t Road ").IsEqualTo("Abbey Road").NormalizingWhiteSpace();
await That(title).StartsWith("ABBEY").Using(StringComparer.OrdinalIgnoreCase);
The same options apply wherever strings are compared, e.g. to the items of a collection of strings or to the message of an exception.
The negations take the same options, so an option can make them fail:
string title = "Abbey Road";
await That(title).DoesNotEndWith("ROAD")
.Because("the casing differs, which would not count with `IgnoringCase()`");
Whitespace at the inner end and ignoring the case with Using
The whitespace options only ignore whitespace at the start or the end of the subject. A prefix, suffix or substring keeps the whitespace at its inner end, unless that end reaches the corresponding end of the subject:
await That("Abbey").StartsWith("Abbey ").IgnoringTrailingWhiteSpace();
await That("AbbeyRoad").DoesNotStartWith("Abbey ").IgnoringTrailingWhiteSpace();
To ignore the case with Using(…), pass a case-insensitive comparer such as StringComparer.OrdinalIgnoreCase
instead of adding IgnoringCase().
Indentation
While IgnoringLeadingWhiteSpace only trims the start of the complete string, IgnoringIndentation removes the
leading whitespace from every line. This allows comparing against a raw string literal that is indented differently
than the subject:
string code = """
public class Beatles
{
public string Album => "Abbey Road";
}
""";
await That(code).Contains("""
public string Album => "Abbey Road";
""").IgnoringIndentation();
This also normalizes the newline style. Trailing whitespace within a line is kept, but a line that consists only of whitespace becomes empty.
Whitespace
NormalizingWhiteSpace replaces every run of whitespace, including line breaks and tabs, with a single space and
removes the whitespace at the start and the end. This compares generated SQL, HTML or log output regardless of its
layout:
string sql = """
SELECT *
FROM Albums
WHERE Title = 'Abbey Road'
""";
await That(sql).IsEqualTo("SELECT * FROM Albums WHERE Title = 'Abbey Road'").NormalizingWhiteSpace();
await That(sql).Contains("FROM Albums WHERE").NormalizingWhiteSpace();
await That("AbbeyRoad").IsNotEqualTo("Abbey Road").NormalizingWhiteSpace();
Whitespace is collapsed, not removed, so "AbbeyRoad" still differs from "Abbey Road". Any Unicode whitespace
counts, e.g. also a non-breaking space.
Combining NormalizingWhiteSpace with other options
- It makes
IgnoringIndentation,IgnoringNewlineStyle,IgnoringLeadingWhiteSpaceandIgnoringTrailingWhiteSpaceredundant, as it removes every difference that they ignore. They can still be specified. - It applies to the patterns of
AsWildcardandAsRegexas well, so a regex sees a single space where the subject had a line break. As the line breaks of the pattern are collapsed too, a comment in a pattern withRegexOptions.IgnorePatternWhitespaceextends to the end of the pattern. - Like with the other whitespace options, a prefix, suffix or substring keeps the whitespace at its inner end, which then has to match whitespace in the subject, unless that end reaches the corresponding end of the subject.
- It cannot be combined with
AsBlock, which compares the lines that the normalization joins. - A failure shows the values with normalized whitespace and the position of the first difference refers to them. Where the failure shows the normalized subject, the original one is listed below it. No differences per line are listed.
Blocks
While IgnoringIndentation removes the leading whitespace from every line, AsBlock keeps the relative
indentation within the expected block and only allows the block as a whole to be indented in the subject.
This is stricter, as a line that is indented differently from the rest of the block does not match:
string code = """
public class Beatles
{
public string Album
{
get;
}
}
""";
await That(code).Contains("""
public string Album
{
get;
}
""").AsBlock();
The block must start and end at line boundaries, and all its lines must share the same whitespace prefix in the subject.
AsBlock also works with IsEqualTo, where the whole text has to be the block, e.g. for an XML element that keeps
the indentation of the document it was taken from, and with the expectations on a collection of strings, such as
HasItem:
string album = """
<album>
<title>Abbey Road</title>
<length>47:03</length>
</album>
""";
await That(album).IsEqualTo("""
<album>
<title>Abbey Road</title>
<length>47:03</length>
</album>
""").AsBlock();
Whitespace lines, newlines and other options in a block
A line that consists only of whitespace matches any line that consists only of whitespace. The newline style is always
ignored, and a single trailing line terminator does not start a new line, so "a\nb\n" has the same two lines as
"a\nb" (as for lines). AsBlock can be combined with IgnoringCase, Using and the count quantifiers;
the whitespace, newline and indentation options throw an InvalidOperationException after it.
Match types
Instead of comparing for equality, IsEqualTo can match the subject against a pattern, a prefix or a suffix. The
same match types are available for IsNotEqualTo, IsOneOf and IsNotOneOf. Contains and DoesNotContain search
within the subject, so they take a pattern, but no prefix or suffix: use StartsWith or EndsWith
for that.
Wildcards
string title = "Let It Be";
await That(title).IsEqualTo("Let*B?").AsWildcard();
| Wildcard specifier | Matches |
|---|---|
| * (asterisk) | Zero or more characters |
| ? (question mark) | Exactly one character (a surrogate pair counts as one) |
The pattern has to cover the complete subject, including all its lines and a trailing newline.
Regular expressions
string title = "Let It Be";
await That(title).IsEqualTo("(.*)Be").AsRegex();
Unlike a wildcard, the pattern may match any part of the subject: IsEqualTo("It").AsRegex() succeeds for
"Let It Be". Enclose the pattern in \A and \z to match the complete subject. IgnoringCase() adds
RegexOptions.IgnoreCase and RegexOptions.CultureInvariant. Every other
option
is opt-in:
using System.Text.RegularExpressions;
string lyrics = "Come together\nRight now";
await That(lyrics).IsEqualTo("^Right now$").AsRegex(RegexOptions.Multiline);
Prefix / suffix
string title = "Abbey Road";
await That(title).IsEqualTo("Abbey").AsPrefix();
await That(title).IsEqualTo("Road").AsSuffix();
Regex engine, timeout and corner cases
- A wildcard or regex pattern is matched by the regex engine, which can't use a custom comparer, so combining
Using(…)withAsWildcard()orAsRegex()throws anInvalidOperationException. - Matching a pattern is limited to the
DefaultRegexTimeout, one second by default. A pattern that takes longer, e.g. because of catastrophic backtracking, throws anArgumentExceptionthat names the timeout and asks you to simplify the pattern.
In a regex, ^ and $ bind to the start and the end of the subject and not to every line, but $ also matches before
a trailing newline. The regex engine applies its own case folding, which can differ from the other expectations for a
few characters, e.g. the Kelvin sign (U+212A) on modern .NET.
One of
You can verify that the string is one of many alternatives, with the same options as for
equality:
string title = "Abbey Road";
await That(title).IsOneOf("Help!", "Abbey Road", "Revolver");
await That(title).IsOneOf("HELP!", "ABBEY ROAD", "REVOLVER").IgnoringCase();
await That(title).IsNotOneOf("Help!", "Revolver");
Null, empty or whitespace
You can verify that the string is null, empty or contains only whitespace:
string? title = null;
await That(title).IsNull();
await That("Abbey Road").IsNotNull();
await That("").IsEmpty();
await That("Abbey Road").IsNotEmpty();
await That(title).IsNullOrEmpty();
await That("Abbey Road").IsNotNullOrEmpty();
await That(title).IsNullOrWhiteSpace();
await That("Abbey Road").IsNotNullOrWhiteSpace();
Length
You can verify that the string has the expected length:
string title = "Abbey Road";
await That(title).HasLength(10);
await That(title).HasLength().Between(8).And(12);
await That(title).HasLength().NotGreaterThan(12);
The Has… expectations whose value is a number or a TimeSpan, e.g. HasLength(), HasCount(), HasYear() or
HasMajor(), continue with one of the following comparisons, each with a negated counterpart:
| Comparison | Negated | Succeeds when the value is |
|---|---|---|
EqualTo(x) | NotEqualTo(x) | equal to x |
GreaterThan(x) | NotGreaterThan(x) | greater than x |
GreaterThanOrEqualTo(x) | NotGreaterThanOrEqualTo(x) | greater than or equal to x |
LessThan(x) | NotLessThan(x) | less than x |
LessThanOrEqualTo(x) | NotLessThanOrEqualTo(x) | less than or equal to x |
Between(min).And(max) | NotBetween(min).And(max) | between min and max, both bounds included |
Passing the value directly, e.g. HasLength(10), is a shorthand for EqualTo.
Comparing with null
The expected value is nullable, e.g. to pass a value mapped from a property. As the actual value is never null,
EqualTo(null) fails and NotEqualTo(null) succeeds, while a comparison of order against null, such as
GreaterThan(null) or Between(null).And(3), fails even when negated.
Lines
You can verify how many lines the string has:
string lyrics = """
Come together
Right now
Over me
""";
await That(lyrics).HasLineCount(3);
await That(lyrics).HasLineCount().NotEqualTo(4);
await That(lyrics).HasLineCount().GreaterThan(2);
HasLineCount() takes the same comparisons as HasLength().
You can also verify the lines themselves. HasLines applies the expectations on the lines as a collection, so all
collection expectations are available:
await That(lyrics).HasLines(lines => lines.Contains("Right now"));
await That(lyrics).HasLines(lines => lines.StartsWith("Come together"));
await That(lyrics).HasLines(lines => lines.All().Satisfy(line => line?.Length < 20));
Lines are separated by \r\n, \n or \r, which are all treated equivalently. A single trailing line terminator
does not start a new line, so "Come together\n" has one line and "" has none, matching how File.ReadLines counts
the lines of a file:
await That("").HasLineCount(0);
await That("Come together").HasLineCount(1);
await That("Come together\n").HasLineCount(1);
await That("Come together\n\n").HasLineCount(2);
Start / end
You can verify that the string starts or ends with a given string, or that it does not, with the same
options as for equality:
string title = "Abbey Road";
await That(title).StartsWith("Abbey");
await That(title).EndsWith("ROAD").IgnoringCase();
await That(title).DoesNotStartWith("Road");
await That(title).DoesNotEndWith("Abbey");
Contains
You can verify that the string contains a given substring, or that it does not, with the same
options as for equality:
string title = "Strawberry Fields Forever";
await That(title).Contains("Fields");
await That(title).Contains("FIELDS").IgnoringCase();
await That(title).DoesNotContain("Penny Lane");
You can also specify how often the substring should be found:
string lyrics = "get back, get back, get back to where you once belonged.";
// 'get' can be found 3 times
await That(lyrics).Contains("get").MoreThan(1)
.Because("count should be '> 1'");
await That(lyrics).Contains("get").AtLeast(2)
.Because("count should be '>= 2'");
await That(lyrics).Contains("get").Exactly(3)
.Because("count should be '== 3'");
await That(lyrics).Contains("get").AtMost(4)
.Because("count should be '<= 4'");
await That(lyrics).Contains("get").LessThan(5)
.Because("count should be '< 5'");
await That(lyrics).Contains("get").Between(1).And(6)
.Because("count should be '>= 1 AND <= 6'");
await That(lyrics).Contains("help").Never();
await That(lyrics).Contains("where").Once();
await That(lyrics).Contains("back").AtLeast().Twice();
Character casing
You can verify that the characters in a string are all upper or lower cased:
await That("1ST PLACE").IsUpperCased()
.Because("it contains no lowercase characters");
await That("1st PLACE").IsNotUpperCased()
.Because("it contains at least one lowercase character");
await That("1st place").IsLowerCased()
.Because("it contains no uppercase characters");
await That("1st PLACE").IsNotLowerCased()
.Because("it contains at least one uppercase character");
Letters without an upper-case (lower-case) form, like ß, count as upper-cased (lower-cased). Use
IncludingUncasedLetters() to also reject them and titlecase letters:
await That("STRAßE").IsNotUpperCased().IncludingUncasedLetters()
.Because("ß is a lowercase letter without an uppercase form");
Parsing
The parsing expectations are only available on .NET 8 or later.
You can verify that the string can be parsed into a type that implements IParsable<T>, and continue with
expectations on the parsed value with Which:
using System.Globalization;
await That("42").IsParsableInto<int>();
await That("42").IsParsableInto<int>().Which.IsGreaterThan(40);
await That("1,5").IsParsableInto<double>(new CultureInfo("de-DE"))
.Because("the format provider is passed to `Parse`");
await That("Abbey Road").IsNotParsableInto<int>();
A failure shows the exception thrown by Parse and keeps it as inner exception.
The same expectations are available for a ReadOnlySpan<char> of a type that implements ISpanParsable<T> and for a
UTF-8 ReadOnlySpan<byte> of a type that implements IUtf8SpanParsable<T>:
await That("42".AsSpan()).IsParsableInto<int>();
await That("42"u8).IsParsableInto<int>();