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 .