In reference to: xunit/xunit#2003
I've run into this problem and decided to try to fix it myself. This is my code, which adds a way to selectively, in an opt-in way, decide which tests (or rather test collections) have which concurrency requirements.
Note: my knowledge about xUnit and how it works might be wrong and my code most likely contains bugs. I made it work for myself, for anyone else trying to use my code, try to understand it and adapt it to your use case.
Since all tests in the same collection are always executed serially, parallelization in xUnit executes collections concurrently, but the tests in the same collection run one after the other, without being parallelized. What I implemented is a way to mark which collections should have their concurrency limited, and optionally by how much (you can specify how many collections can be executed concurrently). This effectively solves the concurrency problems discussed, while (hopefully) maintaining full backwards compatibility with how xUnit works, by simply adding an optional feature.
Just copy-paste the classes LimitConcurrencyAttribute
, LimitedConcurrencyAssemblyRunner
, LimitedConcurrencyTestFrameworkExecutor
and LimitedConcurrencyTestFramework
into your test project and adapt them to your needs. The code is written for .NET 7, but should be fairly easily portable to older .NET versions.
The easiest way to enable concurrency limits is to change the xUnit test framework on the test assembly to LimitedConcurrencyTestFramework
(put this line in any .cs
file, like AssemblyInfo.cs
):
// TODO: change the type name and assembly name to match your use case
[assembly: TestFramework("xUnitLimitedConcurrencyTestFramework.LimitedConcurrencyTestFramework", "xUnitLimitedConcurrencyTestFramework")]
Note: don't forget to change the test framework full type name and assembly name to match your case.
The LimitConcurrencyAttribute
does not do anything by itself. In order to use it, you must use LimitedConcurrencyAssemblyRunner
. There is no direct way to swap out just the runner, so I created a LimitedConcurrencyTestFrameworkExecutor
and LimitedConcurrencyTestFramework
for that.
LimitedConcurrencyTestFramework
and LimitedConcurrencyAssemblyRunner
write out some diagnostic messages while they are running. To see those messages (for example when running dotnet test
), you have to enable xUnit diagnostic messages. For example, by adding an xunit.runner.json
file to the project:
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"diagnosticMessages": true
}
And adding this line to your test project .csproj
:
<ItemGroup>
<None Update="xunit.runner.json" CopyToOutputDirectory="Always" />
</ItemGroup>
Running the example code, with diagnostic messages enabled, I get this output:
$ dotnet test
Determining projects to restore...
All projects are up-to-date for restore.
xUnitLimitedConcurrencyTestFramework -> /home/mantas/projects/xUnitLimitedConcurrencyTestFramework/xUnitLimitedConcurrencyTestFramework/bin/Debug/net7.0/xUnitLimitedConcurrencyTestFramework.dll
Test run for /home/mantas/projects/xUnitLimitedConcurrencyTestFramework/xUnitLimitedConcurrencyTestFramework/bin/Debug/net7.0/xUnitLimitedConcurrencyTestFramework.dll (.NETCoreApp,Version=v7.0)
Microsoft (R) Test Execution Command Line Tool Version 17.4.0+c02ece877c62577810f893c44279ce79af820112 (x64)
Copyright (c) Microsoft Corporation. All rights reserved.
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
[xUnit.net 00:00:00.59] xUnitLimitedConcurrencyTestFramework: Using xUnitLimitedConcurrencyTestFramework.LimitedConcurrencyTestFramework.
[xUnit.net 00:00:00.67] xUnitLimitedConcurrencyTestFramework: [xUnitLimitedConcurrencyTestFramework.LimitedConcurrencyAssemblyRunner] Setting up the synchronization context...
[xUnit.net 00:00:00.68] xUnitLimitedConcurrencyTestFramework: [xUnitLimitedConcurrencyTestFramework.LimitedConcurrencyAssemblyRunner] Collecting collections...
[xUnit.net 00:00:00.71] xUnitLimitedConcurrencyTestFramework: [xUnitLimitedConcurrencyTestFramework.LimitedConcurrencyAssemblyRunner] Executing collections...
[xUnit.net 00:00:00.71] xUnitLimitedConcurrencyTestFramework: [xUnitLimitedConcurrencyTestFramework.LimitedConcurrencyAssemblyRunner] Starting test collection 1/7...
[xUnit.net 00:00:00.71] xUnitLimitedConcurrencyTestFramework: [xUnitLimitedConcurrencyTestFramework.LimitedConcurrencyAssemblyRunner] Starting test collection 2/7...
[xUnit.net 00:00:00.71] xUnitLimitedConcurrencyTestFramework: [xUnitLimitedConcurrencyTestFramework.LimitedConcurrencyAssemblyRunner] Starting test collection 3/7...
[xUnit.net 00:00:01.79] xUnitLimitedConcurrencyTestFramework: [xUnitLimitedConcurrencyTestFramework.LimitedConcurrencyAssemblyRunner] Starting test collection 4/7...
[xUnit.net 00:00:02.80] xUnitLimitedConcurrencyTestFramework: [xUnitLimitedConcurrencyTestFramework.LimitedConcurrencyAssemblyRunner] Starting test collection 5/7...
[xUnit.net 00:00:03.81] xUnitLimitedConcurrencyTestFramework: [xUnitLimitedConcurrencyTestFramework.LimitedConcurrencyAssemblyRunner] Starting test collection 6/7...
[xUnit.net 00:00:05.82] xUnitLimitedConcurrencyTestFramework: [xUnitLimitedConcurrencyTestFramework.LimitedConcurrencyAssemblyRunner] Starting test collection 7/7...
[xUnit.net 00:00:05.82] xUnitLimitedConcurrencyTestFramework: [xUnitLimitedConcurrencyTestFramework.LimitedConcurrencyAssemblyRunner] Waiting for the last 1 test collections to end...
Passed! - Failed: 0, Passed: 8, Skipped: 0, Total: 8, Duration: 5 s - xUnitLimitedConcurrencyTestFramework.dll (net7.0)
The line Using xUnitLimitedConcurrencyTestFramework.LimitedConcurrencyTestFramework.
indicates that the concurrency limits are enabled. The other messages are just diagnostic messages to keep track of how the concurrency is doing.
To limit concurrency I created a LimitConcurrencyAttribute
. You can place the attribute on (in order of precedence):
- Test methods (
[Fact]
,[Theory]
, etc..) - Test classes
- Collection definitions (
[CollectionDefinition]
) - Assembly (
[assembly: ...]
)
The LimitConcurrencyAttribute
attribute also has a property MaxConcurrentCollections
. This property determines how many collections can be executed concurrently in total.
To determine what concurrency limits have been applied to a single collection, concurrency for each test case (test method) in the collection is determined. Then, the lowest concurrency value of all of the test cases is used.
To determine what concurrency limits have been applied to a single test case (test method), LimitConcurrencyAttribute
is scanned for according to precedence rules defined above.
For example, if we have a [CollectionDefinition("collection1")]
class which also has [LimitConcurrency(4)]
, but then a test class using that collection definition [Collection("collection1")]
has a method [Fact]
with [LimitConcurrency(2)]
, the concurrency limit for the whole collection "collection1"
will be set to 2
.
Don't be afraid to simply inspect what the LimitedConcurrencyAssemblyRunner
does in order to adapt it more to your use case.
If you just want to customize how to determine concurrency limits for a collection, you can customize the LimitedConcurrencyAssemblyRunner.GetMaxConcurrentCollectionsCount
method. By default, that method just looks for the LimitConcurrencyAttribute
, but this can be customized, for example, to look if a class of the method implements an interface, like IClassFixture<WebApplicationFactory<Startup>>
.