diff --git a/TUnit.Assertions.Tests/Bugs/Tests3521.cs b/TUnit.Assertions.Tests/Bugs/Tests3521.cs new file mode 100644 index 0000000000..43d294ce9d --- /dev/null +++ b/TUnit.Assertions.Tests/Bugs/Tests3521.cs @@ -0,0 +1,112 @@ +namespace TUnit.Assertions.Tests.Bugs; + +/// +/// Tests for issue #3521: Assert.ThrowsAsync(Func<Task>) incorrectly passes when no exception is thrown +/// +public class Tests3521 +{ + private static Task DoesNotThrow() => Task.CompletedTask; + private static void DoesNotThrowSync() { } + + [Test] + public async Task ThrowsAsync_WithMethodGroup_ShouldFailWhenNoExceptionThrown() + { + // This should fail because DoesNotThrow doesn't throw an exception + var exception = await TUnitAssert.ThrowsAsync( + async () => await TUnitAssert.ThrowsAsync(DoesNotThrow)); + + await TUnitAssert.That(exception.Message).Contains("but no exception was thrown"); + } + + [Test] + public async Task ThrowsAsync_WithLambda_ShouldFailWhenNoExceptionThrown() + { + // This should fail because the lambda doesn't throw an exception + var exception = await TUnitAssert.ThrowsAsync( + async () => await TUnitAssert.ThrowsAsync(() => DoesNotThrow())); + + await TUnitAssert.That(exception.Message).Contains("but no exception was thrown"); + } + + [Test] + public async Task ThrowsAsync_WithAsyncLambda_ShouldFailWhenNoExceptionThrown() + { + // This should fail because the async lambda doesn't throw an exception + var exception = await TUnitAssert.ThrowsAsync( + async () => await TUnitAssert.ThrowsAsync(async () => await DoesNotThrow())); + + await TUnitAssert.That(exception.Message).Contains("but no exception was thrown"); + } + + [Test] + public async Task ThrowsAsync_Generic_WithMethodGroup_ShouldFailWhenNoExceptionThrown() + { + // Test the generic version with Exception type explicitly + var exception = await TUnitAssert.ThrowsAsync( + async () => await TUnitAssert.ThrowsAsync(DoesNotThrow)); + + await TUnitAssert.That(exception.Message).Contains("but no exception was thrown"); + } + + [Test] + public async Task ThrowsAsync_Type_ShouldFailWhenNoExceptionThrown() + { + // Test the Type-based overload + var exception = await TUnitAssert.ThrowsAsync( + async () => await TUnitAssert.ThrowsAsync(typeof(Exception), DoesNotThrow)); + + await TUnitAssert.That(exception.Message).Contains("but no exception was thrown"); + } + + [Test] + public async Task Throws_Generic_ShouldFailWhenNoExceptionThrown() + { + // Test the synchronous version + var exception = await TUnitAssert.ThrowsAsync(async () => + { + TUnitAssert.Throws(DoesNotThrowSync); + await Task.CompletedTask; + }); + + await TUnitAssert.That(exception.Message).Contains("but no exception was thrown"); + } + + [Test] + public async Task Throws_Type_ShouldFailWhenNoExceptionThrown() + { + // Test the synchronous Type-based overload + var exception = await TUnitAssert.ThrowsAsync(async () => + { + TUnitAssert.Throws(typeof(Exception), DoesNotThrowSync); + await Task.CompletedTask; + }); + + await TUnitAssert.That(exception.Message).Contains("but no exception was thrown"); + } + + [Test] + public async Task ThrowsAsync_ShouldStillWorkWhenExceptionIsThrown() + { + // Verify that ThrowsAsync still works correctly when an exception IS thrown + var thrownException = new InvalidOperationException("Test exception"); + + var caughtException = await TUnitAssert.ThrowsAsync(async () => + { + await Task.Yield(); + throw thrownException; + }); + + await TUnitAssert.That(caughtException).IsSameReferenceAs(thrownException); + } + + [Test] + public async Task Throws_ShouldStillWorkWhenExceptionIsThrown() + { + // Verify that Throws still works correctly when an exception IS thrown + var thrownException = new InvalidOperationException("Test exception"); + + var caughtException = TUnitAssert.Throws(() => throw thrownException); + + await TUnitAssert.That(caughtException).IsSameReferenceAs(thrownException); + } +} diff --git a/TUnit.Assertions/Conditions/ThrowsAssertion.cs b/TUnit.Assertions/Conditions/ThrowsAssertion.cs index 17c39266f0..f18721e258 100644 --- a/TUnit.Assertions/Conditions/ThrowsAssertion.cs +++ b/TUnit.Assertions/Conditions/ThrowsAssertion.cs @@ -199,6 +199,30 @@ public ExceptionParameterNameAssertion WithParameterName(string expe Context.ExpressionBuilder.Append($".WithParameterName(\"{expectedParameterName}\")"); return new ExceptionParameterNameAssertion(Context, expectedParameterName); } + + /// + /// Adds runtime Type-based exception checking for non-generic Throws scenarios. + /// Returns a specialized assertion that validates against the provided Type. + /// + public async Task WithExceptionType(Type expectedExceptionType) + { + if (!typeof(Exception).IsAssignableFrom(expectedExceptionType)) + { + throw new ArgumentException($"Type {expectedExceptionType.Name} must be an Exception type", nameof(expectedExceptionType)); + } + + // Await the current assertion to get the exception + await this; + var (exception, _) = await Context.GetAsync(); + + // Now validate it's the correct type + if (exception != null && !expectedExceptionType.IsInstanceOfType(exception)) + { + throw new Exceptions.AssertionException($"Expected {expectedExceptionType.Name} but got {exception.GetType().Name}: {exception.Message}"); + } + + return exception; + } } /// diff --git a/TUnit.Assertions/Extensions/Assert.cs b/TUnit.Assertions/Extensions/Assert.cs index 6a39743dc2..6b9d5119fb 100644 --- a/TUnit.Assertions/Extensions/Assert.cs +++ b/TUnit.Assertions/Extensions/Assert.cs @@ -1,5 +1,7 @@ using System.Collections; +using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; +using TUnit.Assertions.Conditions; using TUnit.Assertions.Exceptions; using TUnit.Assertions.Sources; @@ -9,6 +11,7 @@ namespace TUnit.Assertions; /// Entry point for all assertions. /// Provides Assert.That() overloads for different source types. /// +[SuppressMessage("Usage", "TUnitAssertions0002:Assert statement not awaited")] public static class Assert { /// @@ -189,7 +192,7 @@ public static TException Throws(Action action) action(); throw new AssertionException($"Expected {typeof(TException).Name} but no exception was thrown"); } - catch (TException ex) + catch (TException ex) when (ex is not AssertionException) { return ex; } @@ -216,7 +219,7 @@ public static Exception Throws(Type exceptionType, Action action) action(); throw new AssertionException($"Expected {exceptionType.Name} but no exception was thrown"); } - catch (Exception ex) when (exceptionType.IsInstanceOfType(ex)) + catch (Exception ex) when (exceptionType.IsInstanceOfType(ex) && ex is not AssertionException) { return ex; } @@ -245,106 +248,57 @@ public static TException Throws(string parameterName, Action action) /// Asserts that the async action throws the specified exception type and returns the exception. /// Example: var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () => await ThrowingMethodAsync()); /// - public static async Task ThrowsAsync(Func action) + public static ThrowsAssertion ThrowsAsync(Func action) where TException : Exception { - try - { - await action(); - throw new AssertionException($"Expected {typeof(TException).Name} but no exception was thrown"); - } - catch (TException ex) - { - return ex; - } - catch (Exception ex) - { - throw new AssertionException($"Expected {typeof(TException).Name} but got {ex.GetType().Name}: {ex.Message}"); - } + return That(action).Throws(); } /// - /// Asserts that the async action throws the specified exception type with the expected parameter name (for ArgumentException types) and returns the exception. - /// Example: var exception = await Assert.ThrowsAsync<ArgumentNullException>("paramName", async () => await ThrowingMethodAsync()); + /// Asserts that the async action throws the specified exception type with the expected parameter name (for ArgumentException types). + /// Uses fluent API - chain .WithParameterName() for parameter validation. + /// Example: await Assert.ThrowsAsync<ArgumentNullException>("paramName", async () => await ThrowingMethodAsync()); /// - public static async Task ThrowsAsync(string parameterName, Func action) + public static ExceptionParameterNameAssertion ThrowsAsync(string parameterName, Func action) where TException : ArgumentException { - var exception = await ThrowsAsync(action); - if (exception.ParamName != parameterName) - { - throw new AssertionException($"Expected {typeof(TException).Name} with ParamName '{parameterName}' but got ParamName '{exception.ParamName}'"); - } - return exception; + return That(action).Throws().WithParameterName(parameterName); } /// /// Asserts that the async action throws any exception (defaults to Exception). /// Example: var exception = await Assert.ThrowsAsync(async () => await ThrowingMethodAsync()); /// - public static async Task ThrowsAsync(Func action) + public static ThrowsAssertion ThrowsAsync(Func action) { - return await ThrowsAsync(action); + return That(action).Throws(); } /// /// Asserts that the Task throws any exception (defaults to Exception). /// Example: var exception = await Assert.ThrowsAsync(Task.FromException(new Exception())); /// - public static async Task ThrowsAsync(Task task) + public static ThrowsAssertion ThrowsAsync(Task task) { - try - { - await task; - throw new AssertionException("Expected an exception but none was thrown"); - } - catch (Exception ex) when (ex is not AssertionException) - { - return ex; - } + return That(task).Throws(); } /// /// Asserts that the ValueTask throws any exception (defaults to Exception). /// Example: var exception = await Assert.ThrowsAsync(new ValueTask(Task.FromException(new Exception()))); /// - public static async Task ThrowsAsync(ValueTask task) + public static ThrowsAssertion ThrowsAsync(ValueTask task) { - try - { - await task; - throw new AssertionException("Expected an exception but none was thrown"); - } - catch (Exception ex) when (ex is not AssertionException) - { - return ex; - } + return That(task.AsTask()).Throws(); } /// /// Asserts that the specified exception type is thrown and returns the exception. /// Non-generic version that takes a Type parameter at runtime. /// - public static async Task ThrowsAsync(Type exceptionType, Func action) + public static Task ThrowsAsync(Type exceptionType, Func action) { - if (!typeof(Exception).IsAssignableFrom(exceptionType)) - { - throw new ArgumentException($"Type {exceptionType.Name} must be an Exception type", nameof(exceptionType)); - } - - try - { - await action(); - throw new AssertionException($"Expected {exceptionType.Name} but no exception was thrown"); - } - catch (Exception ex) when (exceptionType.IsInstanceOfType(ex)) - { - return ex; - } - catch (Exception ex) - { - throw new AssertionException($"Expected {exceptionType.Name} but got {ex.GetType().Name}: {ex.Message}"); - } + return That(action).Throws(exceptionType); } /// @@ -388,36 +342,20 @@ public static TException ThrowsExactly(string parameterName, Action /// Asserts that exactly the specified exception type is thrown (not subclasses) and returns the exception. /// Example: var exception = await Assert.ThrowsExactlyAsync<InvalidOperationException>(async () => await ThrowingMethodAsync()); /// - public static async Task ThrowsExactlyAsync(Func action) + public static ThrowsExactlyAssertion ThrowsExactlyAsync(Func action) where TException : Exception { - try - { - await action(); - throw new AssertionException($"Expected exactly {typeof(TException).Name} but no exception was thrown"); - } - catch (Exception ex) when (ex.GetType() == typeof(TException)) - { - return (TException)ex; - } - catch (Exception ex) - { - throw new AssertionException($"Expected exactly {typeof(TException).Name} but got {ex.GetType().Name}: {ex.Message}"); - } + return That(action).ThrowsExactly(); } /// - /// Asserts that exactly the specified exception type is thrown (not subclasses) with the expected parameter name (for ArgumentException types) and returns the exception. - /// Example: var exception = await Assert.ThrowsExactlyAsync<ArgumentNullException>("paramName", async () => await ThrowingMethodAsync()); + /// Asserts that exactly the specified exception type is thrown (not subclasses) with the expected parameter name (for ArgumentException types). + /// Uses fluent API - chain .WithParameterName() for parameter validation. + /// Example: await Assert.ThrowsExactlyAsync<ArgumentNullException>("paramName", async () => await ThrowingMethodAsync()); /// - public static async Task ThrowsExactlyAsync(string parameterName, Func action) + public static ExceptionParameterNameAssertion ThrowsExactlyAsync(string parameterName, Func action) where TException : ArgumentException { - var exception = await ThrowsExactlyAsync(action); - if (exception.ParamName != parameterName) - { - throw new AssertionException($"Expected {typeof(TException).Name} with ParamName '{parameterName}' but got ParamName '{exception.ParamName}'"); - } - return exception; + return That(action).ThrowsExactly(parameterName); } } diff --git a/TUnit.Assertions/Sources/AsyncDelegateAssertion.cs b/TUnit.Assertions/Sources/AsyncDelegateAssertion.cs index 8e6a2bc7dc..6147ea3e3f 100644 --- a/TUnit.Assertions/Sources/AsyncDelegateAssertion.cs +++ b/TUnit.Assertions/Sources/AsyncDelegateAssertion.cs @@ -166,4 +166,28 @@ public ThrowsExactlyAssertion ThrowsExactly() where TExc var mappedContext = Context.MapException(); return new ThrowsExactlyAssertion(mappedContext); } + + /// + /// Asserts that the async delegate throws the specified exception type (runtime Type parameter). + /// Non-generic version for dynamic exception type checking. + /// Example: await Assert.That(async () => await ThrowingMethodAsync()).Throws(typeof(InvalidOperationException)); + /// + public Task Throws(Type exceptionType) + { + Context.ExpressionBuilder.Append($".Throws({exceptionType.Name})"); + // Delegate to the generic Throws() and add runtime type checking + var assertion = Throws(); + // Return the assertion with runtime type filtering applied + return assertion.WithExceptionType(exceptionType); + } + + /// + /// Asserts that the async delegate throws exactly the specified exception type with the expected parameter name. + /// For ArgumentException types only. + /// Example: await Assert.That(async () => await ThrowingMethodAsync()).ThrowsExactly<ArgumentNullException>("paramName"); + /// + public ExceptionParameterNameAssertion ThrowsExactly(string parameterName) where TException : ArgumentException + { + return ThrowsExactly().WithParameterName(parameterName); + } } diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt index c70ec89cf9..8b838e794c 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -1401,6 +1401,7 @@ namespace .Conditions public ThrowsAssertion(. context) { } protected override bool IsExactTypeMatch { get; } protected override bool CheckExceptionType( actualException, out string? errorMessage) { } + public . WithExceptionType( expectedExceptionType) { } public .<> WithInnerException() { } public . WithMessage(string expectedMessage) { } public . WithMessage(string expectedMessage, comparison) { } @@ -3911,10 +3912,13 @@ namespace .Sources public .<.> IsNotCompletedSuccessfully() { } public .<.> IsNotFaulted() { } public . IsTypeOf() { } + public . Throws( exceptionType) { } public . Throws() where TException : { } public . ThrowsExactly() where TException : { } + public . ThrowsExactly(string parameterName) + where TException : { } } public class AsyncFuncAssertion : ., ., . { diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt index a630b91b97..05c906c479 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -1398,6 +1398,7 @@ namespace .Conditions public ThrowsAssertion(. context) { } protected override bool IsExactTypeMatch { get; } protected override bool CheckExceptionType( actualException, out string? errorMessage) { } + public . WithExceptionType( expectedExceptionType) { } public .<> WithInnerException() { } public . WithMessage(string expectedMessage) { } public . WithMessage(string expectedMessage, comparison) { } @@ -3890,10 +3891,13 @@ namespace .Sources public .<.> IsNotCompletedSuccessfully() { } public .<.> IsNotFaulted() { } public . IsTypeOf() { } + public . Throws( exceptionType) { } public . Throws() where TException : { } public . ThrowsExactly() where TException : { } + public . ThrowsExactly(string parameterName) + where TException : { } } public class AsyncFuncAssertion : ., ., . { diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt index 959456d646..941d60bc07 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -1401,6 +1401,7 @@ namespace .Conditions public ThrowsAssertion(. context) { } protected override bool IsExactTypeMatch { get; } protected override bool CheckExceptionType( actualException, out string? errorMessage) { } + public . WithExceptionType( expectedExceptionType) { } public .<> WithInnerException() { } public . WithMessage(string expectedMessage) { } public . WithMessage(string expectedMessage, comparison) { } @@ -3911,10 +3912,13 @@ namespace .Sources public .<.> IsNotCompletedSuccessfully() { } public .<.> IsNotFaulted() { } public . IsTypeOf() { } + public . Throws( exceptionType) { } public . Throws() where TException : { } public . ThrowsExactly() where TException : { } + public . ThrowsExactly(string parameterName) + where TException : { } } public class AsyncFuncAssertion : ., ., . { diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt index 79d4d99df0..67f8cfe8c3 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -1331,6 +1331,7 @@ namespace .Conditions public ThrowsAssertion(. context) { } protected override bool IsExactTypeMatch { get; } protected override bool CheckExceptionType( actualException, out string? errorMessage) { } + public . WithExceptionType( expectedExceptionType) { } public .<> WithInnerException() { } public . WithMessage(string expectedMessage) { } public . WithMessage(string expectedMessage, comparison) { } @@ -3550,10 +3551,13 @@ namespace .Sources public .<.> IsNotCompleted() { } public .<.> IsNotFaulted() { } public . IsTypeOf() { } + public . Throws( exceptionType) { } public . Throws() where TException : { } public . ThrowsExactly() where TException : { } + public . ThrowsExactly(string parameterName) + where TException : { } } public class AsyncFuncAssertion : ., ., . {