XUnit資料共享與並行測試

波多爾斯基發表於2023-05-10

引言

在單元或者整合測試的過程中,需要測試的用例非常多,如果測試是一條一條過,那麼需要花費不少的時間。從 V2 開始,預設情況下 XUnit 自動配置並行(參考資料),大大提升了測試速度。本文將對 ASP.NET CORE WEBAPI 程式進行整合測試,並探討 XUnit 的資料共享與測試並行的方法。

XUnit預設在一個類內的測試程式碼是序列執行的,而在不同類的測試程式碼是並行執行的。

整合測試

對於整合測試來說,我們有一些比較重的資源初始化,而我並不想他們在並行執行中重複初始化,因此需要將並行執行的資源共享。

我們現在的測試類是這樣的:

    public class ProgramTests : IClassFixture<WebApplicationFactory<Program>>
    {
        private readonly WebApplicationFactory<Program> _factory;
        private readonly ITestOutputHelper testOutputHelper;
        private readonly HttpClient _client;
        public ProgramTests(WebApplicationFactory<Program> factory, ITestOutputHelper testOutputHelper)
        {
            _factory = factory;
            this.testOutputHelper = testOutputHelper;
            _client = _factory.WithWebHostBuilder(builder =>
            {
                builder.UseEnvironment(Environments.Production);
            }).CreateClient(new WebApplicationFactoryClientOptions() { BaseAddress = new Uri("http://localhost:9000") });
            var token = TokenHelper.GetToken("username", "password");
            _client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
            // Act
        }

        [Fact]
        public async Task V1Legacy_GetDeviceInfoes()
        {
            string url = "url1";
            // Arrange
            testOutputHelper.WriteLine($"Testing:{url}");
            var response = await _client.GetAsync(url);

            // Assert
            response.EnsureSuccessStatusCode(); // Status Code 200-299
            var result = await response.Content.ReadAsStringAsync();
            var target = JsonSerializer.Deserialize<DeviceInfo>(result, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
            Assert.NotNull(target); 
        }

        [Fact]
        public async Task V1Legacy_GetCurrent()
        {
            var url = "url2";
            // Arrange
            testOutputHelper.WriteLine($"Testing:{url}");
            var response = await _client.GetAsync(url);

            // Assert
            response.EnsureSuccessStatusCode(); // Status Code 200-299
            var result = await response.Content.ReadAsStringAsync();
            var target = JsonSerializer.Deserialize<DeviceDataDto>(result, new JsonSerializerOptions {PropertyNameCaseInsensitive = true });
            Assert.NotNull(target);
        }

        [Theory]
        [InlineData("url3")]
        [InlineData("url4")]
        public async Task V1Legacy_CheckUrlExist(string url)
        {
            // Arrange
            testOutputHelper.WriteLine($"Testing:{url}");
            var request = new HttpRequestMessage(HttpMethod.Head, url);
            var response = await _client.SendAsync(request);

            // Assert
            Assert.NotEqual(404, (int)response.StatusCode); 
        }
    }

在這個測試中,使用 IClassFixture 進行整合測試,確保同一個類之內的程式碼共享同一個資源,不同測試方法序列執行。

TIPS: 這裡我使用 HEAD 請求來探查給定地址是否存在,ASP. NET CORE 會預設拒絕這個請求(返回406),但是不會提示 404 的錯誤。

現在的執行時間是這樣的:

單類最佳化

首先研究為什麼這個程式花費了如此多的時間執行測試,XUnit 在進行不同 Fact 的測試時,會生成不同的物件,我們已經透過實現 IClassFixture<WebApplicationFactory<Program>> 共享了必要的資料嗎?

並沒有,XUnit 只是注入了 WebApplicationFactory<Program>,而我們在建構函式中執行了很多費時間的操作,包括構造 HttpClient ,獲取 token 等。由於獲取 token 的函式需要呼叫外部服務花費了很長的時間,我們可以嘗試注入 HttpClient 進行最佳化。

請注意:大多數情況注入 HttpClient 不是一個好主意,更推薦利用 WebApplicationFactory<Program> 對每個測試動態生成 HttpClient 以保證 HttpClient 是初始乾淨的狀態。

我們加入一個新的類:

    public class SharedHttpClientFixture : IDisposable
    {
        public HttpClient Client { get; init; }

        public SharedHttpClientFixture()
        {
            WebApplicationFactory<YourAssemblyName.Program> factory = new();
            
                Client = factory.WithWebHostBuilder(builder =>
                        {
                            builder.UseEnvironment(Environments.Production);
                        }).CreateClient(new WebApplicationFactoryClientOptions() { BaseAddress = new Uri("http://localhost:9000") });

            var token = TokenHelper.GetToken("username", "password");
            Client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
            // Act
        }

        public void Dispose()
        {
            //throw new NotImplementedException();
        }
    }

並修改測試類的簽名:

    public class ProgramTests : IClassFixture<SharedHttpClientFixture>
    {
        private readonly ITestOutputHelper testOutputHelper;
        private readonly HttpClient _client;
        public ProgramTests(SharedHttpClientFixture httpClientFixture, ITestOutputHelper testOutputHelper)
        {
            _client = httpClientFixture.Client;
            this.testOutputHelper = testOutputHelper;
        }

改完之後,速度提升效果還是非常顯著的:
image

跨類序列

我們多數情況下不會將所有的測試都放在一個類中,對於多個類,我們需要跨類共享。XUnit 使用 ICollectionFixture<> 支援跨類共享。程式碼主體拆成兩個類,並修改類簽名如下:

    [Collection("V1 Test Fixture")]
    public class ProgramTests
    {
    ...
    }
    [Collection("V1 Test Fixture")]
    public class UploadTests
    {
	....
	}

我們需要新定義一個類,這個類沒有實質性作用,只是作為標識:

    [CollectionDefinition("V1 Test Fixture")]
    public class TestCollection : ICollectionFixture<SharedHttpClientFixture>
    {
        // This class has no code, and is never created. Its purpose is simply
        // to be the place to apply [CollectionDefinition] and all the
        // ICollectionFixture<> interfaces.
    }

我們針對多個類進行測試:
image

跨類並行(資料不共享)

我們注意到,不同類使用了相同的 Collection 進行標註,因此他們實際上會進行同步排程——上一個執行完成後才會開始執行下一個測試。我們如果使用並行會怎麼樣呢?顯然,修改 Colleciton 會對每個類都生成一次需要注入物件,資料不能直接被共享。

    [Collection("V1 Test Fixture1")]
    public class ProgramTests
    {
    ...
    }
    [Collection("V1 Test Fixture")]
    public class UploadTests
    {
	....
	}
	[CollectionDefinition("V1 Test Fixture1")]
    public class Test1Collection : ICollectionFixture<SharedHttpClientFixture>
    {
        // This class has no code, and is never created. Its purpose is simply
        // to be the place to apply [CollectionDefinition] and all the
        // ICollectionFixture<> interfaces.
    }
        [CollectionDefinition("V1 Test Fixture")]
    public class TestCollection : ICollectionFixture<SharedHttpClientFixture>
    {
        // This class has no code, and is never created. Its purpose is simply
        // to be the place to apply [CollectionDefinition] and all the
        // ICollectionFixture<> interfaces.
    }

初始化語句會被執行兩次,我們實現了並行,但是資料並不是共享的。(大多數情況下已經夠用了。)
image

跨類並行(資料共享)

由於任務並行無法得知其他任務的工作狀態,這個時候資料共享可能會引入很多執行緒問題(競爭、死鎖等),因此不太建議在這種情況下進行共享,我最終也是使用的並行不共享的方式實現。如果我們非得這麼用,也不是不行,我們需要小心處理執行緒同步問題,以互斥鎖為例:

 public class SharedHttpClientFixture : IDisposable
    {
        private static HttpClient _httpClient;
        public HttpClient Client => GetClient();

        private HttpClient GetClient()
        {
            if (_httpClient == null) Init();
            return _httpClient;
        }


        public static Mutex count = new();

        public SharedHttpClientFixture()
        {

        }

        private void Init()
        {
            count.WaitOne();
            if(_httpClient == null)
            {
				...
            }
            count.ReleaseMutex();
        }

        public void Dispose()
        {
            //throw new NotImplementedException();
        }
}

這樣多個型別使用靜態變數實現了共享,並利用互斥鎖保證初始化只執行一次。
image

由於引入了執行緒同步機制,這種情況下,並行測試並不一定意味著效能會更好,實際上往往還會更差。

生命週期

XUnit 對共享的資料型別執行以下策略:

  • IClassFixture,類的第一個測試方法執行之前,會對注入物件進行初始化。隨後每一個方法都會生成測試的類的新物件,並將注入物件傳遞給他們,在測試類中所有測試方法執行完畢後銷燬。
  • ICollectionFixture,多個類中執行的第一個測試方法之前會對注入物件進行初始化。隨後每一個方法都會生成測試類的新物件,並且將注入物件傳遞給他們,在所有測試類的最後一個方法執行完畢之後銷燬。

Program 可見性

預設情況下 Program 是其他專案不可見的,這樣會導致 WebApplicationFactory<Program> 提示錯誤。.NET 6 開始引入了 minimal API,我的專案是升級而來,並沒有使用到這個東西,所以 Program 類是對外可見的。

    public class Program
    {
        static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);
            builder.WebHost.UseUrls("http://*:9000");
            ConfigureServices(builder.Services, builder.Configuration);
            var app = builder.Build();
            Configure(app);
            app.Run();
        }
    }

如果使用 Minimal API,那麼你需要在專案檔案中對測試專案公開可見性。

<ItemGroup>
     <InternalsVisibleTo Include="MyTestProject" />
</ItemGroup>

或者在 Program.cs 的最後加上一行。

var builder = WebApplication.CreateBuilder(args);
// ... Configure services, routes, etc.
app.Run();
+ public partial class Program { }

注意事項

Microsoft.VisualStudio.TestPlatform.TestHost 名稱空間也有一個 Program 類,如果你自己實現自定義型別,由於預設引用,不注意就使用了這個東西,而不是你 API 的 Program 類,這樣會導致測試無法執行,提示:“找不到 testHost.dep.json”這樣的錯誤,所以儘量使用帶名稱空間的限定名稱。

結論

在 XUnit 測試中,可以使用 IClassFixtureICollectionFixture 來進行資料共享,對於相同類之間的測試會預設進行的序列測試,對不同類之間共享資料的情況,也會進行序列呼叫。對於在不同類的測試,推薦使用不共享資料的並行測試,資料共享越多,造成狀態不一致的風險就越大,因此建議有限制地使用測試資料共享。

請注意,XUnit 不會統計注入物件的初始化時間,而且多次執行測試時間會有一些區別,因此本文中列出的時間僅供參考。

擴充閱讀

如果覺得自帶的方注入方式滿足不了你的要求,那麼可以考慮使用第三方類庫實現的支援 XUnit 的依賴注入容器。請關注這個專案: pengweiqhca/Xunit.DependencyInjection: Use Microsoft.Extensions.DependencyInjection to resolve xUnit test cases. (github.com)

相關文章