AwesomeAssertions / FluentAssertions 快速排除更新欄位

在開發系統時,如果測試情境的輸出會有許多資料類別並且要驗證很多欄位時,會利用 object graph 比對整個結果是否和預期一致。

現在我的專案裡有個 CustomerRootData 類別,裡頭屬性為十幾個不同型別的子集合,每個類別都有 UpdateTime、UpdateUser 這兩個欄位,但這兩個欄位是在程式執行當下才寫進去的,不屬於測試驗證的重點,在寫測試的時候常常陷入重複設定的地獄。

於是在 AwesomeAssertions / AwesomeAssertions Object Graphs 的架構下,想辦法在做比對時去做到比較方便省事的設定方法,讓我輕鬆地去排除指定的欄位。

在找尋資料、解決方法的同時,也讓我得知不一樣的處理方式,將這些處理方式用文章記錄下來,也提供給大家做個參考。

測試目標

先看看類別,當然這些都是簡化設計過的,主要是都有保留每個類別的 UpdateTime 與 UpdateUser 屬性

/// <summary>
/// CustomerRootData
/// </summary>
public class CustomerRootData
{
    public MetaData Meta { get; set; }

    public ProcessLog Log { get; set; }

    public List<DataRecord> Records { get; set; }

    public List<EventItem> Events { get; set; }

    public List<DetailItem> Details { get; set; }
}

/// <summary>
/// Model 1: Meta 資訊
/// </summary>
public class MetaData
{
    public Guid Id { get; set; }
    public string Description { get; set; }
    public DateTime UpdateTime { get; set; }
    public string UpdateUser { get; set; }
}

/// <summary>
/// Model 2: 處理紀錄
/// </summary>
public class ProcessLog
{
    public int Sequence { get; set; }
    public string Message { get; set; }
    public DateTime UpdateTime { get; set; }
    public string UpdateUser { get; set; }
}

/// <summary>
/// Model 3: 資料記錄
/// </summary>
public class DataRecord
{
    public int RecordId { get; set; }
    public decimal Value { get; set; }
    public DateTime UpdateTime { get; set; }
    public string UpdateUser { get; set; }
}

/// <summary>
/// Model 4: 事件項目
/// </summary>
public class EventItem
{
    public string Code { get; set; }
    public string Description { get; set; }
    public DateTime UpdateTime { get; set; }
    public string UpdateUser { get; set; }
}

/// <summary>
/// Model 5: 明細項目
/// </summary>
public class DetailItem
{
    public int DetailId { get; set; }
    public int Count { get; set; }
    public DateTime UpdateTime { get; set; }
    public string UpdateUser { get; set; }
}

接著是服務類別,不過這也只是個示意程式碼,方法裡的邏輯不是重點

  • CustomerRootData:聚合了五個子集合(MetaData、ProcessLog、DataRecord、EventItem、DetailItem)。
  • 所有類別都有 UpdateTime/UpdateUser,用來驗證排除邏輯或更新邏輯。
  • MetadataUpdater.Apply(...):就是測試的目標,專門把所有的更新欄位一次性填好。
/// <summary>
/// class MetadataUpdater
/// </summary>
public class MetadataUpdater
{
    /// <summary>
    /// The time provider
    /// </summary>
    private readonly TimeProvider _timeProvider;

    /// <summary>
    /// Initializes a new instance of the <see cref="MetadataUpdater"/> class
    /// </summary>
    /// <param name="timeProvider">The timeProvider</param>
    public MetadataUpdater(TimeProvider timeProvider)
    {
        this._timeProvider = timeProvider;
    }

    /// <summary>
    /// 為 CustomerRootData 及其所有子物件,填入當前時間與使用者
    /// </summary>
    public void Apply(CustomerRootData data, string user)
    {
        var now = this._timeProvider.GetUtcNow().DateTime;

        data.Meta.UpdateTime = now;
        data.Meta.UpdateUser = user;

        data.Log.UpdateTime = now;
        data.Log.UpdateUser = user;

        foreach (var r in data.Records)
        {
            r.UpdateTime = now;
            r.UpdateUser = user;
        }

        foreach (var e in data.Events)
        {
            e.UpdateTime = now;
            e.UpdateUser = user;
        }

        foreach (var d in data.Details)
        {
            d.UpdateTime = now;
            d.UpdateUser = user;
        }
    }
}

 

單元測試

下面是使用直接設定要排除比對屬性的方式,以下的測試程式裡使用了文件上的設定方式

[Theory]
[AutoDataWithCustomization]
public void Apply_使用一般的排除設定方式(
    [Frozen(Matching.DirectBaseType)] FakeTimeProvider fakeTimeProvider,
    IFixture fixture,
    MetadataUpdater sut)
{
    // arrange
    var customerRootData = fixture.Create<CustomerRootData>();

    var assertData = Utilities.GetAssertData(customerRootData);

    const string user = "tester";

    var currentTime = new DateTime(2025, 4, 1, 0, 0, 0, DateTimeKind.Local);
    fakeTimeProvider.SetLocalTimeZone(TimeZoneInfo.Local);
    fakeTimeProvider.SetUtcNow(TimeZoneInfo.ConvertTimeToUtc(currentTime));

    // act
    sut.Apply(customerRootData, user);

    // asset
    customerRootData.Meta.Should().BeEquivalentTo(
        assertData.Meta,
        options => options.Excluding(ctx => new { ctx.UpdateUser, ctx.UpdateTime }));

    customerRootData.Log.Should().BeEquivalentTo(
        assertData.Log,
        options => options.Excluding(ctx => ctx.Path == "UpdateUser")
                          .Excluding(ctx => ctx.Path == "UpdateTime"));

    customerRootData.Records.Should().BeEquivalentTo(
        assertData.Records,
        options => options.Excluding(ctx => new { ctx.UpdateUser, ctx.UpdateTime }));

    customerRootData.Events.Should().BeEquivalentTo(
        assertData.Events,
        options => options.Excluding(ctx => ctx.Path.EndsWith("UpdateUser"))
                          .Excluding(ctx => ctx.Path.EndsWith("UpdateTime")));

    customerRootData.Details.Should().BeEquivalentTo(
        assertData.Details,
        options => options.Excluding(ctx => Regex.IsMatch(ctx.Path, "UpdateUser$"))
                          .Excluding(ctx => Regex.IsMatch(ctx.Path, "UpdateTime$")));
}

如果覺得沒有必要將每種類別的資料都個別去做驗證,下面的測試程式就是在一次的驗證比對方式。

第一種還是會對各個類別資料去做排除設定,而第二種就是直接排除所有的 UpdateUser 與 UpdateTime 屬性

[Theory]
[AutoDataWithCustomization]
public void Apply_一個比對裡去排除設定方式(
	[Frozen(Matching.DirectBaseType)] FakeTimeProvider fakeTimeProvider,
	IFixture fixture,
	MetadataUpdater sut)
{
	// arrange
	var customerRootData = fixture.Create<CustomerRootData>();

	var assertData = Utilities.GetAssertData(customerRootData);

	const string user = "tester";

	var currentTime = new DateTime(2025, 4, 1, 0, 0, 0, DateTimeKind.Local);
	fakeTimeProvider.SetLocalTimeZone(TimeZoneInfo.Local);
	fakeTimeProvider.SetUtcNow(TimeZoneInfo.ConvertTimeToUtc(currentTime));

	// act
	sut.Apply(customerRootData, user);

	// asset
	// 直接比對 customerRootData 與 assertData,然後分成非集合與集合使用不同的排除設定方式
	customerRootData.Should().BeEquivalentTo(
		assertData,
		options => options
				   // Meta 與 Log 不是集合,使用 Path 排除
				   .Excluding(ctx => ctx.Path == "Meta.UpdateUser")
				   .Excluding(ctx => ctx.Path == "Meta.UpdateTime")
				   .Excluding(ctx => ctx.Path == "Log.UpdateUser")
				   .Excluding(ctx => ctx.Path == "Log.UpdateTime")

				   // 針對 Records 集合
				   .For(r => r.Records).Exclude(e => new { e.UpdateUser, e.UpdateTime })

				   // 針對 Events 集合
				   .For(r => r.Events).Exclude(e => new { e.UpdateUser, e.UpdateTime })

				   // 針對 Details 集合
				   .For(r => r.Details).Exclude(e => new { e.UpdateUser, e.UpdateTime })
	);

	// 直接比對 customerRootData 與 assertData,然後設定排除所有類別的 UpdateUser, UpdateTime 
	customerRootData.Should().BeEquivalentTo(
		assertData,
		options => options.Excluding(ctx => ctx.Path.EndsWith("UpdateUser"))
						  .Excluding(ctx => ctx.Path.EndsWith("UpdateTime"))
	);
}    

可以看到使用Path.EndWith的方式是最為直接,所有 nested hierarchy 都能自動過濾,而且不用理會是哪個類別,只看屬性名稱。

 

建立自定義的靜態擴充方法

因為測試方法不會只有一個,而是會有相當多個。所以乾脆寫個靜態擴充方法,針對 UpdateUser 與 UpdateTime 這兩個屬性去做排除。

public static class EquivalencyOptionsExtensions
{
    public static EquivalencyOptions<T> ExcludeUpdateMetadata<T>(
        this EquivalencyOptions<T> options)
    {
        return options
               .Excluding(ctx => ctx.Path.EndsWith("UpdateTime"))
               .Excluding(ctx => ctx.Path.EndsWith("UpdateUser"));
    }
}

實際使用的情境

[Theory]
[AutoDataWithCustomization]
public void Apply_使用自定義的靜態擴充方法去排除設定方式(
	[Frozen(Matching.DirectBaseType)] FakeTimeProvider fakeTimeProvider,
	IFixture fixture,
	MetadataUpdater sut)
{
	// arrange
	var customerRootData = fixture.Create<CustomerRootData>();

	var assertData = Utilities.GetAssertData(customerRootData);

	const string user = "tester";

	var currentTime = new DateTime(2025, 4, 1, 0, 0, 0, DateTimeKind.Local);
	fakeTimeProvider.SetLocalTimeZone(TimeZoneInfo.Local);
	fakeTimeProvider.SetUtcNow(TimeZoneInfo.ConvertTimeToUtc(currentTime));

	// act
	sut.Apply(customerRootData, user);

	// asset
	// 直接比對 customerRootData 與 assertData,然後設定排除使用靜態擴充方法 
	customerRootData.Should().BeEquivalentTo(assertData, options => options.ExcludeUpdateMetadata());
	
	// 也可以一個一個去設定使用
	customerRootData.Meta.Should().BeEquivalentTo(assertData.Meta, options => options.ExcludeUpdateMetadata());
	customerRootData.Log.Should().BeEquivalentTo(assertData.Log, options => options.ExcludeUpdateMetadata());
	customerRootData.Records.Should().BeEquivalentTo(assertData.Records, options => options.ExcludeUpdateMetadata());
	customerRootData.Events.Should().BeEquivalentTo(assertData.Events, options => options.ExcludeUpdateMetadata());
	customerRootData.Details.Should().BeEquivalentTo(assertData.Details, options => options.ExcludeUpdateMetadata());
}

這個 ExcludeUpdateMetadata 靜態擴充方法是已經將屬性名稱固定在方法裡,如果你想要讓開發者可以自行輸入多個屬性名稱,可以參考以下的實作:

/// <summary>
/// 排除所有路徑結尾符合任一指定屬性名稱的欄位
/// </summary>
/// <param name="options">options</param>
/// <param name="propertyNames">屬性名稱</param>
/// <typeparam name="T"></typeparam>
/// <returns></returns>
public static EquivalencyOptions<T> ExcludingProperties<T>(
    this EquivalencyOptions<T> options,
    params string[] propertyNames)
{
    // 只要 Path 以任何一個 name 結尾,就排除
    return options.Excluding(ctx => propertyNames.Any(name => ctx.Path.EndsWith(name)));
}

實際使用的情境

[Theory]
[AutoDataWithCustomization]
public void Apply_使用可以指定屬性名稱的靜態擴充方法去排除設定方式(
    [Frozen(Matching.DirectBaseType)] FakeTimeProvider fakeTimeProvider,
    IFixture fixture,
    MetadataUpdater sut)
{
    // arrange
    var customerRootData = fixture.Create<CustomerRootData>();

    var assertData = Utilities.GetAssertData(customerRootData);

    const string user = "tester";

    var currentTime = new DateTime(2025, 4, 1, 0, 0, 0, DateTimeKind.Local);
    fakeTimeProvider.SetLocalTimeZone(TimeZoneInfo.Local);
    fakeTimeProvider.SetUtcNow(TimeZoneInfo.ConvertTimeToUtc(currentTime));

    // act
    sut.Apply(customerRootData, user);

    // asset
    // 直接比對 customerRootData 與 assertData,然後設定排除使用靜態擴充方法 
    customerRootData.Should().BeEquivalentTo(assertData, options => options.ExcludeUpdateMetadata());

    // 也可以一個一個去設定使用
    customerRootData.Meta.Should().BeEquivalentTo(
        assertData.Meta, options => options.ExcludingProperties("UpdateUser", "UpdateTime"));
    
    customerRootData.Log.Should().BeEquivalentTo(
        assertData.Log, options => options.ExcludingProperties("UpdateUser", "UpdateTime"));
    
    customerRootData.Records.Should().BeEquivalentTo(
        assertData.Records, options => options.ExcludingProperties("UpdateUser", "UpdateTime"));
    
    customerRootData.Events.Should().BeEquivalentTo(
        assertData.Events, options => options.ExcludingProperties("UpdateUser", "UpdateTime"));
    
    customerRootData.Details.Should().BeEquivalentTo(
        assertData.Details, options => options.ExcludingProperties("UpdateUser", "UpdateTime"));
}

 

使用 AssertionEngineInitializer  進行全域的預設設定

如果覺得在同一個測試類別裡的每個測試方法都要重複去做 options 的 Exclude 設定是有點麻煩,那麼可以將排除條件做 default 設定,那麼就不需要每個測試方法都要做設定了。

// 要另外建立 AssertionInitializer
.cs 檔案

using FluentAssertions;
using FluentAssertions.Extensibility;

[assembly: AssertionEngineInitializer(typeof(AssertionInitializer), nameof(AssertionInitializer.Initialize))]

internal static class AssertionInitializer
{
    public static void Initialize()
    {
        AssertionConfiguration.Current
                              .Equivalency
                              .Modify(opts => opts
                                              .Excluding(ctx => ctx.Path.EndsWith("UpdateTime"))
                                              .Excluding(ctx => ctx.Path.EndsWith("UpdateUser"))
                              );
    }
}

做了以上的設定後,測試方法裡的驗證比對就不必再去做 options 的 Exclude 設定。

[AssertionEngineInitializer] 這種註冊方式會在整個測試 assembly 一次性執行,而不管你有多少個測試類別和方法。

只要這個 attribute 標在同一個專案(同一個 assembly)裡,該 assembly 的所有 .BeEquivalentTo(以及底層會觸發 equivalency engine 的  assertions)都會自動帶入你在初始化裡呼叫的那段 .Modify(...) 設定。

// 所有的 assertions 都會使用設定為 default 的 Excluding 排除欄位

[Theory]
[AutoDataWithCustomization]
public void Apply_使用AssertionInitializer將排除設定為default(
    [Frozen(Matching.DirectBaseType)] FakeTimeProvider fakeTimeProvider,
    IFixture fixture,
    MetadataUpdater sut)
{
    // arrange
    var customerRootData = fixture.Create<CustomerRootData>();

    var assertData = Utilities.GetAssertData(customerRootData);

    const string user = "tester";

    var currentTime = new DateTime(2025, 4, 1, 0, 0, 0, DateTimeKind.Local);
    fakeTimeProvider.SetLocalTimeZone(TimeZoneInfo.Local);
    fakeTimeProvider.SetUtcNow(TimeZoneInfo.ConvertTimeToUtc(currentTime));

    // act
    sut.Apply(customerRootData, user);

    // asset
    customerRootData.Meta.Should().BeEquivalentTo(assertData.Meta);
    customerRootData.Log.Should().BeEquivalentTo(assertData.Log);
    customerRootData.Records.Should().BeEquivalentTo(assertData.Records);
    customerRootData.Events.Should().BeEquivalentTo(assertData.Events);
    customerRootData.Details.Should().BeEquivalentTo(assertData.Details);

    customerRootData.Should().BeEquivalentTo(assertData);
}

當然你還是可以依據需求在 assertion 裡去設定 options 的內容,就會重新指定而覆寫預設行為。

 

最後

透過 AwesomeAssertions / FluentAssertions 的 object‑graph 比對功能,我們不必再手動對每個集合、逐一地呼叫 .Excluding(e => …),只要靠 ctx.Path 或 ctx.DeclaringType 等篩選條件,就能「一次性」排除所有 UpdateTime、UpdateUser。若再搭配 extension method、甚至全域設定,整體測試程式碼的可讀性、維護性都能大大提升。

以上

純粹是在寫興趣的,用寫程式、寫文章來抒解工作壓力