diff --git a/src/TestFramework/TestFramework/Assertions/Assert.ThrowsException.cs b/src/TestFramework/TestFramework/Assertions/Assert.ThrowsException.cs index 5c39d4138e..1f83c35a38 100644 --- a/src/TestFramework/TestFramework/Assertions/Assert.ThrowsException.cs +++ b/src/TestFramework/TestFramework/Assertions/Assert.ThrowsException.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System.ComponentModel; @@ -25,8 +25,8 @@ public readonly struct AssertNonStrictThrowsInterpolatedStringHandler(action, isStrictType: false, "Throws"); - shouldAppend = _state.FailAction is not null; + _state = IsThrowsFailing(action, isStrictType: false); + shouldAppend = _state.FailureKind != ThrowsFailureKind.NotFailing; if (shouldAppend) { _builder = new StringBuilder(literalLength + formattedCount); @@ -40,17 +40,16 @@ public AssertNonStrictThrowsInterpolatedStringHandler(int literalLength, int for internal TException ComputeAssertion(string actionExpression) { - if (_state.FailAction is not null) + if (_state.FailureKind != ThrowsFailureKind.NotFailing) { - _builder!.Insert(0, string.Format(CultureInfo.CurrentCulture, FrameworkMessages.CallerArgumentExpressionSingleParameterMessage, "action", actionExpression) + " "); - _state.FailAction(_builder!.ToString()); + ReportThrowsFailed(isStrictType: false, _state, _builder!.ToString(), actionExpression, nameof(Throws)); } else { return (TException)_state.ExceptionThrown!; } - // This will not hit, but need it for compiler. + // Reached when ReportThrowsFailed records the failure into the active AssertScope and returns instead of throwing. return null!; } @@ -96,8 +95,8 @@ public readonly struct AssertThrowsExactlyInterpolatedStringHandler public AssertThrowsExactlyInterpolatedStringHandler(int literalLength, int formattedCount, Action action, out bool shouldAppend) { - _state = IsThrowsFailing(action, isStrictType: true, "ThrowsExactly"); - shouldAppend = _state.FailAction is not null; + _state = IsThrowsFailing(action, isStrictType: true); + shouldAppend = _state.FailureKind != ThrowsFailureKind.NotFailing; if (shouldAppend) { _builder = new StringBuilder(literalLength + formattedCount); @@ -111,17 +110,16 @@ public AssertThrowsExactlyInterpolatedStringHandler(int literalLength, int forma internal TException ComputeAssertion(string actionExpression) { - if (_state.FailAction is not null) + if (_state.FailureKind != ThrowsFailureKind.NotFailing) { - _builder!.Insert(0, string.Format(CultureInfo.CurrentCulture, FrameworkMessages.CallerArgumentExpressionSingleParameterMessage, "action", actionExpression) + " "); - _state.FailAction(_builder!.ToString()); + ReportThrowsFailed(isStrictType: true, _state, _builder!.ToString(), actionExpression, nameof(ThrowsExactly)); } else { return (TException)_state.ExceptionThrown!; } - // This will not hit, but need it for compiler. + // Reached when ReportThrowsFailed records the failure into the active AssertScope and returns instead of throwing. return null!; } @@ -324,17 +322,17 @@ private static TException ThrowsException(Action action, bool isStri _ = action ?? throw new ArgumentNullException(nameof(action)); _ = message ?? throw new ArgumentNullException(nameof(message)); - ThrowsExceptionState state = IsThrowsFailing(action, isStrictType, assertMethodName); - if (state.FailAction is not null) + ThrowsExceptionState state = IsThrowsFailing(action, isStrictType); + if (state.FailureKind != ThrowsFailureKind.NotFailing) { - state.FailAction(BuildUserMessageForActionExpression(message, actionExpression)); + ReportThrowsFailed(isStrictType, state, message, actionExpression, assertMethodName); } else { return (TException)state.ExceptionThrown!; } - // This will not hit, but need it for compiler. + // Reached when ReportThrowsFailed records the failure into the active AssertScope and returns instead of throwing. return null!; } @@ -344,17 +342,17 @@ private static TException ThrowsException(Action action, bool isStri _ = action ?? throw new ArgumentNullException(nameof(action)); _ = messageBuilder ?? throw new ArgumentNullException(nameof(messageBuilder)); - ThrowsExceptionState state = IsThrowsFailing(action, isStrictType, assertMethodName); - if (state.FailAction is not null) + ThrowsExceptionState state = IsThrowsFailing(action, isStrictType); + if (state.FailureKind != ThrowsFailureKind.NotFailing) { - state.FailAction(BuildUserMessageForActionExpression(messageBuilder(state.ExceptionThrown), actionExpression)); + ReportThrowsFailed(isStrictType, state, messageBuilder(state.ExceptionThrown), actionExpression, assertMethodName); } else { return (TException)state.ExceptionThrown!; } - // This will not hit, but need it for compiler. + // Reached when ReportThrowsFailed records the failure into the active AssertScope and returns instead of throwing. return null!; } @@ -480,17 +478,17 @@ private static async Task ThrowsExceptionAsync(Func(action, isStrictType, assertMethodName).ConfigureAwait(false); - if (state.FailAction is not null) + ThrowsExceptionState state = await IsThrowsAsyncFailingAsync(action, isStrictType).ConfigureAwait(false); + if (state.FailureKind != ThrowsFailureKind.NotFailing) { - state.FailAction(BuildUserMessageForActionExpression(message, actionExpression)); + ReportThrowsFailed(isStrictType, state, message, actionExpression, assertMethodName); } else { return (TException)state.ExceptionThrown!; } - // This will not hit, but need it for compiler. + // Reached when ReportThrowsFailed records the failure into the active AssertScope and returns instead of throwing. return null!; } @@ -500,21 +498,21 @@ private static async Task ThrowsExceptionAsync(Func(action, isStrictType, assertMethodName).ConfigureAwait(false); - if (state.FailAction is not null) + ThrowsExceptionState state = await IsThrowsAsyncFailingAsync(action, isStrictType).ConfigureAwait(false); + if (state.FailureKind != ThrowsFailureKind.NotFailing) { - state.FailAction(BuildUserMessageForActionExpression(messageBuilder(state.ExceptionThrown), actionExpression)); + ReportThrowsFailed(isStrictType, state, messageBuilder(state.ExceptionThrown), actionExpression, assertMethodName); } else { return (TException)state.ExceptionThrown!; } - // This will not hit, but need it for compiler. + // Reached when ReportThrowsFailed records the failure into the active AssertScope and returns instead of throwing. return null!; } - private static async Task IsThrowsAsyncFailingAsync(Func action, bool isStrictType, string assertMethodName) + private static async Task IsThrowsAsyncFailingAsync(Func action, bool isStrictType) where TException : Exception { try @@ -529,32 +527,13 @@ private static async Task IsThrowsAsyncFailingAsync - { - string finalMessage = string.Format( - CultureInfo.CurrentCulture, - FrameworkMessages.WrongExceptionThrown, - userMessage, - typeof(TException), - ex.GetType()); - ReportAssertFailed("Assert." + assertMethodName, finalMessage); - }, ex); + : ThrowsExceptionState.CreateWrongTypeState(ex); } - return ThrowsExceptionState.CreateFailingState( - failAction: userMessage => - { - string finalMessage = string.Format( - CultureInfo.CurrentCulture, - FrameworkMessages.NoExceptionThrown, - userMessage, - typeof(TException)); - ReportAssertFailed("Assert." + assertMethodName, finalMessage); - }, null); + return ThrowsExceptionState.CreateNoExceptionState(); } - private static ThrowsExceptionState IsThrowsFailing(Action action, bool isStrictType, string assertMethodName) + private static ThrowsExceptionState IsThrowsFailing(Action action, bool isStrictType) where TException : Exception { try @@ -569,49 +548,129 @@ private static ThrowsExceptionState IsThrowsFailing(Action action, b return isExceptionOfType ? ThrowsExceptionState.CreateNotFailingState(ex) - : ThrowsExceptionState.CreateFailingState( - userMessage => - { - string finalMessage = string.Format( - CultureInfo.CurrentCulture, - FrameworkMessages.WrongExceptionThrown, - userMessage, - typeof(TException), - ex.GetType()); - ReportAssertFailed("Assert." + assertMethodName, finalMessage); - }, ex); + : ThrowsExceptionState.CreateWrongTypeState(ex); + } + + return ThrowsExceptionState.CreateNoExceptionState(); + } + + [StackTraceHidden] + private static void ReportThrowsFailed( + bool isStrictType, + ThrowsExceptionState state, + string? userMessage, + string actionExpression, + string assertMethodName) + where TException : Exception + { + Type expectedType = typeof(TException); + string expectedTypeName = GetDisplayTypeName(expectedType, includeNamespace: false); + string expectedTypeFullName = GetDisplayTypeName(expectedType, includeNamespace: true); + + StructuredAssertionMessage message; + + if (state.FailureKind == ThrowsFailureKind.NoExceptionThrown) + { + string summary = isStrictType + ? $"Expected exception of exact type {expectedTypeName} but no exception was thrown." + : $"Expected exception of type {expectedTypeName} (or derived) but no exception was thrown."; + + message = new StructuredAssertionMessage(summary) + .WithUserMessage(userMessage) + .WithCallSiteExpression(FormatCallSiteExpression($"Assert.{assertMethodName}<{expectedTypeName}>", actionExpression)); + } + else + { + Exception actualException = state.ExceptionThrown!; + Type actualType = actualException.GetType(); + string actualTypeName = GetDisplayTypeName(actualType, includeNamespace: false); + string actualTypeFullName = GetDisplayTypeName(actualType, includeNamespace: true); + + string summary = isStrictType + ? $"Expected exception of exact type {expectedTypeName} but caught {actualTypeName}." + : $"Expected exception of type {expectedTypeName} (or derived) but caught {actualTypeName}."; + + string expectedTypeLabel = isStrictType ? expectedTypeFullName : $"{expectedTypeFullName} (or derived)"; + + EvidenceBlock evidence = EvidenceBlock.Create() + .AddLine("expected type:", expectedTypeLabel) + .AddLine("actual type:", actualTypeFullName) + .AddLine("actual exception:", $"{actualTypeFullName}: {actualException.Message}"); + + message = new StructuredAssertionMessage(summary) + .WithUserMessage(userMessage) + .WithEvidence(evidence) + .WithCallSiteExpression(FormatCallSiteExpression($"Assert.{assertMethodName}<{expectedTypeName}>", actionExpression)); } - return ThrowsExceptionState.CreateFailingState( - failAction: userMessage => + ReportAssertFailed(message); + } + + // Renders a type name without the CLR backtick-arity suffix and with closed generic arguments expanded recursively + // (e.g. "MyException`1" with int → "MyException") so it stays readable in summary lines and pasteable in call-site lines. + private static string GetDisplayTypeName(Type type, bool includeNamespace) + { + if (!type.IsGenericType) + { + return includeNamespace ? type.FullName ?? type.Name : type.Name; + } + + string name = type.Name; + int tick = name.IndexOf('`'); + if (tick >= 0) + { + name = name.Substring(0, tick); + } + + if (includeNamespace && !string.IsNullOrEmpty(type.Namespace)) + { + name = $"{type.Namespace}.{name}"; + } + + Type[] args = type.GetGenericArguments(); + StringBuilder sb = new(name.Length + 16); + sb.Append(name); + sb.Append('<'); + for (int i = 0; i < args.Length; i++) + { + if (i > 0) { - string finalMessage = string.Format( - CultureInfo.CurrentCulture, - FrameworkMessages.NoExceptionThrown, - userMessage, - typeof(TException)); - ReportAssertFailed("Assert." + assertMethodName, finalMessage); - }, null); + sb.Append(", "); + } + + sb.Append(GetDisplayTypeName(args[i], includeNamespace)); + } + + sb.Append('>'); + return sb.ToString(); + } + + private enum ThrowsFailureKind : byte + { + NotFailing, + NoExceptionThrown, + WrongExceptionType, } private readonly struct ThrowsExceptionState { public Exception? ExceptionThrown { get; } - public Action? FailAction { get; } + public ThrowsFailureKind FailureKind { get; } - private ThrowsExceptionState(Exception? exceptionThrown, Action? failAction) + private ThrowsExceptionState(ThrowsFailureKind failureKind, Exception? exceptionThrown) { - // If the assert is failing, failAction should be non-null, and exceptionWhenNotFailing may or may not be null. - // If the assert is not failing, exceptionWhenNotFailing should be non-null, and failAction should be null. ExceptionThrown = exceptionThrown; - FailAction = failAction; + FailureKind = failureKind; } - public static ThrowsExceptionState CreateFailingState(Action failAction, Exception? exceptionThrown) - => new(exceptionThrown, failAction); + public static ThrowsExceptionState CreateWrongTypeState(Exception exceptionThrown) + => new(ThrowsFailureKind.WrongExceptionType, exceptionThrown); + + public static ThrowsExceptionState CreateNoExceptionState() + => new(ThrowsFailureKind.NoExceptionThrown, null); public static ThrowsExceptionState CreateNotFailingState(Exception exception) - => new(exception, failAction: null); + => new(ThrowsFailureKind.NotFailing, exception); } } diff --git a/src/TestFramework/TestFramework/Assertions/Assert.cs b/src/TestFramework/TestFramework/Assertions/Assert.cs index 56b13403ec..9a152c193b 100644 --- a/src/TestFramework/TestFramework/Assertions/Assert.cs +++ b/src/TestFramework/TestFramework/Assertions/Assert.cs @@ -246,9 +246,6 @@ private static string BuildUserMessageForConditionExpression(string? format, str private static string BuildUserMessageForValueExpression(string? format, string valueExpression) => BuildUserMessageForSingleExpression(format, valueExpression, "value"); - private static string BuildUserMessageForActionExpression(string? format, string actionExpression) - => BuildUserMessageForSingleExpression(format, actionExpression, "action"); - private static string BuildUserMessageForCollectionExpression(string? format, string collectionExpression) => BuildUserMessageForSingleExpression(format, collectionExpression, "collection"); @@ -318,6 +315,15 @@ internal static void CheckParameterNotNull([NotNull] object? param, string asser internal static string ReplaceNulls(object? input) => input?.ToString() ?? string.Empty; + /// + /// Formats a call-site expression like Assert.MethodName(expression). + /// Returns if the expression is empty or contains a line break. + /// + private static string? FormatCallSiteExpression(string methodName, string expression) + => string.IsNullOrEmpty(expression) || expression.IndexOfAny(['\n', '\r']) >= 0 + ? null + : $"{methodName}({expression})"; + private static int CompareInternal(string? expected, string? actual, bool ignoreCase, CultureInfo culture) #pragma warning disable CA1309 // Use ordinal string comparison => string.Compare(expected, actual, ignoreCase, culture); diff --git a/src/TestFramework/TestFramework/Assertions/EvidenceBlock.cs b/src/TestFramework/TestFramework/Assertions/EvidenceBlock.cs index 4bc26ac237..25e9bfac22 100644 --- a/src/TestFramework/TestFramework/Assertions/EvidenceBlock.cs +++ b/src/TestFramework/TestFramework/Assertions/EvidenceBlock.cs @@ -23,7 +23,8 @@ internal EvidenceBlock AddLine(string label, string value) /// /// Formats the evidence block as aligned label: value lines. - /// Labels are right-padded so all values start at the same column. + /// Labels are right-padded so all values start at the same column. Values that span multiple lines + /// are re-indented so continuation lines align under the first value column. /// internal string Format() { @@ -41,6 +42,8 @@ internal string Format() } } + string continuationIndent = new(' ', maxLabelLength + 1); + StringBuilder sb = new(); for (int i = 0; i < _lines.Count; i++) { @@ -51,12 +54,52 @@ internal string Format() EvidenceLine line = _lines[i]; - // Pad label (which includes trailing colon) to align values, then append a space and value sb.Append(line.Label.PadRight(maxLabelLength)); sb.Append(' '); - sb.Append(line.Value); + AppendValue(sb, line.Value, continuationIndent); } return sb.ToString(); } + + private static void AppendValue(StringBuilder sb, string value, string continuationIndent) + { + int start = 0; + int len = value.Length; + int i = 0; + while (i < len) + { + char c = value[i]; + if (c is '\n' or '\r') + { + sb.Append(value, start, i - start); + int next = i + 1; + if (c == '\r' && next < len && value[next] == '\n') + { + next++; + } + + sb.Append(Environment.NewLine); + + // Skip the continuation indent when the line break is the last thing in the value, or when the next + // character is itself another line break. The latter avoids emitting an indent on a line that is + // intentionally blank, which would leave whitespace-only continuation lines. + if (next < len && value[next] is not '\n' and not '\r') + { + sb.Append(continuationIndent); + } + + start = next; + i = next; + continue; + } + + i++; + } + + if (start < len) + { + sb.Append(value, start, len - start); + } + } } diff --git a/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.ThrowsExceptionTests.cs b/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.ThrowsExceptionTests.cs index e773d3ad92..be274076a8 100644 --- a/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.ThrowsExceptionTests.cs +++ b/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.ThrowsExceptionTests.cs @@ -70,7 +70,16 @@ public void ThrowsAsync_WhenExceptionIsNotExpectedType_ShouldThrow() Action action = t.Wait; action.Should().Throw() .WithInnerException() - .WithMessage("Assert.ThrowsAsync failed. Expected exception type:. Actual exception type:. 'action' expression: '() => throw new Exception()'."); + .Which.Message.Should().Be( + """ + Assertion failed. Expected exception of type ArgumentException (or derived) but caught Exception. + + expected type: System.ArgumentException (or derived) + actual type: System.Exception + actual exception: System.Exception: Exception of type 'System.Exception' was thrown. + + Assert.ThrowsAsync(() => throw new Exception()) + """); } public void ThrowsExactlyAsync_WhenExceptionIsDerivedFromExpectedType_ShouldThrow() @@ -79,7 +88,16 @@ public void ThrowsExactlyAsync_WhenExceptionIsDerivedFromExpectedType_ShouldThro Action action = t.Wait; action.Should().Throw() .WithInnerException() - .WithMessage("Assert.ThrowsExactlyAsync failed. Expected exception type:. Actual exception type:. 'action' expression: '() => throw new ArgumentNullException()'."); + .Which.Message.Should().Be( + """ + Assertion failed. Expected exception of exact type ArgumentException but caught ArgumentNullException. + + expected type: System.ArgumentException + actual type: System.ArgumentNullException + actual exception: System.ArgumentNullException: Value cannot be null. + + Assert.ThrowsExactlyAsync(() => throw new ArgumentNullException()) + """); } public void Throws_WithMessageBuilder_Passes() @@ -105,7 +123,13 @@ public void Throws_WithMessageBuilder_FailsBecauseNoException() return "message constructed via builder."; }); action.Should().Throw() - .WithMessage("Assert.Throws failed. Expected exception type: but no exception was thrown. 'action' expression: '() => { }'. message constructed via builder."); + .Which.Message.Should().Be( + """ + Assertion failed. Expected exception of type ArgumentNullException (or derived) but no exception was thrown. + message constructed via builder. + + Assert.Throws(() => { }) + """); wasBuilderCalled.Should().BeTrue(); exceptionPassedToBuilder.Should().BeNull(); @@ -122,7 +146,28 @@ public void Throws_WithMessageBuilder_FailsBecauseTypeMismatch() return "message constructed via builder."; }); action.Should().Throw() - .WithMessage("Assert.Throws failed. Expected exception type:. Actual exception type:. 'action' expression: '() => throw new ArgumentOutOfRangeException(\"MyParamNameHere\")'. message constructed via builder."); + .Which.Message.Should().BeOneOf( + """ + Assertion failed. Expected exception of type ArgumentNullException (or derived) but caught ArgumentOutOfRangeException. + message constructed via builder. + + expected type: System.ArgumentNullException (or derived) + actual type: System.ArgumentOutOfRangeException + actual exception: System.ArgumentOutOfRangeException: Specified argument was out of the range of valid values. (Parameter 'MyParamNameHere') + + Assert.Throws(() => throw new ArgumentOutOfRangeException("MyParamNameHere")) + """, + """ + Assertion failed. Expected exception of type ArgumentNullException (or derived) but caught ArgumentOutOfRangeException. + message constructed via builder. + + expected type: System.ArgumentNullException (or derived) + actual type: System.ArgumentOutOfRangeException + actual exception: System.ArgumentOutOfRangeException: Specified argument was out of the range of valid values. + Parameter name: MyParamNameHere + + Assert.Throws(() => throw new ArgumentOutOfRangeException("MyParamNameHere")) + """); wasBuilderCalled.Should().BeTrue(); exceptionPassedToBuilder.Should().BeOfType(); @@ -152,7 +197,13 @@ public void ThrowsExactly_WithMessageBuilder_FailsBecauseNoException() return "message constructed via builder."; }); action.Should().Throw() - .WithMessage("Assert.ThrowsExactly failed. Expected exception type: but no exception was thrown. 'action' expression: '() => { }'. message constructed via builder."); + .Which.Message.Should().Be( + """ + Assertion failed. Expected exception of exact type ArgumentNullException but no exception was thrown. + message constructed via builder. + + Assert.ThrowsExactly(() => { }) + """); wasBuilderCalled.Should().BeTrue(); exceptionPassedToBuilder.Should().BeNull(); @@ -169,7 +220,28 @@ public void ThrowsExactly_WithMessageBuilder_FailsBecauseTypeMismatch() return "message constructed via builder."; }); action.Should().Throw() - .WithMessage("Assert.ThrowsExactly failed. Expected exception type:. Actual exception type:. 'action' expression: '() => throw new ArgumentOutOfRangeException(\"MyParamNameHere\")'. message constructed via builder."); + .Which.Message.Should().BeOneOf( + """ + Assertion failed. Expected exception of exact type ArgumentNullException but caught ArgumentOutOfRangeException. + message constructed via builder. + + expected type: System.ArgumentNullException + actual type: System.ArgumentOutOfRangeException + actual exception: System.ArgumentOutOfRangeException: Specified argument was out of the range of valid values. (Parameter 'MyParamNameHere') + + Assert.ThrowsExactly(() => throw new ArgumentOutOfRangeException("MyParamNameHere")) + """, + """ + Assertion failed. Expected exception of exact type ArgumentNullException but caught ArgumentOutOfRangeException. + message constructed via builder. + + expected type: System.ArgumentNullException + actual type: System.ArgumentOutOfRangeException + actual exception: System.ArgumentOutOfRangeException: Specified argument was out of the range of valid values. + Parameter name: MyParamNameHere + + Assert.ThrowsExactly(() => throw new ArgumentOutOfRangeException("MyParamNameHere")) + """); wasBuilderCalled.Should().BeTrue(); exceptionPassedToBuilder.Should().BeOfType(); @@ -199,7 +271,13 @@ public async Task ThrowsAsync_WithMessageBuilder_FailsBecauseNoException() return "message constructed via builder."; }); (await action.Should().ThrowAsync()) - .WithMessage("Assert.ThrowsAsync failed. Expected exception type: but no exception was thrown. 'action' expression: '() => Task.CompletedTask'. message constructed via builder."); + .Which.Message.Should().Be( + """ + Assertion failed. Expected exception of type ArgumentNullException (or derived) but no exception was thrown. + message constructed via builder. + + Assert.ThrowsAsync(() => Task.CompletedTask) + """); wasBuilderCalled.Should().BeTrue(); exceptionPassedToBuilder.Should().BeNull(); @@ -216,7 +294,28 @@ public async Task ThrowsAsync_WithMessageBuilder_FailsBecauseTypeMismatch() return "message constructed via builder."; }); (await action.Should().ThrowAsync()) - .WithMessage("Assert.ThrowsAsync failed. Expected exception type:. Actual exception type:. 'action' expression: '() => Task.FromException(new ArgumentOutOfRangeException(\"MyParamNameHere\"))'. message constructed via builder."); + .Which.Message.Should().BeOneOf( + """ + Assertion failed. Expected exception of type ArgumentNullException (or derived) but caught ArgumentOutOfRangeException. + message constructed via builder. + + expected type: System.ArgumentNullException (or derived) + actual type: System.ArgumentOutOfRangeException + actual exception: System.ArgumentOutOfRangeException: Specified argument was out of the range of valid values. (Parameter 'MyParamNameHere') + + Assert.ThrowsAsync(() => Task.FromException(new ArgumentOutOfRangeException("MyParamNameHere"))) + """, + """ + Assertion failed. Expected exception of type ArgumentNullException (or derived) but caught ArgumentOutOfRangeException. + message constructed via builder. + + expected type: System.ArgumentNullException (or derived) + actual type: System.ArgumentOutOfRangeException + actual exception: System.ArgumentOutOfRangeException: Specified argument was out of the range of valid values. + Parameter name: MyParamNameHere + + Assert.ThrowsAsync(() => Task.FromException(new ArgumentOutOfRangeException("MyParamNameHere"))) + """); wasBuilderCalled.Should().BeTrue(); exceptionPassedToBuilder.Should().BeOfType(); @@ -246,7 +345,13 @@ public async Task ThrowsExactlyAsync_WithMessageBuilder_FailsBecauseNoException( return "message constructed via builder."; }); (await action.Should().ThrowAsync()) - .WithMessage("Assert.ThrowsExactlyAsync failed. Expected exception type: but no exception was thrown. 'action' expression: '() => Task.CompletedTask'. message constructed via builder."); + .Which.Message.Should().Be( + """ + Assertion failed. Expected exception of exact type ArgumentNullException but no exception was thrown. + message constructed via builder. + + Assert.ThrowsExactlyAsync(() => Task.CompletedTask) + """); wasBuilderCalled.Should().BeTrue(); exceptionPassedToBuilder.Should().BeNull(); @@ -263,10 +368,125 @@ public async Task ThrowsExactlyAsync_WithMessageBuilder_FailsBecauseTypeMismatch return "message constructed via builder."; }); (await action.Should().ThrowAsync()) - .WithMessage("Assert.ThrowsExactlyAsync failed. Expected exception type:. Actual exception type:. 'action' expression: '() => Task.FromException(new ArgumentOutOfRangeException(\"MyParamNameHere\"))'. message constructed via builder."); + .Which.Message.Should().BeOneOf( + """ + Assertion failed. Expected exception of exact type ArgumentNullException but caught ArgumentOutOfRangeException. + message constructed via builder. + + expected type: System.ArgumentNullException + actual type: System.ArgumentOutOfRangeException + actual exception: System.ArgumentOutOfRangeException: Specified argument was out of the range of valid values. (Parameter 'MyParamNameHere') + + Assert.ThrowsExactlyAsync(() => Task.FromException(new ArgumentOutOfRangeException("MyParamNameHere"))) + """, + """ + Assertion failed. Expected exception of exact type ArgumentNullException but caught ArgumentOutOfRangeException. + message constructed via builder. + + expected type: System.ArgumentNullException + actual type: System.ArgumentOutOfRangeException + actual exception: System.ArgumentOutOfRangeException: Specified argument was out of the range of valid values. + Parameter name: MyParamNameHere + + Assert.ThrowsExactlyAsync(() => Task.FromException(new ArgumentOutOfRangeException("MyParamNameHere"))) + """); wasBuilderCalled.Should().BeTrue(); exceptionPassedToBuilder.Should().BeOfType(); ((ArgumentOutOfRangeException)exceptionPassedToBuilder!).ParamName.Should().Be("MyParamNameHere"); } + + public void Throws_WithInterpolation_InsideAssertScope_WrongExceptionType_DoesNotThrowInvalidCast() + { + // Regression: ComputeAssertion used to fall through to (TException)_state.ExceptionThrown! after + // ReportAssertFailed returned in scope mode, casting an unrelated exception type and crashing the test. + Action action = () => + { + using (Assert.Scope()) + { + Assert.Throws(() => throw new InvalidOperationException("boom"), $"ctx={42}"); + } + }; + + action.Should().Throw(); + } + + public void ThrowsExactly_WithInterpolation_InsideAssertScope_WrongExceptionType_DoesNotThrowInvalidCast() + { + Action action = () => + { + using (Assert.Scope()) + { + Assert.ThrowsExactly(() => throw new ArgumentNullException("p"), $"ctx={42}"); + } + }; + + action.Should().Throw(); + } + + public void Throws_WhenExceptionMessageContainsNewline_ContinuationLinesAreIndented() + { + static void Action() => Assert.Throws(() => throw new InvalidOperationException("line1\nline2")); + Action action = Action; + + // "actual exception:" is the longest label (17 chars) + 1 space = 18 chars indent for the continuation line. + action.Should().Throw() + .Which.Message.Should().Be( + """ + Assertion failed. Expected exception of type ArgumentException (or derived) but caught InvalidOperationException. + + expected type: System.ArgumentException (or derived) + actual type: System.InvalidOperationException + actual exception: System.InvalidOperationException: line1 + line2 + + Assert.Throws(() => throw new InvalidOperationException("line1\nline2")) + """); + } + + public void Throws_WhenActionExpressionContainsNewline_OmitsCallSiteLine() + { + // Multi-line action expressions can't be re-rendered as a single call-site line; the helper drops the line. +#pragma warning disable IDE0053 // Use expression body for lambda - intentional block body so the captured CallerArgumentExpression spans multiple lines. + static void Action() => Assert.Throws(() => + { + throw new InvalidOperationException("oops"); + }); +#pragma warning restore IDE0053 + Action action = Action; + + action.Should().Throw() + .Which.Message.Should().Be( + """ + Assertion failed. Expected exception of type ArgumentException (or derived) but caught InvalidOperationException. + + expected type: System.ArgumentException (or derived) + actual type: System.InvalidOperationException + actual exception: System.InvalidOperationException: oops + """); + } + + public void Throws_WhenExpectedTypeIsGeneric_RendersFriendlyTypeName() + { + // Without the friendly-name helper, the closed generic would render as "ThrowsTestGenericException`1" (with backtick) in + // both the summary and the call-site line, breaking the prose and producing un-pasteable C#. + static void Action() => Assert.Throws>(() => throw new InvalidOperationException("oops")); + Action action = Action; + + action.Should().Throw() + .Which.Message.Should().Be( + """ + Assertion failed. Expected exception of type ThrowsTestGenericException (or derived) but caught InvalidOperationException. + + expected type: Microsoft.VisualStudio.TestPlatform.TestFramework.UnitTests.ThrowsTestGenericException (or derived) + actual type: System.InvalidOperationException + actual exception: System.InvalidOperationException: oops + + Assert.Throws>(() => throw new InvalidOperationException("oops")) + """); + } +} + +internal sealed class ThrowsTestGenericException : Exception +{ } diff --git a/test/UnitTests/TestFramework.UnitTests/Assertions/EvidenceBlockTests.cs b/test/UnitTests/TestFramework.UnitTests/Assertions/EvidenceBlockTests.cs index 9bc0a523a1..a32e6a312a 100644 --- a/test/UnitTests/TestFramework.UnitTests/Assertions/EvidenceBlockTests.cs +++ b/test/UnitTests/TestFramework.UnitTests/Assertions/EvidenceBlockTests.cs @@ -40,8 +40,11 @@ public void Format_TwoLines_AlignsLabels() // "expected:" is 9 chars, "actual:" is 7 chars // "actual:" should be padded to 9 chars for alignment - string expected = "expected: 42" + Environment.NewLine + "actual: 37"; - result.Should().Be(expected); + result.Should().Be( + $""" + expected: 42 + actual: 37 + """); } public void Format_MultipleLines_AlignsToLongestLabel() @@ -54,12 +57,13 @@ public void Format_MultipleLines_AlignsToLongestLabel() string result = block.Format(); - string[] lines = result.Split([Environment.NewLine], StringSplitOptions.None); - lines.Should().HaveCount(4); - lines[0].Should().Be("expected: 42"); - lines[1].Should().Be("actual: 37"); - lines[2].Should().Be("ignore case: true"); - lines[3].Should().Be("culture: tr-TR"); + result.Should().Be( + $""" + expected: 42 + actual: 37 + ignore case: true + culture: tr-TR + """); } public void Lines_ReturnsAddedLines() @@ -74,4 +78,112 @@ public void Lines_ReturnsAddedLines() block.Lines[1].Label.Should().Be("actual:"); block.Lines[1].Value.Should().Be("37"); } + + public void Format_ValueWithLF_IndentsContinuationToValueColumn() + { + EvidenceBlock block = EvidenceBlock.Create() + .AddLine("expected:", "line1\nline2"); + + string result = block.Format(); + + // "expected:" is 9 chars + 1 space = 10 chars of indent. + result.Should().Be( + $""" + expected: line1 + line2 + """); + } + + public void Format_ValueWithCRLF_IndentsContinuationOnce() + { + EvidenceBlock block = EvidenceBlock.Create() + .AddLine("expected:", "line1\r\nline2"); + + string result = block.Format(); + + result.Should().Be( + $""" + expected: line1 + line2 + """); + } + + public void Format_ValueWithCROnly_IndentsContinuation() + { + EvidenceBlock block = EvidenceBlock.Create() + .AddLine("expected:", "line1\rline2"); + + string result = block.Format(); + + result.Should().Be( + $""" + expected: line1 + line2 + """); + } + + public void Format_ValueWithMultipleNewlines_IndentsAllContinuations() + { + EvidenceBlock block = EvidenceBlock.Create() + .AddLine("expected:", "a\nb\nc"); + + string result = block.Format(); + + result.Should().Be( + $""" + expected: a + b + c + """); + } + + public void Format_ValueEndingWithNewline_DoesNotEmitTrailingIndent() + { + EvidenceBlock block = EvidenceBlock.Create() + .AddLine("expected:", "abc\n") + .AddLine("actual:", "37"); + + string result = block.Format(); + + // No row of pure whitespace between the two lines. + result.Should().Be( + $""" + expected: abc + + actual: 37 + """); + } + + public void Format_ValueWithConsecutiveNewlines_DoesNotEmitWhitespaceOnlyLines() + { + EvidenceBlock block = EvidenceBlock.Create() + .AddLine("expected:", "a\n\nb"); + + string result = block.Format(); + + // The blank line between "a" and "b" must not contain the continuation indent. + result.Should().Be( + $""" + expected: a + + b + """); + } + + public void Format_MixedSingleAndMultiLineValues_AlignsToValueColumn() + { + EvidenceBlock block = EvidenceBlock.Create() + .AddLine("expected type:", "Foo") + .AddLine("actual exception:", "System.InvalidOperationException: line1\nline2"); + + string result = block.Format(); + + // Longest label is "actual exception:" (17 chars) + 1 space = 18 chars indent for continuation. + result.Should().Be( + $""" + expected type: Foo + actual exception: System.InvalidOperationException: line1 + line2 + """); + } }