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
219 changes: 139 additions & 80 deletions src/TestFramework/TestFramework/Assertions/Assert.ThrowsException.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -25,8 +25,8 @@ public readonly struct AssertNonStrictThrowsInterpolatedStringHandler<TException

public AssertNonStrictThrowsInterpolatedStringHandler(int literalLength, int formattedCount, Action action, out bool shouldAppend)
{
_state = IsThrowsFailing<TException>(action, isStrictType: false, "Throws");
shouldAppend = _state.FailAction is not null;
_state = IsThrowsFailing<TException>(action, isStrictType: false);
shouldAppend = _state.FailureKind != ThrowsFailureKind.NotFailing;
if (shouldAppend)
{
_builder = new StringBuilder(literalLength + formattedCount);
Expand All @@ -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<TException>(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!;
}

Expand Down Expand Up @@ -96,8 +95,8 @@ public readonly struct AssertThrowsExactlyInterpolatedStringHandler<TException>

public AssertThrowsExactlyInterpolatedStringHandler(int literalLength, int formattedCount, Action action, out bool shouldAppend)
{
_state = IsThrowsFailing<TException>(action, isStrictType: true, "ThrowsExactly");
shouldAppend = _state.FailAction is not null;
_state = IsThrowsFailing<TException>(action, isStrictType: true);
shouldAppend = _state.FailureKind != ThrowsFailureKind.NotFailing;
if (shouldAppend)
{
_builder = new StringBuilder(literalLength + formattedCount);
Expand All @@ -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<TException>(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!;
}

Expand Down Expand Up @@ -324,17 +322,17 @@ private static TException ThrowsException<TException>(Action action, bool isStri
_ = action ?? throw new ArgumentNullException(nameof(action));
_ = message ?? throw new ArgumentNullException(nameof(message));

ThrowsExceptionState state = IsThrowsFailing<TException>(action, isStrictType, assertMethodName);
if (state.FailAction is not null)
ThrowsExceptionState state = IsThrowsFailing<TException>(action, isStrictType);
if (state.FailureKind != ThrowsFailureKind.NotFailing)
{
state.FailAction(BuildUserMessageForActionExpression(message, actionExpression));
ReportThrowsFailed<TException>(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!;
}

Expand All @@ -344,17 +342,17 @@ private static TException ThrowsException<TException>(Action action, bool isStri
_ = action ?? throw new ArgumentNullException(nameof(action));
_ = messageBuilder ?? throw new ArgumentNullException(nameof(messageBuilder));

ThrowsExceptionState state = IsThrowsFailing<TException>(action, isStrictType, assertMethodName);
if (state.FailAction is not null)
ThrowsExceptionState state = IsThrowsFailing<TException>(action, isStrictType);
if (state.FailureKind != ThrowsFailureKind.NotFailing)
{
state.FailAction(BuildUserMessageForActionExpression(messageBuilder(state.ExceptionThrown), actionExpression));
ReportThrowsFailed<TException>(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!;
}

Expand Down Expand Up @@ -480,17 +478,17 @@ private static async Task<TException> ThrowsExceptionAsync<TException>(Func<Task
_ = action ?? throw new ArgumentNullException(nameof(action));
_ = message ?? throw new ArgumentNullException(nameof(message));

ThrowsExceptionState state = await IsThrowsAsyncFailingAsync<TException>(action, isStrictType, assertMethodName).ConfigureAwait(false);
if (state.FailAction is not null)
ThrowsExceptionState state = await IsThrowsAsyncFailingAsync<TException>(action, isStrictType).ConfigureAwait(false);
if (state.FailureKind != ThrowsFailureKind.NotFailing)
{
state.FailAction(BuildUserMessageForActionExpression(message, actionExpression));
ReportThrowsFailed<TException>(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!;
}

Expand All @@ -500,21 +498,21 @@ private static async Task<TException> ThrowsExceptionAsync<TException>(Func<Task
_ = action ?? throw new ArgumentNullException(nameof(action));
_ = messageBuilder ?? throw new ArgumentNullException(nameof(messageBuilder));

ThrowsExceptionState state = await IsThrowsAsyncFailingAsync<TException>(action, isStrictType, assertMethodName).ConfigureAwait(false);
if (state.FailAction is not null)
ThrowsExceptionState state = await IsThrowsAsyncFailingAsync<TException>(action, isStrictType).ConfigureAwait(false);
if (state.FailureKind != ThrowsFailureKind.NotFailing)
{
state.FailAction(BuildUserMessageForActionExpression(messageBuilder(state.ExceptionThrown), actionExpression));
ReportThrowsFailed<TException>(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<ThrowsExceptionState> IsThrowsAsyncFailingAsync<TException>(Func<Task> action, bool isStrictType, string assertMethodName)
private static async Task<ThrowsExceptionState> IsThrowsAsyncFailingAsync<TException>(Func<Task> action, bool isStrictType)
where TException : Exception
{
try
Expand All @@ -529,32 +527,13 @@ private static async Task<ThrowsExceptionState> IsThrowsAsyncFailingAsync<TExcep

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.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<TException>(Action action, bool isStrictType, string assertMethodName)
private static ThrowsExceptionState IsThrowsFailing<TException>(Action action, bool isStrictType)
where TException : Exception
{
try
Expand All @@ -569,49 +548,129 @@ private static ThrowsExceptionState IsThrowsFailing<TException>(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<TException>(
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<Int32>") 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<string>? FailAction { get; }
public ThrowsFailureKind FailureKind { get; }

private ThrowsExceptionState(Exception? exceptionThrown, Action<string>? 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<string> 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);
}
}
12 changes: 9 additions & 3 deletions src/TestFramework/TestFramework/Assertions/Assert.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -318,6 +315,15 @@ internal static void CheckParameterNotNull([NotNull] object? param, string asser
internal static string ReplaceNulls(object? input)
=> input?.ToString() ?? string.Empty;

/// <summary>
/// Formats a call-site expression like <c>Assert.MethodName(expression)</c>.
/// Returns <see langword="null"/> if the expression is empty or contains a line break.
/// </summary>
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);
Expand Down
Loading