2

For a project I'm working on, I'd like to insert testdata before every test and then rollback the transaction for the next test to start with a clean slate.

The skeleton code below works fine for that:

[TestClass]
public class TransactionAsyncTest
{
    private TransactionScope? _scope;

    [TestInitialize]
    public void TestInitialize()
    {
        _scope = new TransactionScope(TransactionScopeOption.Required, TransactionScopeAsyncFlowOption.Enabled);
    }

    [TestMethod]
    public void Test()
    {
        Assert.IsNotNull(Transaction.Current);
    }
    
    [TestCleanup]
    public void TestCleanup()
    {
        _scope?.Dispose();
    }
}

But as soon as I'm changing the definition of TestInitialize to public async Task TestInitialize(), the Transaction.Current seems to be null inside Test(). Is there any way to make TestInitialize async while using the same transaction(scope) in Test?

3
  • I'm not aware of any .net test framework which will retain a single async scope across pre-/test/post- methods. The closest I'm aware of is tunit which at least has an option for you to ask it to flow the async scope from your pre hooks. Commented Jul 14 at 12:08
  • In general it's hard to build on the framework side of things if you're using the pre-/test/post- style hooks that they all seem to favour. I've been looking for a test framework that supports "pipeline" style hooking (where each "middleware" layer calls down to the next eventually arriving at a test) which would be far more async friendly but I've not found one. Commented Jul 14 at 12:23
  • I found that removing TransactionScopeAsyncFlowOption.Enabled actually fixed that. Don't know why though :) Commented Jul 14 at 12:26

1 Answer 1

1

It's likely that TransactionScopeAsyncFlowOption.Enabled uses AsyncLocal under the hoods for storage. Async locals are similar to thread statics, but are very different. Thread static data is data that's bound to a specific thread. But async locals are values that are bound to an "async control flow" (e.g, async methods) and are part of .NET's ExecutionContext. Once you exit an "async" method, any async local values set there are normally lost as well. Simplified example:

using System;
using System.Threading;
using System.Threading.Tasks;

await C.MyMethod1();
Console.WriteLine(C.MyValue1.Value ?? "MyValue1 is null"); // prints MyValue1 is null


await C.MyMethod2();
Console.WriteLine(C.MyValue2.Value ?? "MyValue2 is null"); // prints MyValue2 is null


await C.MyMethod3();
Console.WriteLine(C.MyValue3.Value); // prints MyMethod3


C.MyMethod4();
Console.WriteLine(C.MyValue4.Value); // prints MyMethod4

static class C
{
    public static AsyncLocal<string> MyValue1 { get; } = new();
    public static AsyncLocal<string> MyValue2 { get; } = new();
    public static AsyncLocal<string> MyValue3 { get; } = new();
    public static AsyncLocal<string> MyValue4 { get; } = new();
    
    
    public static async Task MyMethod1()
    {
        MyValue1.Value = "MyMethod1";
        await Task.Delay(1000);
    }

    public static async Task MyMethod2()
    {
        MyValue2.Value = "MyMethod2";
        await Task.Delay(1000);
    }

    public static Task MyMethod3()
    {
        MyValue3.Value = "MyMethod3";
        return Task.Delay(1000); // NOTE: This is not async method!
    }

    public static void MyMethod4()
    {
        MyValue4.Value = "MyMethod4";
    }
}

However, you can manually capture the execution context. This should look like the following:

[TestClass]
public class TransactionAsyncTest
{
    private TransactionScope? _scope;
    private ExecutionContext? _context;

    [TestInitialize]
    public async Task TestInitialize()
    {
        _scope = new TransactionScope(TransactionScopeOption.Required, TransactionScopeAsyncFlowOption.Enabled);
        await Task.Delay(100);
        _context = ExecutionContext.Capture();
    }

    [TestMethod]
    public void Test()
    {
        if (_context is not null)
        {
            ExecutionContext.Restore(_context);
        }

        Assert.IsNotNull(Transaction.Current);
    }

    [TestCleanup]
    public void TestCleanup()
    {
        _scope?.Dispose();
    }
}

Alternatively, when you use TransactionScopeAsyncFlowOption.Suppress, the storage appears to be more likely a thread static, and this is not going to work correctly in the following case:

[TestClass]
public class TransactionAsyncTest
{
    private TransactionScope? _scope;

    [TestInitialize]
    public async Task TestInitialize()
    {
        // At the beginning of TestInitialize, we are on some thread, call it "X"
        await Task.Delay(100);
        // After the await, we are on thread "Y".
        _scope = new TransactionScope(TransactionScopeOption.Required, TransactionScopeAsyncFlowOption.Suppress);
    }

    [TestMethod]
    public void Test()
    {
        // This runs on thread "X", not "Y".
        // NOTE: This is an undocumented implementation detail of MSTest that you shouldn't rely on.
        // If you create TransactionScope on thread X, this will work.
        // But when created on thread Y, it doesn't.
        Assert.IsNotNull(Transaction.Current);
    }

    [TestCleanup]
    public void TestCleanup()
    {
        _scope?.Dispose();
    }
}

The relevant logic in .NET implementation is:

https://github.com/dotnet/runtime/blob/39fc72dc943c98605a1d7774a6884ecd12f2618d/src/libraries/System.Transactions.Local/src/System/Transactions/TransactionScope.cs#L936-L953

there you see if AsyncFlowEnabled is true (it follows TransactionScopeAsyncFlowOption.Enabled), it calls CallContextCurrentData.CreateOrGetCurrentData , which is in turn using async locals:

https://github.com/dotnet/runtime/blob/39fc72dc943c98605a1d7774a6884ecd12f2618d/src/libraries/System.Transactions.Local/src/System/Transactions/Transaction.cs#L1061-L1075

on the non-AsyncFlowEnabled path (the else), it uses ContextData.TLSCurrentData

https://github.com/dotnet/runtime/blob/39fc72dc943c98605a1d7774a6884ecd12f2618d/src/libraries/System.Transactions.Local/src/System/Transactions/Transaction.cs#L1140

which is thread static

I recommend using TransactionScopeAsyncFlowOption.Enabled and manually capturing execution context at the end of test initialize, and restoring it at the beginning of test method.

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.