歡迎光臨
每天分享高質量文章

【ASP.NET Core 沉思錄】CreateWebHostBuilder 是一個 Convension

失蹤人口回歸。去年六月份開始,我開始翻譯一千多頁的《CSharp 7 in a Nutshell》到現在為止終於告一段落。我又回歸了表世界。從這次開始我希望展開一個全新的主題。我叫它 ASP.NET Core 沉思錄(多麼高大上的名字,自我陶醉~)。今天是第一個主題。CreateWebHostBuilder 是一個 Convension。

太長不讀

對於 WebApplicationFactory 而言,預設情況下會採取如下假定:

  • Startup 所在的程式集應當就是應用程式入口(Main)所在的程式集;(官方工程模板的坑)

  • 應用程式入口所在的類(Program),裡面會包含整個建立和配置 IWebHostBuilder 的過程;

  • 建立和配置 IWebHostBuilder 的過程是由應用程式入口所在類的 CreateWebHostBuilder 方法完成的。

在滿足上述假定的情況下,無需額外程式碼,Web 應用的執行和測試將共享相同的邏輯。如若不然,則測試失敗。如果無法滿足上述三種條件還可以透過整合 WebApplicationFactory 並重寫 CreateWebHostBuilder 方法來解決。

以上約束僅僅限定於 WebApplicationFactory,若直接在測試中使用 TestServer 則沒有這種限制。

WebApplicationFactoryT 並不是 TStartup,而是應用程式入口所在的程式集中的任意型別。

娓娓道來

如果我們使用 dotnet 命令列建立一個 ASP.NET Core MVC/WebAPI 的工程。那麼它的啟動程式碼大概是這樣的:

public static class Program
{
    public static void Main(string[] args)
    {
        CreateWebHostBuilder(args).Build().Run();
    }

    public static IWebHostBuilder CreateWebHostBuilder(string[] args)
    {
        // Modified a little bit for the sake of illustration
        return new WebHostBuilder()
            .UseKestrel()
            .ConfigureLogging(lb =>
            {
                lb.SetMinimumLevel(LogLevel.Debug).AddConsole();
            })
            .UseStartup();
    }
}

有沒有小夥伴好奇,為什麼需要一個 CreateWebHostBuilder 方法?從直觀上看,它是建立並完成基本的 IWebHostBuilder 配置的方法。這個方法應在測試中進行復用以確保測試和應用程式中的 IWebHostBuilder 配置幾乎相同,例如:

[Fact]
public async Task should_get_response_text()
{
    IWebHostBuilder webHostBuilder = Program.CreateWebHostBuilder(Array.Empty<string>());

    using (var testServer = new TestServer(webHostBuilder))
    using (HttpClient client = testServer.CreateClient())
    {
        HttpResponseMessage response = await client.GetAsync("/message");

        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Assert.Equal("Hello"await response.Content.ReadAsStringAsync());
    }
}

這個測試是可以順利透過的。但是我們認為將 Program.CreateWebHostBuilder 暴露並不是一個好的感覺。我們更希望把這個配置過程分離。例如分離到一個類中:

public class WebHostBuilderConfigurator
{
    public IWebHostBuilder Configure(IWebHostBuilder webHostBuilder)
    {
        return webHostBuilder
            .UseKestrel()
            .ConfigureLogging(lb =>
            {
                lb.SetMinimumLevel(LogLevel.Debug).AddConsole();
            })
            .UseStartup();
    }
}

這樣,Program 僅僅包含整個應用程式的入口,CreateWebHostBuilder 方法就被刪掉了:

public static void Main(string[] args)
{
    var webHostBuilder = new WebHostBuilder();
    new WebHostBuilderConfigurator().Configure(webHostBuilder).Build().Run();
}

測試也就變成了:

[Fact]
public async Task should_get_response_text()
{
    IWebHostBuilder webHostBuilder = new WebHostBuilderConfigurator().Configure(new WebHostBuilder());

    using (var testServer = new TestServer(webHostBuilder))
    using (HttpClient client = testServer.CreateClient())
    {
        HttpResponseMessage response = await client.GetAsync("/message");

        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Assert.Equal("Hello"await response.Content.ReadAsStringAsync());
    }
}

看起來不錯,測試也透過了真是可喜可賀。現在我們準備使用更加完善的 WebApplicationFactory 代替 TestServer 進行測試:

[Fact]
public async Task should_get_response_text_using_web_app_factory()
{
    using (var factory = new WebApplicationFactory().WithWebHostBuilder(
        wb => new WebHostBuilderConfigurator().Configure(wb)))
    using (HttpClient client = factory.CreateClient())
    {
        HttpResponseMessage response = await client.GetAsync("/message");

        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Assert.Equal("Hello"await response.Content.ReadAsStringAsync());
    }
}

看起來不錯,但是發現測試執行的時候卻失敗了。並伴有詭異的異常資訊:

System.InvalidOperationException : No method 'public static IWebHostBuilder CreateWebHostBuilder(string[] args)' found on 'WebApp.Program'. Alternatively, WebApplicationFactory`1 can be extended and 'protected virtual IWebHostBuilder CreateWebHostBuilder()' can be overridden to provide your own IWebHostBuilder instance.

哦,真神奇,它怎麼找到 WebApp.Program 的?我只告訴了它 Startup 而並沒有提供任何 Program 型別的資訊啊?而這個時候,如果我們老老實實的恢復 WebApp.Program 類中的 CreateWebHostBuilder 方法,那麼測試就順利透過了。

這是為什麼呢?原來讓測試環境盡可能的 Match 執行環境是我們共同的心願,WebApplicationFactory 希望能夠自動的幫我們解決這個問題,於是它做瞭如下的假定:

  • Startup 所在的程式集應當就是應用程式入口(Main)所在的程式集;

  • 應用程式入口所在的類(Program),裡面會包含整個建立和配置 IWebHostBuilder 的過程;

  • 建立和配置 IWebHostBuilder 的過程是由應用程式入口所在類的 CreateWebHostBuilder 方法完成的。

只要符合這三個假定,那麼你盡可不費吹灰之力就達到了產品測試配置一致的目的。而如果不符合這個假定將讓測試在預設狀態下執行失敗。具體的程式碼請參考 這裡 和 這裡。從 WebHostFactoryResolver 裡面可以看出,除了 CreateWebHostBuilder 方法之外,BuildWebHost 也是一個 Convension,只不過主要是為了向前相容的目的。

在真實的專案中,很可能是不滿足這三個條件的,那麼怎麼辦呢?還好我們可以透過整合 WebApplicationFactory 並重寫 CreateWebHostBuilder 方法來解決這個問題:

public class MyWebApplicationFactory : WebApplicationFactory<Startup>
{
    protected override IWebHostBuilder CreateWebHostBuilder()
    {
        var webHostBuilder = new WebHostBuilder();
        new WebHostBuilderConfigurator().Configure(webHostBuilder);
        return webHostBuilder;
    }
}

並相應的將測試更改為:

[Fact]
public async Task should_get_response_text_using_web_app_factory()
{
    using (var factory = new MyWebApplicationFactory())
    using (HttpClient client = factory.CreateClient())
    {
        HttpResponseMessage response = await client.GetAsync("/message");

        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Assert.Equal("Hello"await response.Content.ReadAsStringAsync());
    }
}

就可以了。

最後,需要提醒的是 WebApplicationFactoryTTEntryPoint ,是入口所在的程式集的型別。雖然平常大家都喜歡寫 Startup

總結

請飛到文章開頭~ 😀

 

    贊(0)

    分享創造快樂