引言
在單元或者整合測試的過程中,需要測試的用例非常多,如果測試是一條一條過,那麼需要花費不少的時間。從 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;
}
改完之後,速度提升效果還是非常顯著的:
跨類序列
我們多數情況下不會將所有的測試都放在一個類中,對於多個類,我們需要跨類共享。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.
}
我們針對多個類進行測試:
跨類並行(資料不共享)
我們注意到,不同類使用了相同的 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.
}
初始化語句會被執行兩次,我們實現了並行,但是資料並不是共享的。(大多數情況下已經夠用了。)
跨類並行(資料共享)
由於任務並行無法得知其他任務的工作狀態,這個時候資料共享可能會引入很多執行緒問題(競爭、死鎖等),因此不太建議在這種情況下進行共享,我最終也是使用的並行不共享的方式實現。如果我們非得這麼用,也不是不行,我們需要小心處理執行緒同步問題,以互斥鎖為例:
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();
}
}
這樣多個型別使用靜態變數實現了共享,並利用互斥鎖保證初始化只執行一次。
由於引入了執行緒同步機制,這種情況下,並行測試並不一定意味著效能會更好,實際上往往還會更差。
生命週期
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 測試中,可以使用 IClassFixture
與 ICollectionFixture
來進行資料共享,對於相同類之間的測試會預設進行的序列測試,對不同類之間共享資料的情況,也會進行序列呼叫。對於在不同類的測試,推薦使用不共享資料的並行測試,資料共享越多,造成狀態不一致的風險就越大,因此建議有限制地使用測試資料共享。
請注意,XUnit 不會統計注入物件的初始化時間,而且多次執行測試時間會有一些區別,因此本文中列出的時間僅供參考。
擴充閱讀
如果覺得自帶的方注入方式滿足不了你的要求,那麼可以考慮使用第三方類庫實現的支援 XUnit 的依賴注入容器。請關注這個專案: pengweiqhca/Xunit.DependencyInjection: Use Microsoft.Extensions.DependencyInjection to resolve xUnit test cases. (github.com)