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

Sar*_*yan 6 .net c# unit-testing async-await cancellationtokensource

我最近编写了一个异步方法,调用外部长时间运行的异步方法,所以我决定通过CancellationToken启用取消.该方法可以同时调用.

实现结合了Stephen Cleary的书" Concurrency in 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();
            }
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

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

最重要的是,我应该忽略实现细节,只测试方法摘要中描述的客户端/调用者(条形图已更新,取消触发器OperationCanceledException,超时触发器TimeoutException).

如果没有,我应该弄湿自己的脚并开始对以下情况进行单元测试:

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

Ste*_*ary 5

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

编写一个测试断言传递给的取消标记会更容易,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);
Run Code Online (Sandbox Code Playgroud)

您可以使用这样的信号来创建一种“锁定步骤”。


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

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

(1) 将像上面那样完成。(2) 和 (3) 不太容易测试(对于正确的测试,需要 MS Fakes 或时间/互斥体的抽象)。在单元测试方面肯定有一个收益递减点,这取决于您想要走多远。