Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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.
- Numeric arguments in test display names do not depend on culture: they are always formatted with the invariant culture, so `[Arguments(1.5, -2)]` is displayed as `Test(1.5, -2)` on every machine. Other values, such as `DateTime`, still use their own `ToString()`, which may depend on the current culture. Use a custom [argument formatter](../extending/argument-formatters.md) if you want a different or culture-independent format.

## See Also

Expand Down
25 changes: 24 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,23 @@ private static string FormatDefault(object? o, Type? parameterType)
return toString;
}

private static bool IsNumeric(object o, Type type)
{
// An explicit allow-list rather than a plain `o is IFormattable` check: DateTime, DateTimeOffset,
// TimeSpan, Guid, enums and user types are IFormattable too, and their display text must not change.
// A new numeric type has to be added here.
// bool and char are primitive but not IFormattable, and their text doesn't depend on culture.
// IntPtr and UIntPtr are primitive too: IFormattable from .NET 5, so formatted invariantly there; on
// netstandard2.0 they are not IFormattable and keep ToString(), which formats with the invariant culture.
// ArgumentFormatterCultureTests checks every INumberBase<T> type of the runtime against this list.
return (type.IsPrimitive && o is IFormattable)
|| o is decimal or BigInteger or Complex
#if NET
|| o is Half or Int128 or UInt128 or System.Runtime.InteropServices.NFloat
#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,46 @@
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";

// Runs for the whole TestProject module, but does nothing unless TUnit.Engine.Tests sets the env var,
// so other tests in this project are unaffected.
[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)");
}
}
173 changes: 173 additions & 0 deletions tests/TUnit.UnitTests/ArgumentFormatterCultureTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
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");
}

[Test]
[MethodDataSource(nameof(Cultures))]
public async Task Format_Formats_Complex_Invariantly(CultureInfo culture)
{
var value = new Complex(1.5, -2);

var result = WithCulture(culture, () => ArgumentFormatter.Format(value, []));

// Complex's own layout differs between runtimes ("(1.5, -2)" vs "<1.5; -2>"), so compare against
// its invariant text rather than a literal; a culture-dependent result would contain "1,5" or U+2212.
await Assert.That(result).IsEqualTo(value.ToString(null, CultureInfo.InvariantCulture));
}

#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), new System.Runtime.InteropServices.NFloat(-2.5)];

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

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

#if NET8_0_OR_GREATER
/// <summary>
/// ArgumentFormatter.IsNumeric is an explicit list of types. This fails when a .NET version adds a
/// numeric type (one implementing INumberBase&lt;T&gt;) that the list misses, so that the type's display
/// text does not quietly fall back to the current culture.
/// </summary>
[Test]
public async Task Every_Runtime_Numeric_Type_Is_Formatted_Invariantly()
{
var culture = WithDecimalCommaAndUnicodeMinus("sv-SE");
var assemblies = new[] { typeof(int).Assembly, typeof(BigInteger).Assembly, typeof(System.Runtime.InteropServices.NFloat).Assembly }.Distinct();
var createValue = typeof(ArgumentFormatterCultureTests).GetMethod(nameof(MinusOneAndAHalf), System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!;

var checkedTypes = new List<Type>();
var cultureDependent = new List<string>();
foreach (var type in assemblies.SelectMany(a => a.GetExportedTypes()))
{
if (!type.IsValueType || type.IsGenericTypeDefinition
|| !type.GetInterfaces().Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(INumberBase<>) && i.GetGenericArguments()[0] == type))
{
continue;
}

object value;
try
{
value = createValue.MakeGenericMethod(type).Invoke(null, null)!;
}
catch (System.Reflection.TargetInvocationException)
{
// The type can't be created from a double (for example char); its text has no culture-specific parts
continue;
}

checkedTypes.Add(type);
var expected = ((IFormattable)value).ToString(null, CultureInfo.InvariantCulture);
var actual = WithCulture(culture, () => ArgumentFormatter.Format(value, []));
if (actual != expected)
{
cultureDependent.Add($"{type.FullName}: '{actual}', expected '{expected}'");
}
}

await Assert.That(checkedTypes).Contains(typeof(double));
await Assert.That(checkedTypes).Contains(typeof(BigInteger));
await Assert.That(cultureDependent).IsEmpty();
}

private static object MinusOneAndAHalf<T>() where T : INumberBase<T> => T.CreateTruncating(-1.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;
}
}
}
4 changes: 2 additions & 2 deletions tests/TUnit.UnitTests/ArgumentFormatterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ public async Task FormatArguments_WithTuples_FormatsCorrectly()

var result = ArgumentFormatter.FormatArguments(args);

// Primitives are formatted with ToString(), i.e. the current culture (3,14 under sv-SE).
await Assert.That(result).IsEqualTo($"(1, a), 42, (True, {3.14})");
// Numbers are formatted with the invariant culture, so this holds under sv-SE as well (#7126).
await Assert.That(result).IsEqualTo("(1, a), 42, (True, 3.14)");
}

[Test]
Expand Down
Loading