[Abp vNext 原始碼分析] - 18. 單元測試

MyZony發表於2021-04-27

簡介

ABP vNext 框架使用 xUnit 作為單元測試元件,官方的所有模組都編寫了大量的 單元/整合測試 確保功能正常。由於 ABP vNext 模組化系統的原因,開發人員在建立單元測試專案的時候需要整合 Volo.Abp.UnitTest 專案,這樣在執行單元測試的時候才不會缺少必要元件。

分析

ABP vNext 單元測試相關的型別最核心的是整合測試基類 AbpIntegratedTest 和 MVC 專用測試基類 AbpAspNetCoreIntegratedTestBase,這兩個基類核心工作就是初始化 IoC 容器並且初始化整個模組系統,只不過後者對 控制器 相關的元件進行了初始化配置,讓開發人員可以針對 控制器 進行單元/整合測試。

從上圖可以看到兩個基類都繼承自 AbpTestBaseWithServiceProvider 基類,在這個基類裡面將 IServiceProvider 作為一個抽象成員。這是因為 MVC 和測試基類的 ServiceProvider 來源不一樣,一個是 ABP vNext 根據 Application 類已註冊 IoC 容器構建的,另一個使用的是 IHost 測試主機內的 ServiceProvider

單元測試執行本質上就是將測試類進行例項化,然後呼叫對應的單元測試方法,所以測試基類的初始化動作都是放在對應的無參建構函式。

雖然 Volo.Abp.UnitTest 也是單獨的一個專案,它的 AbpTestBaseModule 是沒有任何動作,僅僅是為了同其他專案保持一致,內部是沒有任何程式碼。

using Volo.Abp.Modularity;

namespace Volo.Abp
{
    public class AbpTestBaseModule : AbpModule
    {

    }
}

整合測試基類

一般來說,我們會直接從 AbpIntegratedTest 繼承並實現我們需要的單元測試基類,包括 ABP vNext 官方的預設模版也是這樣。整合測試基類的核心程式碼很簡單,就是在無參建構函式的內部進行初始化動作,且在過程中按順序執行兩個生命週期方法。

簡易流程圖:

public abstract class AbpIntegratedTest<TStartupModule> : AbpTestBaseWithServiceProvider, IDisposable
    where TStartupModule : IAbpModule
{
    protected IAbpApplication Application { get; }

    protected override IServiceProvider ServiceProvider => Application.ServiceProvider;

    protected IServiceProvider RootServiceProvider { get; }

    protected IServiceScope TestServiceScope { get; }

    protected AbpIntegratedTest()
    {
        var services = CreateServiceCollection();

        BeforeAddApplication(services);

        var application = services.AddApplication<TStartupModule>(SetAbpApplicationCreationOptions);
        Application = application;

        AfterAddApplication(services);

      	// 根據已有 IServiceCollection 建立 IoC 容器。
        RootServiceProvider = CreateServiceProvider(services);
        TestServiceScope = RootServiceProvider.CreateScope();
				
      	// 使用子容器對 ABP 模組系統進行初始化。
        application.Initialize(TestServiceScope.ServiceProvider);
    }

    // ... 其他程式碼。
}

上述程式碼可以看到預設的測試基類並沒有直接使用 RootServiceProvider,而是建立了一個子容器給 ABP vNext 使用,這主要是為了後續可以對容器進行銷燬操作,具體可一看下面的 Dispose() 方法。

public virtual void Dispose()
{
    Application.Shutdown();
    TestServiceScope.Dispose();
    Application.Dispose();
}

總的來說,測試基類就是構建了一個 IAbpApplication 物件,根據傳入的 TStartupModule 模組進入拓撲排序過程,依次執行各個模組的生命週期配置。

MVC 測試基類

針對我們的 Http Api 層,如果需要對 Controller 進行測試的話,就需要從 MVC 測試基類繼承編寫單元/整合測試,各個型別的關係如下。

classDiagram class AbpTestBaseWithServiceProvider { #ServiceProvider #GetService() ~T~ #GetRequiredService() ~T~ } class AbpIntegratedTest~TStartupModule~{ #BeforeAddApplication(IServiceCollection service) #SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options) #AfterAddApplication(IServiceCollection services) #CreateServiceProvider(IServiceCollection service) +Dispose() } class AbpAspNetCoreIntegratedTestBase~TStartup~{ #TestServer Server #HttpClient Client -IHost host #CreateHostBuilder() IHostBuilder #ConfigureServices(HostBuilderContext context, IServiceCollection services) #GetUrl_OfType_TController() string #GetUrl_OfType_TController(string actionName) string #GetUrl_OfType_TController(string actionName, object queryStringParamsAsAnonymousObject) string +Dispose() } class IDispose{ <<interface>> IDispose } AbpIntegratedTest~TStartupModule~ --|> AbpTestBaseWithServiceProvider AbpIntegratedTest~TStartupModule~ ..|> IDispose AbpAspNetCoreIntegratedTestBase~TStartup~ --|> AbpTestBaseWithServiceProvider AbpAspNetCoreIntegratedTestBase~TStartup~ ..|> IDispose

針對 AspNetCore 來說,ABP 建立了一個新的 Host 主機,在每次執行測試的時候會啟動一個新的 Web 伺服器。(並不會建立真實服務,不存在埠占用問題)

在基類當中,ABP 定義了兩個屬性 ServerClient,它們都是 Mock 了對應的介面,方便後續的單元測試,這裡的 ITestServerAccessor 介面是用於 Mock AspNetCoreTestDynamicProxyHttpClientFactory 介面所需要的。

AspNetCoreTestDynamicProxyHttpClientFactory 介面是 ABP 底層進行動態代理所使用的,在請求遠端服務的時候會呼叫這個介面建立 HttpClient 物件。

protected AbpAspNetCoreIntegratedTestBase()
{
    var builder = CreateHostBuilder();

    _host = builder.Build();
    _host.Start();

    Server = _host.GetTestServer();
    Client = _host.GetTestClient();

    ServiceProvider = Server.Services;

    ServiceProvider.GetRequiredService<ITestServerAccessor>().Server = Server;
}

從 UML 類圖當中,可以看到基類定義了幾個 GetUrl() 方法,這幾個方法是根據 Controller 獲取對應的請求路徑。

這裡我以一個 SampleController 控制器為例,它提供了一個 Index 方法,返回了一個頁面內容。針對它來說,我們編寫整合測試是這樣操作的。

public class SimpleController : AbpController
{
    public ActionResult Index()
    {
        return Content("Index-Result");
    }
}

整合測試

public class SimpleController_Tests : AbpAspNetCoreIntegratedTestBase<Startup>
{
    protected virtual async Task<HttpResponseMessage> GetResponseAsync(string url, HttpStatusCode expectedStatusCode = HttpStatusCode.OK)
    {
        using (var requestMessage = new HttpRequestMessage(HttpMethod.Get, url))
        {
            requestMessage.Headers.Add("Accept-Language", CultureInfo.CurrentUICulture.Name);
            var response = await Client.SendAsync(requestMessage);
            response.StatusCode.ShouldBe(expectedStatusCode);
            return response;
        }
    }

    protected virtual async Task<string> GetResponseAsStringAsync(string url, HttpStatusCode expectedStatusCode = HttpStatusCode.OK)
    {
        using (var response = await GetResponseAsync(url, expectedStatusCode))
        {
            return await response.Content.ReadAsStringAsync();
        }
    }

    [Fact]
    public async Task ActionResult_ContentResult()
    {
        var result = await GetResponseAsStringAsync(
            GetUrl<SimpleController>(nameof(SimpleController.Index))
        );

        result.ShouldBe("Index-Result");
    }
}

EF Core 的整合

在執行單元測試過程中,我們難免會對資料庫進行操作。這個時候不可能連線真實資料庫,就需要我們在測試基類當中進行一些初始化動作,將底層的資料庫連結改為 SQLite 的記憶體模式。

public class SampleEntityFrameworkCoreTestModule : AbpModule
{
    private SqliteConnection _sqliteConnection;

    public override void ConfigureServices(ServiceConfigurationContext context)
    {
        ConfigureInMemorySqlite(context.Services);
    }

    private void ConfigureInMemorySqlite(IServiceCollection services)
    {
        // 建立連結並執行遷移。
        _sqliteConnection = CreateDatabaseAndGetConnection();

        // 使用 SQLite 作為 EF Provider。
        services.Configure<AbpDbContextOptions>(options =>
        {
            options.Configure(context =>
            {
                context.DbContextOptions.UseSqlite(_sqliteConnection);
            });
        });
    }

    public override void OnApplicationShutdown(ApplicationShutdownContext context)
    {
        _sqliteConnection.Dispose();
    }

    private static SqliteConnection CreateDatabaseAndGetConnection()
    {
        // 使用 SQLite 的記憶體模式連結字串。
        var connection = new SqliteConnection("Data Source=:memory:");
        connection.Open();

        var options = new DbContextOptionsBuilder<SampleMigrationsDbContext>()
            .UseSqlite(connection)
            .Options;

        // 執行遷移,構建表結構。
        using (var context = new SampleMigrationsDbContext(options))
        {
            context.GetService<IRelationalDatabaseCreator>().CreateTables();
        }

        return connection;
    }
}

總結

ABP 的測試更偏向於整合測試,因為各個功能都依賴於模組,所以在執行單元測試的時候會執行更長的時間。日常開發過程當中,我們更多地還是針對應用層進行測試就可以了,粒度更細的話也可以針對倉儲層領域層API 層 編寫測試即可。

為了保證專案質量,在開發完成之後編寫單元/整合測試是每個開發人員應做的工作。編寫單元/整合測試,雖然不能 100% 避免 BUG,但可以保證每次進行業務修改之後介面的正確性。

相關文章