Upgrade 4 to 5
This is a work in progress. Please send a PR with any amendments.
ShouldBeOneOf and friends take a collection, not params
ShouldBeOneOf, ShouldNotBeOneOf, and ShouldBeOfTypes were variadic in v4. In v5 they take their candidates as a single collection, so call sites passing individual values fail with CS1503:
// v4 — compiles
result.ShouldBeOneOf(Status.Active, Status.Pending, Status.Closed);
// v5 — error CS1503: Argument 2: cannot convert from 'Status' to 'Status[]'
result.ShouldBeOneOf([Status.Active, Status.Pending, Status.Closed]);Wrap the values in a collection expression, or in new[] { … } below C# 12. Call sites that already pass an array or a collection expression are unaffected.
This is the most widely hit compile break in the upgrade — ShouldBeOneOf(a, b, c) was the canonical v4 spelling — but it is a purely mechanical fix and the compiler points at every occurrence.
The params array was dropped because a params parameter must come last, which leaves no room for the [CallerArgumentExpression] parameter these assertions now carry. That parameter is why the failure message reports your source text:
result
should be one of
[Status.Active, Status.Pending, Status.Closed]
but was
Status.DraftString assertions default to case-sensitive comparison
ShouldContain, ShouldNotContain, ShouldStartWith, ShouldEndWith, ShouldNotStartWith, and ShouldNotEndWith defaulted to Case.Insensitive in v4, while ShouldBe compared case-sensitively. In v5 all string assertions are case-sensitive by default, aligning with ShouldBe, C# string semantics, and other assertion libraries. Case.Insensitive remains available as an opt-in.
String failure messages echo less of the value
In v4, ShouldBe on strings echoed the actual and expected values up to a hardcoded 5000 characters, silently, and computed the difference section from those already-truncated values. Two long strings differing past character 5000 therefore produced a 10 KB message showing two identical-looking values and no difference at all.
In v5 the difference is always computed from the full values, and only the echo is clipped — to ShouldlyConfiguration.MaxStringLengthInMessages, which now defaults to 1000. Truncation is announced rather than silent:
Messages for long strings get substantially shorter, and the difference section carries the information the raw echo used to bury. If you were relying on the old volume of output, set ShouldlyConfiguration.MaxStringLengthInMessages = 5000;.
Line-mode diffs also now cap at 20 changed lines per side, with an ... and N more line(s) marker.
The Shouldly.Configuration namespace has been removed
The approval-test configuration types — ShouldMatchConfiguration, ShouldMatchConfigurationBuilder, and friends — lived in the Shouldly.Configuration namespace in v4. In v5 they have been collapsed into the root Shouldly namespace, and Shouldly.Configuration no longer exists. (FirstNonShouldlyMethodFinder, ITestMethodFinder, and FindMethodUsingAttribute<T> are gone entirely — see ShouldMatchApproved no longer walks the stack.)
Any using Shouldly.Configuration; now fails to compile with CS0234:
Delete the using Shouldly.Configuration; line — the types resolve via using Shouldly;.
ShouldMatchApprovedDefaults has moved
The static ShouldMatchApprovedDefaults builder used to configure approval tests has moved off ShouldlyConfiguration and onto a new ShouldMatchConfiguration class. Code referencing the old accessor fails to compile with CS0117:
Change the class name only. Both types live in the root Shouldly namespace, so no using change is needed beyond using Shouldly;.
ShouldMatchApproved no longer walks the stack
In v4, ShouldMatchApproved walked the runtime stack trace to find the test method, and used its reflected declaring type, method name, and PDB source information to name and place the approval files. In v5 the test method is captured at compile time instead, via [CallerMemberName] and [CallerFilePath] parameters on ShouldMatchApproved itself. This removes the reflection dependency (ShouldMatchApproved no longer carries [RequiresUnreferencedCode], so it works under trimming and Native AOT), and no longer requires compiling with full debug information.
Two things to check when upgrading:
Approval files are now prefixed with the source file name
The default approval file name is now {SourceFileName}.{MethodName}.approved.{extension} where v4 used the declaring type's name. Under the usual one-class-per-file convention these are identical, so most suites are unaffected. If a test class lives in a file with a different name — including nested classes and multiple classes per file — the expected file name changes, and the test will fail reporting the new missing .approved file. Either rename the source file to match the class, rename the approval file to the reported name, or restore the old name with WithFilenameGenerator. Custom FilenameGenerator delegates keep compiling: TestMethodInfo.DeclaringTypeName still exists but is [Obsolete] and now returns the source file name; prefer the new TestMethodInfo.SourceFileName.
UseCallerLocation and LocateTestMethodUsingAttribute are removed
Both existed to point the stack walk at the right frame when ShouldMatchApproved was wrapped in a helper method. With compile-time capture there is no stack walk to redirect, so both now fail with CS0619. The replacement is for the helper to capture the caller info itself and pass it through:
This is the same forwarding idiom used for [CallerArgumentExpression], and unlike the v4 stack walk it is immune to inlining, async state machines, and obfuscation. Helpers nested more than one level deep forward the same two parameters at each level.
Custom assertion extensions no longer need [ShouldlyMethods]
In v4, custom assertion classes had to be annotated with [ShouldlyMethods] so the stack-walking machinery could tell Shouldly frames from test frames when reconstructing the asserted expression. In v5 the expression is captured at compile time with [CallerArgumentExpression], so the attribute is no longer required — a custom assertion is just an extension method. See Writing custom assertions for the v5 pattern, including how to verify your [CallerArgumentExpression] wiring with ShouldlyConfiguration.AssertCallerArgumentExpressionIsUsed().
The attribute still exists and is still honored by the legacy stack-walking fallback that only runs for netstandard2.0 consumers whose compiler does not supply caller argument expressions; keep it if your custom assertion library supports those targets.
ShouldBeEquivalentTo has been rewritten
The comparison engine behind ShouldBeEquivalentTo was rewritten for v5 (see the roadmap for the full rationale). The public API is unchanged, plus a new optional EquivalencyOptions parameter.
The new model
A comparison strategy is selected per node in the object graph:
Leaf values: primitives, enums, string (ordinal), Guid, DateTime/DateTimeOffset/TimeSpan, Uri, Type, delegates, and System.* value-semantic types generally
Compared with Equals, with lossless cross-numeric equality (int 1 ≡ long 1)
Dictionaries (IDictionary, IDictionary<,>, IReadOnlyDictionary<,>, immutable variants)
Matched by key, recursing into values
Sets (ISet<>, IReadOnlySet<>, immutable variants)
Order-insensitive
Other collections
Order-sensitive, element-by-element, recursing; container type ignored (array ≡ List<T>); multidimensional arrays compared by rank and dimensions
Everything else — classes and structs
Member-wise and recursive over public properties and fields; indexers skipped
Member selection uses the expectation's members, with declared types and subset semantics: every member on the expectation must exist and match on the actual object; extra members on the actual object are ignored; runtime type identity is no longer required. This enables the most-requested scenario:
Failures now collect every difference and report them all with their paths, instead of stopping at the first.
What newly passes
Most changes turn v4 failures (or crashes) into passes: cross-type and anonymous-type expectations, dictionaries compared insertion-order-insensitively with complex values compared structurally, sets compared order-insensitively, array vs List<T> with equal elements, structs and tuples holding reference-type members, types with indexers (previously NotSupportedException), Type- and Uri-valued members, and numeric members of different types holding the same value.
What newly fails
Equalsoverrides on complex types are ignored — members are compared. Values that wereEquals-equal but structurally different passed in v4 and now fail. (Equivalency is for comparing structure;ShouldBehonorsEquals.)Structs are compared by public members, not
ValueType.Equals, so structs whose equality depended on private state compare only their public surface — and a struct with no public members now trips the vacuous-comparison guard below.Multidimensional arrays check shape: a 2×3 array no longer equals a 3×2 array with the same flat content.
Vacuous comparisons fail. If a node selects zero comparable members (e.g.
new object(), an empty marker interface as the declared type, or ignoring every member via options), the assertion fails with guidance instead of silently passing.
Audit note: derived members in base-typed slots
v4 walked runtime types, so asserting on derived instances held in base-typed members also compared derived-only members. v5 selects members from the declared type, so those members are no longer compared — if your tests rely on this, cast the expectation to the concrete type. The vacuous-comparison guard catches the zero-member extreme, but a partial narrowing will not fail loudly.
Deliberate differences from FluentAssertions
Lists and arrays remain order-sensitive by default (opt out with EquivalencyOptions.IgnoreOrder), different enum types with equal underlying values are not equivalent, cyclic graphs are compared gracefully without configuration, and recursion depth is unbounded. The executable comparison suite at src/EquivalencyComparisonTests documents every remaining divergence.
EquivalencyOptions
Trimming and AOT
ShouldBeEquivalentTo no longer carries [RequiresUnreferencedCode]. The reflection-based comparison sits behind the Shouldly.Equivalency.IsReflectionEnabledByDefault feature switch (the same pattern as System.Text.Json); a companion source-generation package that registers AOT-safe type shapes is planned, at which point trimmed publishes can disable the switch and drop the reflection path entirely.
Last updated