Home > other >  Await not returning in SpecFlow async test step
Await not returning in SpecFlow async test step

Time:04-30

I'm having trouble with some async SpecFlow unit tests where an await is never returning. I think that it is to do with the SpecFlow SynchronizationContext, but I'm not really sure how this works.

The class I am writing tests for periodically runs code on an IScheduler. This code awaits on a Task that completes on an external event. In the unit test the IScheduler is a TestScheduler and the external event Task is simulated by some stub code.

The only way I have been able to get this to work, is to ensure that the await and the task completion are both called from synchronous SpecFlow test steps.

  • If the await is called from an asynchronous test step then the await never returns.
  • If the await is called from a synchronous test step and the task completion is called from an asynchronous test step then the await will return, but on a worker thread, so not under control of the unit test, so I have to delay to ensure it happens.
  • If both the await and the task completion are called from synchronous test steps then the await will return synchronously on the same thread as the unit test.

My assumption is that when the await is called from an asynchronous test step then SynchronizationContext.Current is captured and when the task completes it tries to return on that SynchronizationContext, but it is no longer valid because it was a previous test step. When called from a synchronous test step I noticed that SynchronizationContext.Current is null, which I assume is why the await returns synchronously with the task completion (which is what I want for testing)

Is there something I can do with the await and/or task completion (both are in the test stub code) to ensure the await ignores SynchronizationContext.Current and always returns synchronously in the test?

I'm using SpecFlow 3.9.58

I was able to reproduce this issue with the following code:

Feature: Await

Scenario: Wait Async, Set Async
    Given we await asynchronously
    When we set asynchronously
    Then await should have completed

Scenario: Wait Sync, Set Sync
    Given we await synchronously
    When we set synchronously
    Then await should have completed

Scenario: Wait Async, Set Sync
    Given we await asynchronously
    When we set synchronously
    Then await should have completed

Scenario: Wait Sync, Set Async
    Given we await synchronously
    When we set asynchronously
    Then await should have completed

Scenario: Wait Sync, Set Async, Delay
    Given we await synchronously
    When we set asynchronously
    And we wait a bit
    Then await should have completed
using Microsoft.Reactive.Testing;
using NUnit.Framework;
using System.Reactive.Concurrency;
using System.Threading;
using System.Threading.Tasks;
using TechTalk.SpecFlow;

namespace Test.SystemTests
{
    [Binding]
    public sealed class AwaitSteps
    {
        private TestScheduler _scheduler = new TestScheduler();
        private TaskCompletionSource _tcs;
        private bool _waiting;

        // The previous Wait() implementation before Zer0's answer
        //private async Task Wait()
        //{
        //    _tcs = new TaskCompletionSource();
        //    _waiting = true;
        //    await _tcs.Task;
        //    _waiting = false;
        //}

        // Updated Wait()/NestedWait() following Zer0's answer
        private async Task NestedWait()
        {
            // Simulate the code in the stub class (can change this)
            _tcs = new TaskCompletionSource();
            await _tcs.Task.ConfigureAwait(false);
        }

        private async Task Wait()
        {
            // Simulate the code in the class under test (prefer not to change this)
            _waiting = true;
            await NestedWait();
            _waiting = false;
        }

        private void WaitOnScheduler()
        {
            _scheduler.Schedule(async () => await Wait());
            _scheduler.AdvanceBy(1);
        }

        private void Set() => _tcs.TrySetResult();

        [Given(@"we await asynchronously")]
        public async Task GivenWeAwaitAsynchronously()
        {
            await Task.CompletedTask; // Just to make the step async
            WaitOnScheduler();
        }

        [Given(@"we await synchronously")]
        public void GivenWeAwaitSynchronously() => WaitOnScheduler();

        [When(@"we set asynchronously")]
        public async Task WhenWeSetAsynchronously()
        {
            await Task.CompletedTask; // Just to make the step async
            Set();
        }

        [When(@"we set synchronously")]
        public void WhenWeSetSynchronously() => Set();

        [When(@"we wait a bit")]
        public async Task WhenWeWaitABit() => await Task.Delay(100);

        [Then(@"await should have completed")]
        public void ThenAwaitShouldHaveCompleted() => Assert.AreEqual(false, _waiting);
    }
}

enter image description here

CodePudding user response:

My assumption is that when the await is called from an asynchronous test step then SynchronizationContext.Current is captured and when the task completes it tries to return on that SynchronizationContext

That can be changed with ConfigureAwait(false). I made that change alone:

private async Task Wait()
{
    _tcs = new TaskCompletionSource();
    _waiting = true;
    await _tcs.Task.ConfigureAwait(false);
    _waiting = false;
}

And got these results, using your code verbatim minus that one change.

enter image description here

Regarding having to call ConfigureAwait(false) all the time there are several workarounds. Here's useful documentation around all of this, including the details and ways to avoid capture such as a simple Task.Run call.

The easiest in this case is doing this as you setup your scenario:

SynchronizationContext.SetSynchronizationContext(null);

This eliminates the need for ConfigureAwait entirely as there's nothing to capture.

There are other options. See the linked article or comment if this doesn't answer your question.

AFAIK, there is no exposed property anywhere to change the default behavior, so some code is required regardless.

That said ConfigureAwait(false) makes it very explicit what you're doing when other coders read it. So I'd keep that in mind if you want a different solution.

  • Related