當 AutoFixtue 的 AutoData 與 Microsoft.Bcl.TimeProvider 碰在一起時會如何?

前面分享的兩篇文章,分別介紹了 Microsoft.Bcl.TimeProvider 和 AutoFixture 的 AutoData

另外在寫測試時,可以使用 FakeTimeProvider 去做抽換,就可以設定時區、時間來完成測試情境的執行。

那麼是不是也可以用 AutoData 的特性,讓我們在寫測試的時候可以省下建立 FakeTimeProvider 的步驟呢?當然可以,不過需要先瞭解該怎麼做、可以怎麼做以及應該如何做。

建立 FakeTimeProviderCustomization 類別

先建立這個類別,等一下就可以放進 AutoDataWithCustomization 裡。之後測試案例在建立 SUT (System Under Test)  測試目標物件的時後,就可以自動建立 FakeTimeProvider 並注入到 SUT 裡,並之後在測使方法裡也能夠在方法簽章裡透過 Frozen 取得。

public class FakeTimeProviderCustomization : ICustomization
{
    /// <summary>
    /// Customizes the fixture
    /// </summary>
    /// <param name="fixture">The fixture</param>
    public void Customize(IFixture fixture)
    {
        fixture.Register(() => new FakeTimeProvider());
    }
}

 

調整 AutoDataWithCustomizationAttribute 類別

在 AutoDataWithCustomizationAttribute 的 CreateFixture 方法裡增加 Customize 設定已加入 FakeTimeProviderCustomization

public class AutoDataWithCustomizationAttribute : AutoDataAttribute
{
    /// <summary>
    /// Initializes a new instance of the <see cref="AutoDataWithCustomizationAttribute"/> class
    /// </summary>
    public AutoDataWithCustomizationAttribute() : base(CreateFixture)
    {
    }

    private static IFixture CreateFixture()
    {
        var fixture = new Fixture().Customize(new AutoNSubstituteCustomization())
                                   .Customize(new MapsterMapperCustomization())
                                   .Customize(new FakeTimeProviderCustomization());

        return fixture;
    }
}

 

調整測試案例

先來看看第一個測試案例原本的樣子

    [Fact]
    public void IsTradeNow_取得的目前時間為假日_應回傳false()
    {
        // arrange
        var currentTime = new DateTime(2024, 9, 28, 0, 0, 0, DateTimeKind.Local);
        this._fakeTimeProvider.SetLocalTimeZone(TimeZoneInfo.Local);
        this._fakeTimeProvider.SetUtcNow(TimeZoneInfo.ConvertTimeToUtc(currentTime));
        
        this._holidayRepository.GetHolidays(Arg.Any<int>(), Arg.Any<int>()).Returns(Holidays);
        
        // act
        var actual = this._sut.IsTradeNow();
        
        // assert
        actual.Should().BeFalse();
    }

將這個測試方法從使用 Fact 改為使用 Theory 與 AutoDataWithCustomization,並修改方法簽章,透過 Frozen 取得 stub 以及 SUT

    [Theory]
    [AutoDataWithCustomization]
    public void IsTradeNow_取得的目前時間為假日_應回傳false(
        [Frozen] FakeTimeProvider fakeTimeProvider,
        [Frozen] IHolidayRepository holidayRepository,
        TradeService sut)
    {
        // arrange
        var currentTime = new DateTime(2024, 9, 28, 0, 0, 0, DateTimeKind.Local);
        fakeTimeProvider.SetLocalTimeZone(TimeZoneInfo.Local);
        fakeTimeProvider.SetUtcNow(TimeZoneInfo.ConvertTimeToUtc(currentTime));
        
        holidayRepository.GetHolidays(Arg.Any<int>(), Arg.Any<int>()).Returns(Holidays);
        
        // act
        var actual = sut.IsTradeNow();
        
        // assert
        actual.Should().BeFalse();
    }

看起來應該沒有什麼問題,我們就執行測試來驗證結果吧

實際執行後卻出現了狀況

由上圖裡可以看到 SUT 裡所使用的 TimeProvider 並不是我們所預期應該要使用 FakeTimeProvider,而是直接使用預設的 TimeProvider.SystemTimeProvider…

這也說明了我們所建立的 FakeTimeProviderCustomization  與修改 AutoDataWithCustomization,然後又在測試方法簽章裡使用 Frozen  取得 FakeTimeProvider  並在測試程式裡做設定,這一切都不管用。

 

認識 AutoFixture 的 Matching 列舉

FrozenAttribute 類別的建構式裡預設是使用了 Matching.ExactType,就是說當你在測試方法簽章使用 Frozen 並指定什麼型別後,就會給你什麼,然後 SUT 所相依注入又剛好是 Frozen 所凍結的確切型別就會拿來使用。

https://github.com/AutoFixture/AutoFixture/blob/master/Src/AutoFixture.xUnit2/FrozenAttribute.cs

回到我們所修改的測試方法,我們所指定凍結的是 FakeTimeProvider 型別的物件,但是 TradeService 建構式所要注入的是 TimeProvder 型別,而 FakeTimeProvider  雖然是繼承自 TimeProvider,但以 AutoData 的設定裡就是不會將 TimeProvider 去用 FakeTimeProvider  做替代。

而 FrozenAttribute 還有提供另外的建構式,是可以讓我們自己去指定 Matching

https://github.com/AutoFixture/AutoFixture/blob/master/Src/AutoFixture.xUnit2/Matching.cs

Matching   列舉的各個項目是代表什麼意思?以下是 ChatGPT  的說明

AutoFixture.Xunit2 中的 Matching 列舉允許開發者通過 FrozenAttribute 指定精確的凍結範圍。當我們希望凍結特定的類型實例時,可能需要進一步指定其凍結的匹配條件,如基於類型、基於接口或基於基類等。Matching 列舉的每個選項都對應特定的情境,根據它們,我們可以控制 FrozenAttribute 在測試中的具體行為。

以下是 Matching 列舉中各個選項的說明以及它們的典型使用情境:

1. ExactType

  • 說明: 這個選項凍結類型與參數完全相同的實例。如果測試中的依賴項與 FrozenAttribute 修飾的參數具有相同的類型,則會使用已凍結的實例。
  • 典型使用情境: 當你希望只針對具體類型進行凍結,並且不希望凍結其他基類或接口的情況下使用。

2. DirectBaseType 

  • 說明: 凍結與參數類型的直接基類相同的依賴項。如果測試中有一個依賴需要的類型是 FrozenAttribute 修飾參數的基類,則會使用凍結實例。
  • 典型使用情境: 當你想測試繼承結構中的基類行為,並希望凍結基類的實例時使用。

3. ImplementedInterfaces

  • 說明: 凍結類型實現的接口。當測試中的依賴需要的類型是某個接口,而這個接口由 FrozenAttribute 修飾的類型實現時,會使用凍結的實例。
  • 典型使用情境: 當你測試依賴於接口的系統,並希望凍結接口的實現類,從而在整個測試中使用相同的實例。

4. ParameterName

  • 說明: 凍結與構造函數或方法參數名稱相匹配的依賴項。當測試中的依賴與指定的參數名稱匹配時,會使用凍結的實例。
  • 典型使用情境: 當你希望針對某個特定的參數進行凍結,而不希望凍結同類型的其他參數時,可以使用這個選項。

5. PropertyName

  • 說明: 凍結與類型中屬性名稱相匹配的依賴項。當屬性名稱與測試中的依賴項匹配時,會使用凍結的實例。
  • 典型使用情境: 當你希望只針對某個特定的屬性凍結依賴,而不凍結其他相同類型的屬性時使用。

6. FieldName

  • 說明: 凍結與類型中欄位名稱相匹配的依賴項。當欄位名稱與測試中的依賴項匹配時,會使用凍結的實例。
  • 典型使用情境: 當你有多個相同類型的欄位,但只希望凍結其中一個具體名稱的欄位時使用。

7. MemberName

  • 說明: 凍結與成員名稱(包括參數名、屬性名和欄位名)相匹配的依賴項。這是 ParameterName、PropertyName 和 FieldName 的組合,會對這三種成員名稱進行匹配,並凍結符合名稱的依賴。
  • 典型使用情境: 當你希望對所有參數、屬性和欄位名稱進行統一凍結時使用。

總結

  • ExactType: 凍結與參數類型完全匹配的依賴項。
  • DirectBaseType: 凍結與參數類型的直接基類相匹配的依賴項。
  • ImplementedInterfaces: 凍結與參數類型實現的接口相匹配的依賴項。
  • ParameterName: 凍結與構造函數或方法參數名稱相匹配的依賴項。
  • PropertyName: 凍結與屬性名稱相匹配的依賴項。
  • FieldName: 凍結與欄位名稱相匹配的依賴項。
  • MemberName: 凍結與參數名、屬性名或欄位名相匹配的依賴項(ParameterName、PropertyName 和 FieldName 的組合)。

 

修改測試案例

由上面的 Matching  列舉的說明裡,我們得知可以使用 Matching.DirectBaseType 這個項目,因為 FakeTimeProvider 是繼承自 TimeProvider

於是修改了測試方法的程式

    [Theory]
    [AutoDataWithCustomization]
    public void IsTradeNow_取得的目前時間為假日_應回傳false(
        [Frozen(Matching.DirectBaseType)] FakeTimeProvider fakeTimeProvider,
        [Frozen] IHolidayRepository holidayRepository,
        TradeService sut)
    {
        // arrange
        var currentTime = new DateTime(2024, 9, 28, 0, 0, 0, DateTimeKind.Local);
        fakeTimeProvider.SetLocalTimeZone(TimeZoneInfo.Local);
        fakeTimeProvider.SetUtcNow(TimeZoneInfo.ConvertTimeToUtc(currentTime));
        
        holidayRepository.GetHolidays(Arg.Any<int>(), Arg.Any<int>()).Returns(Holidays);
        
        // act
        var actual = sut.IsTradeNow();
        
        // assert
        actual.Should().BeFalse();
    }

觀察測試執行時的狀態

上圖可以看到 SUT 所注入使用的是 FakeTimeProvider

然後進入到實作裡再做確認,的確是注入使用 FakeTimeProvider

於是就這樣解決啦!

 

其他的解決方式

在尋找解決方法的過程時看到了某種方法,解法不見得比上面透過 Frozen 去指定 Matching.DirectBaseType 這樣簡單漂亮,但也讓我多認識另外的解法。

https://stackoverflow.com/questions/49576741/override-autofixture-customization-setup

上面在 stackoverflow 所找到的『Override Autofixture customization setup』為參考來源。

建立一個 AutoDataWithCustomizationType 類別,記得要繼承 AutoDataAttribute 類別

首先建立一個 TradeServiceCustomization 類別,需要繼承實作 ICustomization 介面

調整測試案例

這個解法也是可行,但是與直接在 FrozenAttribute 裡指定使用 Matching.DirectBaseType 的方式來比較的話,這個 with CustomizartonType 的方式就顯得繁瑣許多,不過至少比我以前在「單元測試使用 AutoFixture.AutoNSubstitute」這篇文章裡的土砲解法還要好很多了。

所以藉由我所提出的案例,讓大家多認識了 AutoFixture 的 Matching 列舉,以及在 Frozen 搭配 Matching 列舉的實際使用案例。