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
112 changes: 112 additions & 0 deletions TUnit.Assertions.Tests/Bugs/Tests3521.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
namespace TUnit.Assertions.Tests.Bugs;

/// <summary>
/// Tests for issue #3521: Assert.ThrowsAsync(Func&lt;Task&gt;) incorrectly passes when no exception is thrown
/// </summary>
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<TUnitAssertionException>(
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<TUnitAssertionException>(
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<TUnitAssertionException>(
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<TUnitAssertionException>(
async () => await TUnitAssert.ThrowsAsync<Exception>(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<TUnitAssertionException>(
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<TUnitAssertionException>(async () =>
{
TUnitAssert.Throws<Exception>(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<TUnitAssertionException>(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<Exception>(() => throw thrownException);

await TUnitAssert.That(caughtException).IsSameReferenceAs(thrownException);
}
}
24 changes: 24 additions & 0 deletions TUnit.Assertions/Conditions/ThrowsAssertion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,30 @@ public ExceptionParameterNameAssertion<TException> WithParameterName(string expe
Context.ExpressionBuilder.Append($".WithParameterName(\"{expectedParameterName}\")");
return new ExceptionParameterNameAssertion<TException>(Context, expectedParameterName);
}

/// <summary>
/// Adds runtime Type-based exception checking for non-generic Throws scenarios.
/// Returns a specialized assertion that validates against the provided Type.
/// </summary>
public async Task<Exception?> 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;
}
}

/// <summary>
Expand Down
116 changes: 27 additions & 89 deletions TUnit.Assertions/Extensions/Assert.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -9,6 +11,7 @@ namespace TUnit.Assertions;
/// Entry point for all assertions.
/// Provides Assert.That() overloads for different source types.
/// </summary>
[SuppressMessage("Usage", "TUnitAssertions0002:Assert statement not awaited")]
public static class Assert
{
/// <summary>
Expand Down Expand Up @@ -189,7 +192,7 @@ public static TException Throws<TException>(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;
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -245,106 +248,57 @@ public static TException Throws<TException>(string parameterName, Action action)
/// Asserts that the async action throws the specified exception type and returns the exception.
/// Example: var exception = await Assert.ThrowsAsync&lt;InvalidOperationException&gt;(async () => await ThrowingMethodAsync());
/// </summary>
public static async Task<TException> ThrowsAsync<TException>(Func<Task> action)
public static ThrowsAssertion<TException> ThrowsAsync<TException>(Func<Task> 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<TException>();
}

/// <summary>
/// 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&lt;ArgumentNullException&gt;("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&lt;ArgumentNullException&gt;("paramName", async () => await ThrowingMethodAsync());
/// </summary>
public static async Task<TException> ThrowsAsync<TException>(string parameterName, Func<Task> action)
public static ExceptionParameterNameAssertion<TException> ThrowsAsync<TException>(string parameterName, Func<Task> action)
where TException : ArgumentException
{
var exception = await ThrowsAsync<TException>(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<TException>().WithParameterName(parameterName);
}

/// <summary>
/// Asserts that the async action throws any exception (defaults to Exception).
/// Example: var exception = await Assert.ThrowsAsync(async () => await ThrowingMethodAsync());
/// </summary>
public static async Task<Exception> ThrowsAsync(Func<Task> action)
public static ThrowsAssertion<Exception> ThrowsAsync(Func<Task> action)
{
return await ThrowsAsync<Exception>(action);
return That(action).Throws<Exception>();
}

/// <summary>
/// Asserts that the Task throws any exception (defaults to Exception).
/// Example: var exception = await Assert.ThrowsAsync(Task.FromException(new Exception()));
/// </summary>
public static async Task<Exception> ThrowsAsync(Task task)
public static ThrowsAssertion<Exception> 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<Exception>();
}

/// <summary>
/// Asserts that the ValueTask throws any exception (defaults to Exception).
/// Example: var exception = await Assert.ThrowsAsync(new ValueTask(Task.FromException(new Exception())));
/// </summary>
public static async Task<Exception> ThrowsAsync(ValueTask task)
public static ThrowsAssertion<Exception> 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<Exception>();
}

/// <summary>
/// Asserts that the specified exception type is thrown and returns the exception.
/// Non-generic version that takes a Type parameter at runtime.
/// </summary>
public static async Task<Exception?> ThrowsAsync(Type exceptionType, Func<Task> action)
public static Task<Exception?> ThrowsAsync(Type exceptionType, Func<Task> 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);
}

/// <summary>
Expand Down Expand Up @@ -388,36 +342,20 @@ public static TException ThrowsExactly<TException>(string parameterName, Action
/// Asserts that exactly the specified exception type is thrown (not subclasses) and returns the exception.
/// Example: var exception = await Assert.ThrowsExactlyAsync&lt;InvalidOperationException&gt;(async () => await ThrowingMethodAsync());
/// </summary>
public static async Task<TException> ThrowsExactlyAsync<TException>(Func<Task> action)
public static ThrowsExactlyAssertion<TException> ThrowsExactlyAsync<TException>(Func<Task> 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<TException>();
}

/// <summary>
/// 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&lt;ArgumentNullException&gt;("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&lt;ArgumentNullException&gt;("paramName", async () => await ThrowingMethodAsync());
/// </summary>
public static async Task<TException> ThrowsExactlyAsync<TException>(string parameterName, Func<Task> action)
public static ExceptionParameterNameAssertion<TException> ThrowsExactlyAsync<TException>(string parameterName, Func<Task> action)
where TException : ArgumentException
{
var exception = await ThrowsExactlyAsync<TException>(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<TException>(parameterName);
}
}
24 changes: 24 additions & 0 deletions TUnit.Assertions/Sources/AsyncDelegateAssertion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -166,4 +166,28 @@ public ThrowsExactlyAssertion<TException> ThrowsExactly<TException>() where TExc
var mappedContext = Context.MapException<TException>();
return new ThrowsExactlyAssertion<TException>(mappedContext);
}

/// <summary>
/// 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));
/// </summary>
public Task<Exception?> Throws(Type exceptionType)
{
Context.ExpressionBuilder.Append($".Throws({exceptionType.Name})");
// Delegate to the generic Throws<Exception>() and add runtime type checking
var assertion = Throws<Exception>();
// Return the assertion with runtime type filtering applied
return assertion.WithExceptionType(exceptionType);
}

/// <summary>
/// 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&lt;ArgumentNullException&gt;("paramName");
/// </summary>
public ExceptionParameterNameAssertion<TException> ThrowsExactly<TException>(string parameterName) where TException : ArgumentException
{
return ThrowsExactly<TException>().WithParameterName(parameterName);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1401,6 +1401,7 @@ namespace .Conditions
public ThrowsAssertion(.<TException> context) { }
protected override bool IsExactTypeMatch { get; }
protected override bool CheckExceptionType( actualException, out string? errorMessage) { }
public .<?> WithExceptionType( expectedExceptionType) { }
public .<> WithInnerException() { }
public .<TException> WithMessage(string expectedMessage) { }
public .<TException> WithMessage(string expectedMessage, comparison) { }
Expand Down Expand Up @@ -3911,10 +3912,13 @@ namespace .Sources
public .<.> IsNotCompletedSuccessfully() { }
public .<.> IsNotFaulted() { }
public .<object?, TExpected> IsTypeOf<TExpected>() { }
public .<?> Throws( exceptionType) { }
public .<TException> Throws<TException>()
where TException : { }
public .<TException> ThrowsExactly<TException>()
where TException : { }
public .<TException> ThrowsExactly<TException>(string parameterName)
where TException : { }
}
public class AsyncFuncAssertion<TValue> : ., .<TValue>, .<TValue>
{
Expand Down
Loading
Loading