Today's Assert.ThrowsAsync and related methods do sync-over-async. This means they convert an async operation into a synchronous one. In other words, they block until the task is complete and return void or the return value directly, rather than returning an awaitable type themselves such as Task ThrowsAsync or Task<Exception> CatchAsync.
What does the problem look like?
public class Assert
{ Problem: not async Task
⬇
public static void ThrowsAsync(AsyncTestDelegate code) { … }
Problem: no async Task ThatAsync
⬇
public static void That(ActualValueDelegate<Task> code) { … }
}
Problem: not async Task
[Test] ⬇
public void TestMethod()
{
Problem: no `await`
⬇
Assert.ThrowsAsync(async () =>
{
await Task.Delay(10_000); // Fiddle while Rome burns, why don’t ya 😇
});
Problem: no `await`
⬇
Assert.That(async () => await …, Throws.InstanceOf<FooException>());
}
Why is this a problem?
Waiting synchronously for an async operation is a antipattern with real-world consequences. The consequences range from losing scalability (if you're doing async right, there is no thread) to outright deadlocks and other logic errors.
Well, in 3.11, I fixed the deadlock issue if you instantiate Windows Forms or WPF controls in your tests. So what then, why not keep doing sync-over-async in Assert?
-
First, deadlocks are still a threat. I only fixed deadlocks on recognized message-pumping SynchronizationContexts, and even that hasn't been stress-tested for reentry with assert delegates nested inside assert delegates. We shouldn't have to go to the length of testing sync-over-async-over-sync-over-async; our APIs shouldn't be allowing it like this.
-
Second, without a good reason, it goes in the face of hard-learned good practices with async/await. The async-await paradigm is complex enough for those who are still becoming familiar with it that we should not be requiring them to code their tests around this antipattern. Assert.*Async is doing nothing which justifies an exception. It's one thing for NUnit as a framework to do sync-over-async with the entry point, the test method itself—this is exactly like C# 7.1's async Task Main—but it's quite another thing for NUnit as a library, in Assert.*Async, to be doing sync-over-async as well.
What does the solution look like?
I've had proposals for this nagging me for a year and a half now, and none of them seem like slam dunks.
If we just switch Assert.ThrowsAsync from void to async Task we silently break some people's code. And if we switch Assert.CatchAsync from Exception to Task<Exception>, we cause any code using it to stop compiling. The second instance might be considered good pain, but the first option (silent breakage) is absolutely unacceptable.
➡ This means likely the best course is deprecation warnings for all Assert.*Async methods.
(Assuming we don't ship a special analyzer in the NUnit package to turn the silent errors into compiler errors and provide a "fix all.")
If we do deprecation warnings, these are our options as I see them:
- Pick new names for the async methods. (I have no ideas for names, though.)
- Introduce
AssertAsync.Throws and AssertAsync.That where all the methods return Task or Task<>.
@rprouse I would like to fix this in 3.11 as part of the theme, if that seems good to you.
Today's Assert.ThrowsAsync and related methods do sync-over-async. This means they convert an async operation into a synchronous one. In other words, they block until the task is complete and return
voidor the return value directly, rather than returning an awaitable type themselves such asTask ThrowsAsyncorTask<Exception> CatchAsync.What does the problem look like?
Why is this a problem?
Waiting synchronously for an async operation is a antipattern with real-world consequences. The consequences range from losing scalability (if you're doing
asyncright, there is no thread) to outright deadlocks and other logic errors.Well, in 3.11, I fixed the deadlock issue if you instantiate Windows Forms or WPF controls in your tests. So what then, why not keep doing sync-over-async in
Assert?First, deadlocks are still a threat. I only fixed deadlocks on recognized message-pumping SynchronizationContexts, and even that hasn't been stress-tested for reentry with assert delegates nested inside assert delegates. We shouldn't have to go to the length of testing sync-over-async-over-sync-over-async; our APIs shouldn't be allowing it like this.
Second, without a good reason, it goes in the face of hard-learned good practices with async/await. The async-await paradigm is complex enough for those who are still becoming familiar with it that we should not be requiring them to code their tests around this antipattern.
Assert.*Asyncis doing nothing which justifies an exception. It's one thing for NUnit as a framework to do sync-over-async with the entry point, the test method itself—this is exactly like C# 7.1'sasync Task Main—but it's quite another thing for NUnit as a library, inAssert.*Async, to be doing sync-over-async as well.What does the solution look like?
I've had proposals for this nagging me for a year and a half now, and none of them seem like slam dunks.
If we just switch
Assert.ThrowsAsyncfromvoidtoasync Taskwe silently break some people's code. And if we switchAssert.CatchAsyncfromExceptiontoTask<Exception>, we cause any code using it to stop compiling. The second instance might be considered good pain, but the first option (silent breakage) is absolutely unacceptable.➡ This means likely the best course is deprecation warnings for all
Assert.*Asyncmethods.(Assuming we don't ship a special analyzer in the NUnit package to turn the silent errors into compiler errors and provide a "fix all.")
If we do deprecation warnings, these are our options as I see them:
AssertAsync.ThrowsandAssertAsync.Thatwhere all the methods returnTaskorTask<>.@rprouse I would like to fix this in 3.11 as part of the theme, if that seems good to you.