Skip to content

Fix formatting exception when comparing strings containing braces#3151

Merged
dennisdoomen merged 3 commits intomainfrom
copilot/fix-system-format-exception
Jan 26, 2026
Merged

Fix formatting exception when comparing strings containing braces#3151
dennisdoomen merged 3 commits intomainfrom
copilot/fix-system-format-exception

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Jan 21, 2026

Be() assertion throws FormatException instead of test failure when comparing strings containing { or } characters.

Root Cause

StringEqualityStrategy.GetMismatchSegmentForLongStrings used StringBuilder.AppendFormat with an interpolated string literal containing user values:

sb.AppendFormat(CultureInfo.InvariantCulture,
    $"""
     Full expectation:
     {expected.RenderAsIndentedBlock().AsNonFormattable()},
     
     Full subject:
     {subject.RenderAsIndentedBlock().AsNonFormattable()}
     """);

This causes double-interpretation: string interpolation inserts user values first, then AppendFormat interprets the result as a format string. Braces in user strings are mistaken for format placeholders.

Changes

  • Use string.Create() to build the interpolated string, then Append() the result
  • Remove unnecessary .AsNonFormattable() calls since we no longer use AppendFormat
string fullDetails = string.Create(CultureInfo.InvariantCulture,
    $"""
     Full expectation:
     {expected.RenderAsIndentedBlock()},
     
     Full subject:
     {subject.RenderAsIndentedBlock()}
     """);
sb.Append(fullDetails);

This ensures user strings are never reinterpreted as format strings.

Original prompt

This section details on the original issue you should resolve

<issue_title>Be() throws System.FormatException when expected value contains "{"</issue_title>
<issue_description>### Description

Hi,

I was writing tests for my code formatter and came across this weird behavior - FluentAssertions use string.Format without sanitizing the values (but that is not optimal solution IMHO).

It is kind of annoying:

This works just fine:

// Ok
[Fact]
public void FormatParameters_MultpleParameters_MovedParametersToTheirOwnLines()
{
    _formatter.Format("""
        codeunit 0 C
        {
            procedure Nop(X: Integer; Y: Integer; Z: Integer)
            begin
            end;
        }
        """).Should().Be("""
        codeunit 0 C
        {
            procedure Nop(
                X: Integer;
                Y: Integer;
                Z: Integer)
            begin
            end;
        }
        """);
}

This throws throws System.FormatException.

[Fact]
public void FormatParameters_MultipleParameters_ShrinksUnnecessaryWhitespace()
{
    _formatter.Format("""
        codeunit 0 C
        {
            procedure Nop(

        // X
        X: Integer;

        // Y
        Y: Integer;

        // Z
        Z: Integer)
            begin
            end;
        }
        """).Should().Be("""
        codeunit 0 C            
        {
            procedure Nop(
                // X
                X: Integer;
                // Y
                Y: Integer;
                // Z
                Z: Integer)
            begin
            end;
        }
        """;
}

Thanks

Reproduction Steps

using FluentAssertions;
using Xunit.Sdk;

namespace DemonCore.Tests;

public class DemonCoreTests
{
    [Fact]
    public void NotOk()
    {
        DemonCore(80).Should().Throw<XunitException>(); // Be() throws System.FormatException
    }

    [Fact]
    public void Ok()
    {
        DemonCore(79).Should().Throw<XunitException>(); // Be() does not throw but fails to format something.
        /*
        Actual assertion message if we just run `DemonCore(79)();`.
    
        **WARNING** failure message 'Expected actual to be a match with the expectation, but it differs at index 0:
           ↓ (actual)
          "{xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
          ""
           ↑ (expected)' could not be formatted with string.Format
           at System.Text.ValueStringBuilder.AppendFormatHelper(IFormatProvider provider, String format, ReadOnlySpan`1 args)
           at System.String.FormatHelper(IFormatProvider provider, String format, ReadOnlySpan`1 args)
           at System.String.Format(IFormatProvider provider, String format, Object[] args)
           at FluentAssertions.Execution.FailureMessageFormatter.FormatArgumentPlaceholders(String failureMessage, Object[] failureArgs)
        */
    }

    private static Action DemonCore(int number) =>
        () =>
        {
            var actual = "{" + new string('x', number);
            var expected = "";
            actual.Should().Be(expected);
        };
}
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="coverlet.collector" Version="6.0.4" />
    <PackageReference Include="FluentAssertions" Version="8.8.0" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
    <PackageReference Include="xunit" Version="2.9.3" />
    <PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
  </ItemGroup>

  <ItemGroup>
    <Using Include="Xunit" />
  </ItemGroup>

</Project>

P.S.: The fact that it fails around 79-80 is funny.

Expected behavior

Be should throw only XunitException for string comparisons - strings are either equal or not equal.

IMHO, building final assertion message incrementally using multiple string.Format calls feels too hacky.
Probably, a better approach would be to create some tree-ish type and walking over it to build the final string. But I'm not regularly programming in C# so don't listen to me. :)

Actual behavior

In reproduction tests:

  • The NotOk test fails - the actual string is "{" and then 80+ "x"s and it is compared to an empty string.
  • The Ok test succeeds but it has string.Format error in the assertion message - the actual string is "{" and 79 "x"s.

Full dotnet test result:

PS C:\MY\NotWork\UpdateAlWorkspaceThing> dotnet test
Restore complete (0,5s)
  UpdateAlWorkspaceThing.Tests net10.0 succeeded (0,1s) → UpdateAlWorkspaceThing.Tests\bin\Debug\net10.0\UpdateAlWorkspaceThing.Tes...

</details>



<!-- START COPILOT CODING AGENT SUFFIX -->

- Fixes fluentassertions/fluentassertions#3147

<!-- START COPILOT CODING AGENT TIPS -->
---

✨ Let Copilot coding agent [set things up for you](https://github.com/fluentassertions/fluentassertions/issues/new?title=+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot) — coding agent works faster and does higher quality work when set up for your repo.

Copilot AI changed the title [WIP] Fix System.FormatException when value contains "{" Fix FormatException when comparing strings containing braces Jan 21, 2026
Copilot AI requested a review from dennisdoomen January 21, 2026 19:41
@github-actions
Copy link
Copy Markdown

github-actions bot commented Jan 21, 2026

Test Results

    37 files  ± 0      37 suites  ±0   2m 43s ⏱️ -22s
 6 290 tests + 2   6 290 ✅ + 2  0 💤 ±0  0 ❌ ±0 
39 086 runs  +12  39 086 ✅ +12  0 💤 ±0  0 ❌ ±0 

Results for commit e773f85. ± Comparison against base commit 4762446.

This pull request removes 10 and adds 10 tests. Note that renamed tests count towards both.
FluentAssertions.Specs.Streams.StreamAssertionSpecs+HaveLength ‑ When_a_throwing_stream_should_have_a_length_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
FluentAssertions.Specs.Streams.StreamAssertionSpecs+HaveLength ‑ When_a_throwing_stream_should_have_a_length_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
FluentAssertions.Specs.Streams.StreamAssertionSpecs+HavePosition ‑ When_a_throwing_stream_should_have_a_position_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
FluentAssertions.Specs.Streams.StreamAssertionSpecs+HavePosition ‑ When_a_throwing_stream_should_have_a_position_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
FluentAssertions.Specs.Streams.StreamAssertionSpecs+NotHaveLength ‑ When_a_throwing_stream_should_not_have_a_length_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
FluentAssertions.Specs.Streams.StreamAssertionSpecs+NotHaveLength ‑ When_a_throwing_stream_should_not_have_a_length_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
FluentAssertions.Specs.Streams.StreamAssertionSpecs+NotHavePosition ‑ When_a_throwing_stream_should_not_have_a_position_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
FluentAssertions.Specs.Streams.StreamAssertionSpecs+NotHavePosition ‑ When_a_throwing_stream_should_not_have_a_position_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
Object name: 'GetLengthExceptionMessage'.)
Object name: 'GetPositionExceptionMessage'.)
FluentAssertions.Specs.Primitives.StringAssertionSpecs+Be ‑ When_long_string_contains_braces_it_should_display_properly
FluentAssertions.Specs.Primitives.StringAssertionSpecs+Be ‑ When_string_contains_opening_brace_it_should_not_throw_format_exception
FluentAssertions.Specs.Streams.StreamAssertionSpecs+HaveLength ‑ When_a_throwing_stream_should_have_a_length_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
Object name: 'GetLengthExceptionMessage'.)
FluentAssertions.Specs.Streams.StreamAssertionSpecs+HaveLength ‑ When_a_throwing_stream_should_have_a_length_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
Object name: 'GetLengthExceptionMessage'.)
FluentAssertions.Specs.Streams.StreamAssertionSpecs+HavePosition ‑ When_a_throwing_stream_should_have_a_position_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
Object name: 'GetPositionExceptionMessage'.)
FluentAssertions.Specs.Streams.StreamAssertionSpecs+HavePosition ‑ When_a_throwing_stream_should_have_a_position_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
Object name: 'GetPositionExceptionMessage'.)
FluentAssertions.Specs.Streams.StreamAssertionSpecs+NotHaveLength ‑ When_a_throwing_stream_should_not_have_a_length_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
Object name: 'GetLengthExceptionMessage'.)
FluentAssertions.Specs.Streams.StreamAssertionSpecs+NotHaveLength ‑ When_a_throwing_stream_should_not_have_a_length_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
Object name: 'GetLengthExceptionMessage'.)
FluentAssertions.Specs.Streams.StreamAssertionSpecs+NotHavePosition ‑ When_a_throwing_stream_should_not_have_a_position_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
Object name: 'GetPositionExceptionMessage'.)
FluentAssertions.Specs.Streams.StreamAssertionSpecs+NotHavePosition ‑ When_a_throwing_stream_should_not_have_a_position_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
Object name: 'GetPositionExceptionMessage'.)

♻️ This comment has been updated with latest results.

@coveralls
Copy link
Copy Markdown

coveralls commented Jan 21, 2026

Pull Request Test Coverage Report for Build 21300676482

Details

  • 4 of 4 (100.0%) changed or added relevant lines in 1 file are covered.
  • No unchanged relevant lines lost coverage.
  • Overall coverage increased (+0.001%) to 97.185%

Totals Coverage Status
Change from base Build 20931548150: 0.001%
Covered Lines: 12851
Relevant Lines: 13070

💛 - Coveralls

@github-actions
Copy link
Copy Markdown

github-actions bot commented Jan 21, 2026

Qodana 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

Contact Qodana team

Contact us at [email protected]

@dennisdoomen dennisdoomen marked this pull request as ready for review January 23, 2026 20:45
@dennisdoomen dennisdoomen force-pushed the copilot/fix-system-format-exception branch from 233a12c to e773f85 Compare January 23, 2026 20:47
@dennisdoomen dennisdoomen requested a review from jnyrup January 23, 2026 20:49
@jnyrup jnyrup linked an issue Jan 25, 2026 that may be closed by this pull request

if (IncludeFullDetails && wasTruncated)
{
sb.AppendFormat(CultureInfo.InvariantCulture,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we polyfill StringBuilder.Append in StringBuilderExtensions we can avoid the intermediate string.

sb.Append(CultureInfo.InvariantCulture,
    $"""


        Full expectation:

        {expected.RenderAsIndentedBlock()},

        Full subject:

        {subject.RenderAsIndentedBlock()}

        """);

For StringBuilderExtensions

public static StringBuilder AppendLine(this StringBuilder stringBuilder, IFormatProvider provider, FormattableString formattable) =>
    stringBuilder.AppendLine(string.Create(provider, formattable));

public static StringBuilder Append(this StringBuilder stringBuilder, IFormatProvider provider, FormattableString formattable) =>
    stringBuilder.Append(string.Create(provider, formattable));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that a suggestion? And why is that important? This code is only executed for a failing test and when the full details are requested.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a suggestion and not blocking for approving the PR.

@dennisdoomen dennisdoomen changed the title Fix FormatException when comparing strings containing braces Fix formatting exception when comparing strings containing braces Jan 26, 2026
@dennisdoomen dennisdoomen merged commit 60b430a into main Jan 26, 2026
14 checks passed
@dennisdoomen dennisdoomen deleted the copilot/fix-system-format-exception branch January 26, 2026 06:29
@jnyrup jnyrup mentioned this pull request Feb 15, 2026
9 tasks
jnyrup pushed a commit to jnyrup/fluentassertions that referenced this pull request Feb 28, 2026
jnyrup pushed a commit to jnyrup/fluentassertions that referenced this pull request Mar 7, 2026
This was referenced Apr 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Be() throws System.FormatException when expected value contains "{"

4 participants