Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/docs/writing-tests/culture.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ using TUnit.Core.Executors;

- Only one culture can be specified per scope. To run the same test under multiple cultures, factor out the test logic into a private method and call it from separate test methods, each with its own `[Culture]` attribute.
- The attribute sets both `CurrentCulture` and `CurrentUICulture` for the executing thread.
- Test display names do not depend on culture: numeric arguments are always formatted with the invariant culture, so `[Arguments(1.5, -2)]` is displayed as `Test(1.5, -2)` on every machine. Use a custom [argument formatter](../extending/argument-formatters.md) if you want a different format.
Comment thread
thomhurst marked this conversation as resolved.
Resolved

## See Also

Expand Down
19 changes: 18 additions & 1 deletion src/TUnit.Core/Helpers/ArgumentFormatter.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.Collections;
using System.Globalization;
using System.Numerics;
using System.Text;

namespace TUnit.Core.Helpers;
Expand Down Expand Up @@ -118,7 +120,11 @@ private static string FormatDefault(object? o, Type? parameterType)
string toString;
try
{
toString = o.ToString()!;
// Numbers are formatted with the invariant culture so a display name is the same on every
// machine, and a decimal comma (sv-SE, de-DE, ...) can't be mistaken for the ", " separator.
toString = IsNumeric(o, type)
? ((IFormattable)o).ToString(null, CultureInfo.InvariantCulture)
: o.ToString()!;
}
catch
{
Expand Down Expand Up @@ -150,6 +156,17 @@ private static string FormatDefault(object? o, Type? parameterType)
return toString;
}

private static bool IsNumeric(object o, Type type)
{
// bool and char are primitive but not IFormattable, and their text doesn't depend on culture.
return (type.IsPrimitive && o is IFormattable)
|| o is decimal or BigInteger
#if NET
|| o is Half or Int128 or UInt128
Comment thread
thomhurst marked this conversation as resolved.
Resolved
#endif
;
}

private static string FormatTuple(object tuple)
{
var elements = TupleHelper.UnwrapTuple(tuple);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using Shouldly;
using TUnit.Engine.Tests.Enums;

namespace TUnit.Engine.Tests;

/// <summary>
/// End-to-end regression tests for https://github.com/thomhurst/TUnit/issues/7126.
/// Display names must format numeric arguments with the invariant culture, identically in
/// source-generated and reflection mode, whatever culture the test process runs under.
/// </summary>
public class Bug7126CultureInvariantDisplayNameTests(TestMode testMode) : InvokableTestBase(testMode)
{
[Test]
[Arguments("en-US")]
[Arguments("sv-SE")]
[Arguments("de-DE")]
public async Task Numeric_Arguments_Are_Displayed_Invariantly(string culture)
{
await RunTestsWithFilter(
"/*/*/Bug7126CultureInvariantDisplayNameTests/*",
[
result => result.ResultSummary.Outcome.ShouldBe("Completed"),
result => result.ResultSummary.Counters.Total.ShouldBe(1),
result => result.ResultSummary.Counters.Passed.ShouldBe(1),
result => result.ResultSummary.Counters.Failed.ShouldBe(0),
result => result.Results.Single().TestName.ShouldBe("NumericArguments(1.5, -2, 2.25, 3.75)")
],
new RunOptions().WithEnvironmentVariable("TUNIT_BUG_7126_CULTURE", culture));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System.Globalization;
using System.Runtime.CompilerServices;
using TUnit.TestProject.Attributes;

namespace TUnit.TestProject.Bugs._7126;

/// <summary>
/// Repro for https://github.com/thomhurst/TUnit/issues/7126 — numeric arguments in display names
/// were formatted with the current culture, so under a decimal-comma culture (sv-SE, de-DE, ...)
/// <c>[Arguments(1.5, -2)]</c> became <c>Test(1,5, −2)</c>: ambiguous against the argument separator
/// and different from machine to machine.
/// TUnit.Engine.Tests sets <c>TUNIT_BUG_7126_CULTURE</c> to run this project under such a culture.
/// </summary>
[EngineTest(ExpectedResult.Pass)]
public class Bug7126CultureInvariantDisplayNameTests
{
internal const string CultureEnvironmentVariable = "TUNIT_BUG_7126_CULTURE";

[ModuleInitializer]
internal static void ApplyRequestedCulture()
{
if (Environment.GetEnvironmentVariable(CultureEnvironmentVariable) is { Length: > 0 } cultureName)
{
var culture = CultureInfo.GetCultureInfo(cultureName);
CultureInfo.DefaultThreadCurrentCulture = culture;
CultureInfo.DefaultThreadCurrentUICulture = culture;
CultureInfo.CurrentCulture = culture;
CultureInfo.CurrentUICulture = culture;
}
}

[Test]
[Arguments(1.5, -2, 2.25f, 3.75)]
public async Task NumericArguments(double value, int count, float ratio, decimal amount)
{
if (Environment.GetEnvironmentVariable(CultureEnvironmentVariable) is { Length: > 0 } cultureName)
{
await Assert.That(CultureInfo.CurrentCulture.Name).IsEqualTo(cultureName);
}

await Assert.That(TestContext.Current!.Metadata.DisplayName)
.IsEqualTo("NumericArguments(1.5, -2, 2.25, 3.75)");
}
}
109 changes: 109 additions & 0 deletions tests/TUnit.UnitTests/ArgumentFormatterCultureTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
using System.Globalization;
using System.Numerics;
using TUnit.Core.Helpers;

namespace TUnit.UnitTests;

/// <summary>
/// Regression tests for https://github.com/thomhurst/TUnit/issues/7126: display-name arguments must not
/// depend on the current culture.
/// </summary>
public class ArgumentFormatterCultureTests
{
public static IEnumerable<Func<CultureInfo>> Cultures()
{
yield return () => CultureInfo.InvariantCulture;
yield return () => CultureInfo.GetCultureInfo("en-US");
// Pin the decimal comma and U+2212 MINUS SIGN: sv-SE uses both under ICU, but not under NLS.
yield return () => WithDecimalCommaAndUnicodeMinus("sv-SE");
yield return () => WithDecimalCommaAndUnicodeMinus("de-DE");
}

[Test]
[MethodDataSource(nameof(Cultures))]
public async Task FormatArguments_Formats_Numbers_Invariantly(CultureInfo culture)
{
object?[] args = [1.5, -2, 2.25f, 3.75m, -1234567.891, (short)-7, 9_000_000_000L, new BigInteger(-42)];

var result = WithCulture(culture, () => ArgumentFormatter.FormatArguments(args));

await Assert.That(result).IsEqualTo("1.5, -2, 2.25, 3.75, -1234567.891, -7, 9000000000, -42");
}

[Test]
[MethodDataSource(nameof(Cultures))]
public async Task Format_Formats_Numbers_Inside_Tuples_And_Collections_Invariantly(CultureInfo culture)
{
var (tuple, array) = WithCulture(culture, () => (
ArgumentFormatter.Format((1.5, -2), []),
ArgumentFormatter.Format(new[] { 0.5, -0.25 }, [])));

await Assert.That(tuple).IsEqualTo("(1.5, -2)");
await Assert.That(array).IsEqualTo("0.5, -0.25");
}

[Test]
[MethodDataSource(nameof(Cultures))]
public async Task Format_With_Parameter_Type_Formats_Numbers_Invariantly(CultureInfo culture)
{
var result = WithCulture(culture, () => ArgumentFormatter.Format(-1.5m, typeof(decimal), []));

await Assert.That(result).IsEqualTo("-1.5");
}

#if NET
[Test]
[MethodDataSource(nameof(Cultures))]
public async Task FormatArguments_Formats_Modern_Numeric_Types_Invariantly(CultureInfo culture)
{
object?[] args = [(Half)(-1.5), (Int128)(-3), (UInt128)4, (nint)(-5)];

var result = WithCulture(culture, () => ArgumentFormatter.FormatArguments(args));

await Assert.That(result).IsEqualTo("-1.5, -3, 4, -5");
}
#endif

[Test]
[MethodDataSource(nameof(Cultures))]
public async Task Custom_Formatters_Still_Take_Precedence(CultureInfo culture)
{
var result = WithCulture(culture, () =>
ArgumentFormatter.Format(1.5, [o => o is double d ? d.ToString("F2", CultureInfo.CurrentCulture) : null]));

await Assert.That(result).IsEqualTo(1.5.ToString("F2", culture));
}

[Test]
[MethodDataSource(nameof(Cultures))]
public async Task Non_Numeric_Primitives_And_Enums_Are_Unchanged(CultureInfo culture)
{
var result = WithCulture(culture, () =>
ArgumentFormatter.FormatArguments([true, 'x', DayOfWeek.Monday, "a.b"]));

await Assert.That(result).IsEqualTo("True, x, Monday, a·b");
}

private static CultureInfo WithDecimalCommaAndUnicodeMinus(string name)
{
var culture = (CultureInfo)CultureInfo.GetCultureInfo(name).Clone();
culture.NumberFormat.NumberDecimalSeparator = ",";
culture.NumberFormat.NumberGroupSeparator = " ";
culture.NumberFormat.NegativeSign = "−";
return culture;
}

private static T WithCulture<T>(CultureInfo culture, Func<T> action)
{
var original = CultureInfo.CurrentCulture;
CultureInfo.CurrentCulture = culture;
try
{
return action();
}
finally
{
CultureInfo.CurrentCulture = original;
}
}
}
Loading