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



我最近正在编写一个async方法,该方法调用外部长时间运行的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();
            }
        }
    }
}

我想知道我是否应该写一个单元测试,明确地断言内部任务barService.GetBarAsync()FooAsync()被取消时被取消。如果是这样,如何清晰地实现它?

最重要的是,我应该忽略实现细节,只是测试客户端/调用者所关注的方法摘要中所描述的(栏更新,取消触发OperationCanceledException,超时触发TimeoutException)。

如果没有,我是否应该尝试一下,开始为以下情况实现单元测试:

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

我想知道我是否应该写一个单元测试,明确地断言内部任务barService.GetBarAsync()在FooAsync()取消时被取消。

如果编写一个测试,断言传递给GetBarAsync取消令牌在传递给FooAsync的取消令牌被取消时也会被取消,这会更容易。

对于异步单元测试,我选择的信号是异步信号的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);

你可以使用这样的信号来创建一种"锁步"。


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

  1. 将CT(间接)传递给service method,触发OperationCanceledException
  2. 也有一个超时,导致TimeoutException .
  3. 执行是互斥的

(1)的处理方法与上面的方法相同。(2)和(3)不太容易测试(对于适当的测试,需要MS Fakes或时间/互斥锁的抽象)。当涉及到单元测试时,肯定存在收益递减的点,这取决于您想要走多远。

感谢Stephen Cleary对Polly retry的点头。也许将来的读者会感兴趣,原始海报代码样本中的所有功能现在都可以从已经经过单元测试的现成Polly原语构建:

  • 通过超时取消令牌(包括与用户提供的取消令牌组合)进行超时的超时策略
  • 并行节流/互斥舱壁策略
  • 等待重试,包括等待期间的取消
  • PolicyWrap合并。

所有的Polly策略都经过完整的单元测试,同步和异步兼容,线程安全的并发执行,并具有直通取消支持。

因此,原始代码的意图可以像这样实现:
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);

我将添加一些关于单元测试的评论(OP最初的问题)到Stephen(更相关的)回答的评论中。

最新更新