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.
TransactionScopeAsyncFlowOption.Enabledactually fixed that. Don't know why though :)