单元测试异步方法:如何明确断言内部任务已取消

Unit testing async method: How to explicitly assert that the internal task was cancelled

我最近在写一个 async 方法调用一个外部 long 运行 async 方法所以我决定通过 CancellationToken 启用取消。该方法可以并发调用。

实施结合了指数退避超时技术,在Stephen Cleary的书中并发C# Cookbook如下;

/// <summary>
/// Sets bar
/// </summary>
/// <param name="cancellationToken">The cancellation token that cancels the operation</param>
/// <returns>A <see cref="Task"/> representing the task of setting bar value</returns>
/// <exception cref="OperationCanceledException">Is thrown when the task is cancelled via <paramref name="cancellationToken"/></exception>
/// <exception cref="TimeoutException">Is thrown when unable to get bar value due to time out</exception>
public async Task FooAsync(CancellationToken cancellationToken)
{
    TimeSpan delay = TimeSpan.FromMilliseconds(250);
    for (int i = 0; i < RetryLimit; i++)
    {
        if (i != 0)
        {
            await Task.Delay(delay, cancellationToken);
            delay += delay; // Exponential backoff
        }

        await semaphoreSlim.WaitAsync(cancellationToken); // Critical section is introduced for long running operation to prevent race condition

        using (CancellationTokenSource cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
        {
            cancellationTokenSource.CancelAfter(TimeSpan.FromMilliseconds(Timeout));
            CancellationToken linkedCancellationToken = cancellationTokenSource.Token;

            try
            {
                cancellationToken.ThrowIfCancellationRequested();
                bar = await barService.GetBarAsync(barId, linkedCancellationToken).ConfigureAwait(false);

                break;
            }
            catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
            {
                if (i == RetryLimit - 1)
                {
                    throw new TimeoutException("Unable to get bar, operation timed out!");
                }

                // Otherwise, exception is ignored. Will give it another try
            }
            finally
            {
                semaphoreSlim.Release();
            }
        }
    }
}

我想知道我是否应该编写一个单元测试,明确断言只要 FooAsync() 被取消,内部任务 barService.GetBarAsync() 就会被取消。如果是这样,如何干净地实施它?

最重要的是,我是否应该忽略实现细节,只测试方法摘要中描述的 client/caller 所关注的内容(栏已更新,取消触发器 OperationCanceledException,超时触发器 TimeoutException).

如果没有,我是否应该亲自动手并开始针对以下情况实施单元测试:

  1. 正在测试它是线程安全的(monitor 一次只能由一个线程获取)
  2. 测试重试机制
  3. 测试服务器未被淹没
  4. 测试甚至可能将常规异常传播给调用者

I wonder if I should write a unit test that explicitly asserts that the internal task barService.GetBarAsync() is cancelled whenever FooAsync() is cancelled.

编写一个测试断言只要传递给 FooAsync 的取消标记被取消,传递给 GetBarAsync 取消标记 就会被取消已取消。

对于异步单元测试,我选择的信号是 TaskCompletionSource<object> 异步信号和 ManualResetEvent 同步信号。由于 GetBarAsync 是异步的,我会使用异步的,例如

var cts = new CancellationTokenSource(); // passed into FooAsync
var getBarAsyncReady = new TaskCompletionSource<object>();
var getBarAsyncContinue = new TaskCompletionSource<object>();
bool triggered = false;
[inject] GetBarAsync = async (barId, cancellationToken) =>
{
  getBarAsyncReady.SetResult(null);
  await getBarAsyncContinue.Task;
  triggered = cancellationToken.IsCancellationRequested;
  cancellationToken.ThrowIfCancellationRequested();
};

var task = FooAsync(cts.Token);
await getBarAsyncReady.Task;
cts.Cancel();
getBarAsyncContinue.SetResult(null);

Assert(triggered);
Assert(task throws OperationCanceledException);

您可以使用这样的信号来创建一种 "lock-step"。


旁注:在我自己的代码中,我从不编写重试逻辑。我使用 Polly,它完全 async 兼容并经过全面测试。这会将需要测试的语义减少到:

  1. CT 被(间接)传递给服务方法,导致触发时 OperationCanceledException
  2. 也有超时,导致TimeoutException.
  3. 执行是互斥的。

(1) 就像上面那样完成。 (2) 和 (3) 不太容易测试(对于适当的测试,需要 MS Fakes 或 time/mutex 的抽象)。在单元测试方面肯定有一个递减的点 returns,你想走多远取决于你。

感谢 Stephen Cleary 同意 Polly 重试。也许未来的读者会感兴趣,原始海报代码示例中的所有功能现在都可以从已经过单元测试的现成 Polly 基元构建:

所有 Polly 策略 fully unit-tested、同步和异步兼容、并发执行线程安全,并支持直通取消。

所以,原始代码的意图可以实现如下:

Policy retry = Policy.Handle<WhateverExceptions>().WaitAndRetryAsync(RetryLimit, retryAttempt => TimeSpan.FromMilliseconds(250 * Math.Pow(2, retryAttempt)));
Policy mutex = Policy.BulkheadAsync(1);
Policy timeout = Policy.TimeoutAsync(/* define overall timeout */);

bar = await timeout.WrapAsync(retry).WrapAsync(mutex).ExecuteAsync(ct => barService.GetBarAsync(barId, ct), cancellationToken);

我将在对 Stephen 的(更相关的)回答的评论中添加一些关于单元测试(OP 的原始问题)的评论。