在開發系統時,如果測試情境的輸出會有許多資料類別並且要驗證很多欄位時,會利用 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 設定,那麼就不需要每個測試方法都要做設定了。
- https://awesomeassertions.org/extensibility/#net-5
- https://github.com/AwesomeAssertions/AwesomeAssertions/blob/main/Src/FluentAssertions/Extensibility/AssertionEngineInitializerAttribute.cs
// 要另外建立 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、甚至全域設定,整體測試程式碼的可讀性、維護性都能大大提升。
以上
純粹是在寫興趣的,用寫程式、寫文章來抒解工作壓力