Repository: fluentassertions/fluentassertions
Pull request #3039: Improve rendering of exception messages when using WithMessage 
Author: login: dennisdoomen
Labels: name: enhancement
Created: 2025-03-13T15:04:52Z | Merged: 2025-03-28T20:29:15Z

In a test like `When_one_of_the_types_does_not_match_the_generic_type_it_should_throw_with_a_clear_explanation`, a multi-line exception message like this

```
Expected exception message to match the equivalent of "Expected type to be "System.Int33" because they are of different type, but found "[System.Int32, System.String, System.Int32]".", but "Expected type to be "System.Int32" because they are of different type, but found "[System.Int32, System.String, System.Int32]"." does not.
```

after this PR, they will be rendered as 

```
Expected exception message to match the equivalent of

    "Expected type to be "System.Int33" because they are of different type, but found "[System.Int32, System.String, System.Int32]".",

but

    "Expected type to be "System.Int32" because they are of different type, but found "[System.Int32, System.String, System.Int32]"."

does not.
```




Diff:
diff --git a/Src/FluentAssertions/Formatting/Line.cs b/Src/FluentAssertions/Formatting/Line.cs
index bab01f9edc..d5971f81f0 100644
--- a/Src/FluentAssertions/Formatting/Line.cs
+++ b/Src/FluentAssertions/Formatting/Line.cs
@@ -107,5 +107,9 @@ public Line Truncate(int characterIndex)
         return state.Truncate(characterIndex, indentation, whitespaceOffset);
     }
 
-    public override string ToString() => state.Render().TrimEnd();
+    public override string ToString()
+    {
+        // Only trim spaces, but keep line breaks.
+        return state.Render().TrimEnd(' ');
+    }
 }
diff --git a/Src/FluentAssertions/Primitives/IStringComparisonStrategy.cs b/Src/FluentAssertions/Primitives/IStringComparisonStrategy.cs
index 6dffb8b1f4..01152ac4e6 100644
--- a/Src/FluentAssertions/Primitives/IStringComparisonStrategy.cs
+++ b/Src/FluentAssertions/Primitives/IStringComparisonStrategy.cs
@@ -8,12 +8,12 @@ namespace FluentAssertions.Primitives;
 internal interface IStringComparisonStrategy
 {
     /// <summary>
-    /// The prefix for the message when the assertion fails.
+    /// Asserts that neither the <paramref name="subject"/> nor the <paramref name="expected"/> strings are null.
     /// </summary>
-    string ExpectationDescription { get; }
+    void AssertNeitherIsNull(AssertionChain assertionChain, string subject, string expected);
 
     /// <summary>
     /// Asserts that the <paramref name="subject"/> matches the <paramref name="expected"/> value.
     /// </summary>
-    void ValidateAgainstMismatch(AssertionChain assertionChain, string subject, string expected);
+    void AssertForEquality(AssertionChain assertionChain, string subject, string expected);
 }
diff --git a/Src/FluentAssertions/Primitives/StringContainsStrategy.cs b/Src/FluentAssertions/Primitives/StringContainsStrategy.cs
index 4d92ad2bd0..6411133327 100644
--- a/Src/FluentAssertions/Primitives/StringContainsStrategy.cs
+++ b/Src/FluentAssertions/Primitives/StringContainsStrategy.cs
@@ -15,16 +15,22 @@ public StringContainsStrategy(IEqualityComparer<string> comparer, OccurrenceCons
         this.occurrenceConstraint = occurrenceConstraint;
     }
 
-    public string ExpectationDescription => "Expected {context:string} {0} to contain the equivalent of ";
-
-    public void ValidateAgainstMismatch(AssertionChain assertionChain, string subject, string expected)
+    public void AssertForEquality(AssertionChain assertionChain, string subject, string expected)
     {
         int actual = subject.CountSubstring(expected, comparer);
 
         assertionChain
             .ForConstraint(occurrenceConstraint, actual)
             .FailWith(
-                $"{ExpectationDescription}{{1}} {{expectedOccurrence}}{{reason}}, but found it {actual.Times()}.",
+                $"Expected {{context:string}} {{0}} to contain the equivalent of {{1}} {{expectedOccurrence}}{{reason}}, but found it {actual.Times()}.",
                 subject, expected);
     }
+
+    public void AssertNeitherIsNull(AssertionChain assertionChain, string subject, string expected)
+    {
+        if (subject is null || expected is null)
+        {
+            assertionChain.FailWith("Expected {context:string} to contain the equivalent of {0}{reason}, but found {1}", subject, expected);
+        }
+    }
 }
diff --git a/Src/FluentAssertions/Primitives/StringEndStrategy.cs b/Src/FluentAssertions/Primitives/StringEndStrategy.cs
index ee97e85384..3a2d0044e0 100644
--- a/Src/FluentAssertions/Primitives/StringEndStrategy.cs
+++ b/Src/FluentAssertions/Primitives/StringEndStrategy.cs
@@ -15,13 +15,11 @@ public StringEndStrategy(IEqualityComparer<string> comparer, string predicateDes
         this.predicateDescription = predicateDescription;
     }
 
-    public string ExpectationDescription => $"Expected {{context:string}} to {predicateDescription} ";
-
-    public void ValidateAgainstMismatch(AssertionChain assertionChain, string subject, string expected)
+    public void AssertForEquality(AssertionChain assertionChain, string subject, string expected)
     {
         assertionChain
             .ForCondition(subject!.Length >= expected.Length)
-            .FailWith($"{ExpectationDescription}{{0}}{{reason}}, but {{1}} is too short.", expected, subject);
+            .FailWith($"{ExpectationDescription}, but {{1}} is too short.", expected, subject);
 
         if (!assertionChain.Succeeded)
         {
@@ -36,7 +34,18 @@ public void ValidateAgainstMismatch(AssertionChain assertionChain, string subjec
         }
 
         assertionChain.FailWith(
-            $"{ExpectationDescription}{{0}}{{reason}}, but {{1}} differs near {subject.IndexedSegmentAt(indexOfMismatch)}.",
+            $"{ExpectationDescription}, but {{1}} differs near {subject.IndexedSegmentAt(indexOfMismatch)}.",
             expected, subject);
     }
+
+    /// <inheritdoc />
+    public void AssertNeitherIsNull(AssertionChain assertionChain, string subject, string expected)
+    {
+        if (subject is null || expected is null)
+        {
+            assertionChain.FailWith($"{ExpectationDescription}, but found {{1}}.", expected, subject);
+        }
+    }
+
+    private string ExpectationDescription => $"Expected {{context:string}} to {predicateDescription} {{0}}{{reason}}";
 }
diff --git a/Src/FluentAssertions/Primitives/StringEqualityStrategy.cs b/Src/FluentAssertions/Primitives/StringEqualityStrategy.cs
index 2f8d4e4110..ba26a0307a 100644
--- a/Src/FluentAssertions/Primitives/StringEqualityStrategy.cs
+++ b/Src/FluentAssertions/Primitives/StringEqualityStrategy.cs
@@ -18,7 +18,7 @@ public StringEqualityStrategy(IEqualityComparer<string> comparer, string predica
         this.predicateDescription = predicateDescription;
     }
 
-    public void ValidateAgainstMismatch(AssertionChain assertionChain, string subject, string expected)
+    public void AssertForEquality(AssertionChain assertionChain, string subject, string expected)
     {
         ValidateAgainstSuperfluousWhitespace(assertionChain, subject, expected);
 
@@ -60,7 +60,15 @@ public void ValidateAgainstMismatch(AssertionChain assertionChain, string subjec
         }
     }
 
-    public string ExpectationDescription => $"Expected {{context:string}} to {predicateDescription} ";
+    public void AssertNeitherIsNull(AssertionChain assertionChain, string subject, string expected)
+    {
+        if (subject is null || expected is null)
+        {
+            assertionChain.FailWith($"{ExpectationDescription}{{0}}{{reason}}, but found {{1}}.", expected, subject);
+        }
+    }
+
+    private string ExpectationDescription => $"Expected {{context:string}} to {predicateDescription} ";
 
     private void ValidateAgainstSuperfluousWhitespace(AssertionChain assertion, string subject, string expected)
     {
diff --git a/Src/FluentAssertions/Primitives/StringStartStrategy.cs b/Src/FluentAssertions/Primitives/StringStartStrategy.cs
index 36ff44ca8e..4738d90355 100644
--- a/Src/FluentAssertions/Primitives/StringStartStrategy.cs
+++ b/Src/FluentAssertions/Primitives/StringStartStrategy.cs
@@ -15,9 +15,7 @@ public StringStartStrategy(IEqualityComparer<string> comparer, string predicateD
         this.predicateDescription = predicateDescription;
     }
 
-    public string ExpectationDescription => $"Expected {{context:string}} to {predicateDescription} ";
-
-    public void ValidateAgainstMismatch(AssertionChain assertionChain, string subject, string expected)
+    public void AssertForEquality(AssertionChain assertionChain, string subject, string expected)
     {
         assertionChain
             .ForCondition(subject.Length >= expected.Length)
@@ -39,4 +37,15 @@ public void ValidateAgainstMismatch(AssertionChain assertionChain, string subjec
             $"{ExpectationDescription}{{0}}{{reason}}, but {{1}} differs near {subject.IndexedSegmentAt(indexOfMismatch)}.",
             expected, subject);
     }
+
+    /// <inheritdoc />
+    public void AssertNeitherIsNull(AssertionChain assertionChain, string subject, string expected)
+    {
+        if (subject is null || expected is null)
+        {
+            assertionChain.FailWith($"{ExpectationDescription}{{0}}{{reason}}, but found {{1}}.", expected, subject);
+        }
+    }
+
+    private string ExpectationDescription => $"Expected {{context:string}} to {predicateDescription} ";
 }
diff --git a/Src/FluentAssertions/Primitives/StringValidator.cs b/Src/FluentAssertions/Primitives/StringValidator.cs
index a1e2b2facc..c98ff29fda 100644
--- a/Src/FluentAssertions/Primitives/StringValidator.cs
+++ b/Src/FluentAssertions/Primitives/StringValidator.cs
@@ -22,27 +22,16 @@ public void Validate(string subject, string expected)
             return;
         }
 
-        if (!ValidateAgainstNulls(subject, expected))
-        {
-            return;
-        }
+        comparisonStrategy.AssertNeitherIsNull(assertionChain, subject, expected);
 
-        if (expected.IsLongOrMultiline() || subject.IsLongOrMultiline())
+        if (assertionChain.Succeeded)
         {
-            assertionChain = assertionChain.UsingLineBreaks;
-        }
+            if (expected.IsLongOrMultiline() || subject.IsLongOrMultiline())
+            {
+                assertionChain = assertionChain.UsingLineBreaks;
+            }
 
-        comparisonStrategy.ValidateAgainstMismatch(assertionChain, subject, expected);
-    }
-
-    private bool ValidateAgainstNulls(string subject, string expected)
-    {
-        if (expected is null == subject is null)
-        {
-            return true;
+            comparisonStrategy.AssertForEquality(assertionChain, subject, expected);
         }
-
-        assertionChain.FailWith($"{comparisonStrategy.ExpectationDescription}{{0}}{{reason}}, but found {{1}}.", expected, subject);
-        return false;
     }
 }
diff --git a/Src/FluentAssertions/Primitives/StringValidatorSupportingNull.cs b/Src/FluentAssertions/Primitives/StringValidatorSupportingNull.cs
index f5fb4478b4..cabd1a6e37 100644
--- a/Src/FluentAssertions/Primitives/StringValidatorSupportingNull.cs
+++ b/Src/FluentAssertions/Primitives/StringValidatorSupportingNull.cs
@@ -24,6 +24,6 @@ public void Validate(string subject, string expected)
             assertionChain = assertionChain.UsingLineBreaks;
         }
 
-        comparisonStrategy.ValidateAgainstMismatch(assertionChain, subject, expected);
+        comparisonStrategy.AssertForEquality(assertionChain, subject, expected);
     }
 }
diff --git a/Src/FluentAssertions/Primitives/StringWildcardMatchingStrategy.cs b/Src/FluentAssertions/Primitives/StringWildcardMatchingStrategy.cs
index 83f590048d..d545ce52a0 100644
--- a/Src/FluentAssertions/Primitives/StringWildcardMatchingStrategy.cs
+++ b/Src/FluentAssertions/Primitives/StringWildcardMatchingStrategy.cs
@@ -1,4 +1,6 @@
 using System;
+using System.Collections.Generic;
+using System.Linq;
 using System.Text;
 using System.Text.RegularExpressions;
 using FluentAssertions.Common;
@@ -8,22 +10,47 @@ namespace FluentAssertions.Primitives;
 
 internal class StringWildcardMatchingStrategy : IStringComparisonStrategy
 {
-    public void ValidateAgainstMismatch(AssertionChain assertionChain, string subject, string expected)
+    public void AssertForEquality(AssertionChain assertionChain, string subject, string expected)
     {
         bool isMatch = IsMatch(subject, expected);
-
         if (isMatch != Negate)
         {
             return;
         }
 
-        if (Negate)
+        if (IsLongOrMultiline(subject) || IsLongOrMultiline(expected))
         {
-            assertionChain.FailWith($"{ExpectationDescription}but {{1}} matches.", expected, subject);
+            expected = RenderAsIndentedBlock(expected);
+            subject = RenderAsIndentedBlock(subject);
+
+            assertionChain
+                .FailWith($$"""
+                            {{ExpectationDescription}}
+
+                            {0},
+
+                            {reason}but
+
+                            {1}
+
+                            {{OutcomeDescription}}.
+
+                            """, expected.AsNonFormattable(),
+                    subject.AsNonFormattable());
         }
         else
         {
-            assertionChain.FailWith($"{ExpectationDescription}but {{1}} does not.", expected, subject);
+            assertionChain.FailWith($"{ExpectationDescription} {{0}}{{reason}}, but {{1}} {OutcomeDescription}.", expected,
+                    subject);
+        }
+    }
+
+    /// <inheritdoc />
+    public void AssertNeitherIsNull(AssertionChain assertionChain, string subject, string expected)
+    {
+        if (subject is null || expected is null)
+        {
+            assertionChain.FailWith($"{ExpectationDescription} {{0}}{{reason}}, but found {{1}}.", expected, subject);
         }
     }
 
@@ -48,6 +75,18 @@ private static string ConvertWildcardToRegEx(string wildcardExpression)
             + "$";
     }
 
+    private static string RenderAsIndentedBlock(string message)
+    {
+        string[] lines = message.Split(["\r\n", "\n", "\r"], StringSplitOptions.None);
+
+        return "    \"" + string.Join(Environment.NewLine + "    ", lines) + "\"";
+    }
+
+    private static bool IsLongOrMultiline(string message)
+    {
+        return message.Length > 80 || message.Contains(Environment.NewLine, StringComparison.Ordinal);
+    }
+
     private string CleanNewLines(string input)
     {
         if (IgnoreAllNewlines)
@@ -63,7 +102,7 @@ private string CleanNewLines(string input)
         return input;
     }
 
-    public string ExpectationDescription
+    private string ExpectationDescription
     {
         get
         {
@@ -72,13 +111,17 @@ public string ExpectationDescription
             builder
                 .Append(Negate ? "Did not expect " : "Expected ")
                 .Append("{context:string}")
-                .Append(IgnoreCase ? " to match the equivalent of" : " to match")
-                .Append(" {0}{reason}, ");
+                .Append(IgnoreCase ? " to match the equivalent of" : " to match");
 
             return builder.ToString();
         }
     }
 
+    private string OutcomeDescription
+    {
+        get { return Negate ? "matches" : "does not"; }
+    }
+
     /// <summary>
     /// Gets or sets a value indicating whether the subject should not match the pattern.
     /// </summary>
diff --git a/Src/FluentAssertions/Specialized/ExceptionAssertions.cs b/Src/FluentAssertions/Specialized/ExceptionAssertions.cs
index 9ada515f2d..b611fa08d5 100644
--- a/Src/FluentAssertions/Specialized/ExceptionAssertions.cs
+++ b/Src/FluentAssertions/Specialized/ExceptionAssertions.cs
@@ -84,12 +84,40 @@ public virtual ExceptionAssertions<TException> WithMessage(string expectedWildca
             .ForCondition(Subject.Any())
             .FailWith("Expected exception with message {0}{reason}, but no exception was thrown.", expectedWildcardPattern);
 
-        AssertExceptionMessage(Subject.Select(exc => exc.Message), expectedWildcardPattern, because,
-            becauseArgs);
+        AssertExceptionMessage(expectedWildcardPattern, because, becauseArgs);
 
         return this;
     }
 
+    private void AssertExceptionMessage(string expectedWildcardPattern, string because, object[] becauseArgs)
+    {
+        var results = new AssertionResultSet();
+
+        foreach (string message in Subject.Select(exc => exc.Message))
+        {
+            using (var scope = new AssertionScope())
+            {
+                var chain = AssertionChain.GetOrCreate();
+                chain.OverrideCallerIdentifier(() => "exception message");
+                chain.ReuseOnce();
+
+                message.Should().MatchEquivalentOf(expectedWildcardPattern, because, becauseArgs);
+
+                results.AddSet(message, scope.Discard());
+            }
+
+            if (results.ContainsSuccessfulSet())
+            {
+                break;
+            }
+        }
+
+        foreach (string failure in results.GetTheFailuresForTheSetWithTheFewestFailures())
+        {
+            assertionChain.FailWith("{0}", failure.AsNonFormattable());
+        }
+    }
+
     /// <summary>
     /// Asserts that the thrown exception contains an inner exception of type <typeparamref name="TInnerException" />.
     /// </summary>
@@ -167,7 +195,8 @@ public ExceptionAssertions<Exception> WithInnerExceptionExactly(Type innerExcept
     {
         Guard.ThrowIfArgumentIsNull(innerException);
 
-        return new ExceptionAssertions<Exception>(AssertInnerExceptionExactly(innerException, because, becauseArgs), assertionChain);
+        return new ExceptionAssertions<Exception>(AssertInnerExceptionExactly(innerException, because, becauseArgs),
+            assertionChain);
     }
 
     /// <summary>
@@ -195,7 +224,7 @@ public ExceptionAssertions<TException> Where(Expression<Func<TException, bool>>
             .ForCondition(condition(SingleSubject))
             .BecauseOf(because, becauseArgs)
             .FailWith("Expected exception where {0}{reason}, but the condition was not met by:"
-                        + Environment.NewLine + Environment.NewLine + "{1}.",
+                + Environment.NewLine + Environment.NewLine + "{1}.",
                 exceptionExpression, Subject);
 
         return this;
@@ -262,39 +291,6 @@ private TException SingleSubject
 
     private static string BuildExceptionsString(IEnumerable<TException> exceptions)
     {
-        return string.Join(Environment.NewLine,
-            exceptions.Select(
-                exception =>
-                    "\t" + Formatter.ToString(exception)));
-    }
-
-    private void AssertExceptionMessage(IEnumerable<string> messages, string expectation,
-        [StringSyntax("CompositeFormat")] string because, params object[] becauseArgs)
-    {
-        var results = new AssertionResultSet();
-
-        foreach (string message in messages)
-        {
-            using (var scope = new AssertionScope())
-            {
-                var chain = AssertionChain.GetOrCreate();
-                chain.OverrideCallerIdentifier(() => "exception message");
-                chain.ReuseOnce();
-
-                message.Should().MatchEquivalentOf(expectation, because, becauseArgs);
-
-                results.AddSet(message, scope.Discard());
-            }
-
-            if (results.ContainsSuccessfulSet())
-            {
-                break;
-            }
-        }
-
-        foreach (string failure in results.GetTheFailuresForTheSetWithTheFewestFailures())
-        {
-            assertionChain.FailWith("{0}", failure.AsNonFormattable());
-        }
+        return string.Join(Environment.NewLine, exceptions.Select(exception => "\t" + Formatter.ToString(exception)));
     }
 }
diff --git a/Tests/FluentAssertions.Specs/Exceptions/OuterExceptionSpecs.cs b/Tests/FluentAssertions.Specs/Exceptions/OuterExceptionSpecs.cs
index f4c56646be..09af399d21 100644
--- a/Tests/FluentAssertions.Specs/Exceptions/OuterExceptionSpecs.cs
+++ b/Tests/FluentAssertions.Specs/Exceptions/OuterExceptionSpecs.cs
@@ -1,5 +1,6 @@
 ﻿using System;
 using System.Diagnostics.CodeAnalysis;
+using Bogus;
 using Xunit;
 using Xunit.Sdk;
 
@@ -41,6 +42,104 @@ public void When_subject_throws_expected_exception_but_with_unexpected_message_i
         }
     }
 
+    [Fact]
+    public void Long_exception_messages_are_rendered_over_multiple_lines()
+    {
+        // Arrange
+        Does testSubject = Does.Throw(new InvalidOperationException("some"));
+
+        try
+        {
+            // Act
+            testSubject
+                .Invoking(x => x.Do())
+                .Should().Throw<InvalidOperationException>()
+                .WithMessage(new Faker().Random.String2(101));
+
+            throw new XunitException("This point should not be reached");
+        }
+        catch (XunitException ex)
+        {
+            // Assert
+            ex.Message.Should().Match(
+                """
+                Expected exception message to match the equivalent of
+
+                    "*",
+
+                but
+
+                    "some"
+
+                does not.
+
+                """);
+        }
+    }
+
+    [Fact]
+    public void Multiline_exception_messages_are_rendered_over_multiple_lines()
+    {
+        // Arrange
+        Does testSubject = Does.Throw(new InvalidOperationException("some"));
+
+        try
+        {
+            // Act
+            testSubject
+                .Invoking(x => x.Do())
+                .Should().Throw<InvalidOperationException>()
+                .WithMessage("""
+                             line1*
+                             line2
+                             """);
+
+            throw new XunitException("This point should not be reached");
+        }
+        catch (XunitException ex)
+        {
+            // Assert
+            ex.Message.Should().Match(
+                """
+                Expected exception message to match the equivalent of
+
+                    "line1*
+                    line2",
+
+                but
+
+                    "some"
+
+                does not.
+
+                """);
+        }
+    }
+
+    [Fact]
+    public void Short_exception_messages_are_rendered_on_a_single_line()
+    {
+        // Arrange
+        Does testSubject = Does.Throw(new InvalidOperationException("some"));
+
+        try
+        {
+            // Act
+            testSubject
+                .Invoking(x => x.Do())
+                .Should().Throw<InvalidOperationException>()
+                .WithMessage(new Faker().Random.String2(50));
+
+            throw new XunitException("This point should not be reached");
+        }
+        catch (XunitException ex)
+        {
+            // Assert
+            ex.Message.Should().Match(
+                """Expected exception message to match the equivalent of "*", but "some" does not.""");
+        }
+    }
+
     [Fact]
     public void When_subject_throws_expected_exception_with_message_starting_with_expected_message_it_should_not_throw()
     {
@@ -177,7 +276,7 @@ public void
         {
             // Assert
             ex.Message.Should().Match(
-                "Expected exception message to match the equivalent of*\"message2\", but*message2*someParam*");
+                "Expected exception message to match the equivalent of*\"message2\",*but*message2*someParam*");
         }
     }
 
diff --git a/Tests/FluentAssertions.Specs/FluentAssertions.Specs.csproj b/Tests/FluentAssertions.Specs/FluentAssertions.Specs.csproj
index 233682d337..f67a676849 100644
--- a/Tests/FluentAssertions.Specs/FluentAssertions.Specs.csproj
+++ b/Tests/FluentAssertions.Specs/FluentAssertions.Specs.csproj
@@ -1,4 +1,4 @@
-<Project Sdk="Microsoft.NET.Sdk">
+﻿<Project Sdk="Microsoft.NET.Sdk">
 
   <PropertyGroup>
     <TargetFrameworks>net47;net6.0;net8.0</TargetFrameworks>
@@ -25,6 +25,7 @@
   </ItemGroup>
 
   <ItemGroup>
+    <PackageReference Include="Bogus" Version="35.6.2" />
     <PackageReference Include="JetBrains.Annotations" Version="2024.3.0" />
     <PackageReference Include="xunit" Version="2.9.3" />
     <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR improves the rendering of exception messages by reformatting multi-line messages and refactoring string comparison and validation methods. Key changes include updating tests to assert multi‐line formatting, renaming and consolidating validation methods across multiple string comparison strategies, and adjusting the trimming behavior in the formatting component.

### Reviewed Changes

Copilot reviewed 11 out of 12 changed files in this pull request and generated 1 comment.

<details>
<summary>Show a summary per file</summary>

| File | Description |
| ---- | ----------- |
| Tests/FluentAssertions.Specs/Exceptions/OuterExceptionSpecs.cs | Added tests to verify multi‐line, long, and short exception message rendering. |
| Src/FluentAssertions/Specialized/ExceptionAssertions.cs | Refactored exception message assertion logic for clearer output. |
| Src/FluentAssertions/Primitives/StringWildcardMatchingStrategy.cs | Renamed methods and updated formatting for multi‐line messages. |
| Src/FluentAssertions/Primitives/StringValidatorSupportingNull.cs | Updated to call the new AssertForEquality method. |
| Src/FluentAssertions/Primitives/StringValidator.cs | Replaced null validation with a call to AssertNeitherIsNull for better consistency. |
| Src/FluentAssertions/Primitives/StringStartStrategy.cs | Renamed mismatch validation method and added null checks. |
| Src/FluentAssertions/Primitives/StringEqualityStrategy.cs | Renamed methods and updated error message formatting. |
| Src/FluentAssertions/Primitives/StringEndStrategy.cs | Modified error messages and integrated a new null check method. |
| Src/FluentAssertions/Primitives/StringContainsStrategy.cs | Renamed methods and updated error message parameter ordering. |
| Src/FluentAssertions/Primitives/IStringComparisonStrategy.cs | Updated the interface to match the new method names. |
| Src/FluentAssertions/Formatting/Line.cs | Adjusted trimming behavior in ToString to explicitly remove trailing spaces. |
</details>


<details>
<summary>Files not reviewed (1)</summary>

* **Tests/FluentAssertions.Specs/FluentAssertions.Specs.csproj**: Language not supported
</details>

<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**Src/FluentAssertions/Primitives/StringContainsStrategy.cs:25**
* [nitpick] Verify that the parameter order in the error message is intentional; currently, 'subject' is passed as the first parameter and 'expected' as the second, which might lead to confusion compared to similar strategies.
```
                $"Expected {context:string} {0} to contain the equivalent of {1} {expectedOccurrence}{reason}, but found it {actual.Times()}."
```
</details>


state: COMMENTED
author_association: NONE
submitted_at: 2025-03-26T22:19:09Z
user: login: jnyrup
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T13:59:56Z
user: login: jnyrup
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T18:37:48Z
user: login: jnyrup
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-28T18:38:15Z
user: login: dennisdoomen
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T20:28:57Z

Review comments:
diff_hunk: @@ -107,5 +107,5 @@ public Line Truncate(int characterIndex)
         return state.Truncate(characterIndex, indentation, whitespaceOffset);
     }
 
-    public override string ToString() => state.Render().TrimEnd();
+    public override string ToString() => state.Render().TrimEnd(' ');
path: Src/FluentAssertions/Formatting/Line.cs
user: login: Copilot
body: Using TrimEnd(' ') only removes space characters and may leave other trailing whitespace (such as newlines) intact. Consider using TrimEnd() if the intent is to eliminate all trailing whitespace for consistency.
```suggestion
    public override string ToString() => state.Render().TrimEnd();
```
created_at: 2025-03-26T22:19:09Z
author_association: NONE
diff_hunk: @@ -41,6 +42,104 @@ public void When_subject_throws_expected_exception_but_with_unexpected_message_i
         }
     }
 
+    [Fact]
+    public void Long_exception_messages_are_rendered_over_multiple_lines()
+    {
+        // Arrange
+        Does testSubject = Does.Throw(new InvalidOperationException("some"));
+
+        try
+        {
+            // Act
+            testSubject
+                .Invoking(x => x.Do())
+                .Should().Throw<InvalidOperationException>()
+                .WithMessage(new Faker().Random.String2(101));
path: Tests/FluentAssertions.Specs/Exceptions/OuterExceptionSpecs.cs
user: login: jnyrup
body: Bogus is a nice library, but to generate a string of 101 characters the `string` constructor should suffice.
```suggestion
                .WithMessage(new string('#', 101));
```
created_at: 2025-03-28T13:17:30Z
author_association: MEMBER
diff_hunk: @@ -48,6 +75,21 @@ private static string ConvertWildcardToRegEx(string wildcardExpression)
             + "$";
     }
 
+    private static string RenderAsIndentedBlock(string message)
+    {
+        IEnumerable<string> lines = message.Split([Environment.NewLine, "\n", "\r"], StringSplitOptions.None);
+
+        StringBuilder builder = new();
+        var lines2 = lines.Select((line, index) => index == 0 ? "    \"" + line : "    " + line);
+
+        return string.Join(Environment.NewLine, lines2) + "\"";
path: Src/FluentAssertions/Primitives/StringWildcardMatchingStrategy.cs
user: login: jnyrup
body: ```suggestion
        string[] lines = message.Split(["\r\n", "\n", "\r"], StringSplitOptions.None);

        return "    \"" + string.Join(Environment.NewLine + "    ", lines) + "\"";
```
created_at: 2025-03-28T13:39:03Z
author_association: MEMBER
diff_hunk: @@ -41,6 +42,104 @@ public void When_subject_throws_expected_exception_but_with_unexpected_message_i
         }
     }
 
+    [Fact]
+    public void Long_exception_messages_are_rendered_over_multiple_lines()
+    {
+        // Arrange
+        Does testSubject = Does.Throw(new InvalidOperationException("some"));
+
+        try
+        {
+            // Act
+            testSubject
+                .Invoking(x => x.Do())
+                .Should().Throw<InvalidOperationException>()
+                .WithMessage(new Faker().Random.String2(101));
path: Tests/FluentAssertions.Specs/Exceptions/OuterExceptionSpecs.cs
user: login: jnyrup
body: Curious why we need a third party library's random string functionality to generate 101 characters 🤔
created_at: 2025-03-28T18:37:48Z
author_association: MEMBER
diff_hunk: @@ -41,6 +42,104 @@ public void When_subject_throws_expected_exception_but_with_unexpected_message_i
         }
     }
 
+    [Fact]
+    public void Long_exception_messages_are_rendered_over_multiple_lines()
+    {
+        // Arrange
+        Does testSubject = Does.Throw(new InvalidOperationException("some"));
+
+        try
+        {
+            // Act
+            testSubject
+                .Invoking(x => x.Do())
+                .Should().Throw<InvalidOperationException>()
+                .WithMessage(new Faker().Random.String2(101));
path: Tests/FluentAssertions.Specs/Exceptions/OuterExceptionSpecs.cs
user: login: dennisdoomen
body: It was just a convenience. I don't think it's worth a discussion. 
created_at: 2025-03-28T20:28:57Z
author_association: MEMBER

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-13T15:07:05Z
body: # [Qodana](https://qodana.cloud/projects/AgRgk/reports/WaGnv) for .NET

**It seems all right 👌**

No new problems were found according to the checks applied

💡 Qodana analysis was run in the pull request mode: only the changed files were checked
☁️ [View the detailed Qodana report](https://qodana.cloud/projects/AgRgk/reports/WaGnv)

<details>
<summary>Contact Qodana team</summary>

Contact us at [qodana-support@jetbrains.com](mailto:qodana-support@jetbrains.com)
  - Or via our issue tracker: https://jb.gg/qodana-issue
  - Or share your feedback: https://jb.gg/qodana-discussions
</details>
<!-- JetBrains/qodana-action@v2024.2.5 : Qodana for .NET -->
author_association: NONE
user: login: coveralls
created_at: 2025-03-26T00:24:55Z
body: ## Pull Request Test Coverage Report for [Build 14133594137](https://coveralls.io/builds/72976802)


### Details

* **68** of **71**   **(95.77%)**  changed or added relevant lines in **9** files are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage decreased (**-0.03%**) to **97.358%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [Src/FluentAssertions/Primitives/StringContainsStrategy.cs](https://coveralls.io/builds/72976802/source?filename=Src%2FFluentAssertions%2FPrimitives%2FStringContainsStrategy.cs#L31) | 1 | 4 | 25.0%
<!-- | **Total:** | **68** | **71** | **95.77%** | -->


|  Totals | [![Coverage Status](https://coveralls.io/builds/72976802/badge)](https://coveralls.io/builds/72976802) |
| :-- | --: |
| Change from base [Build 14090391235](https://coveralls.io/builds/72938936): |  -0.03% |
| Covered Lines: | 12442 |
| Relevant Lines: | 12637 |

---
##### 💛  - [Coveralls](https://coveralls.io)

author_association: NONE
user: login: dennisdoomen
created_at: 2025-03-28T16:35:39Z
body: @jnyrup did you not approve or reject the PR on purpose?
author_association: MEMBER