在开发系统时,如果测试情境的输出会有许多资料类别并且要验证很多栏位时,会利用 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;
}
}
}
单元测试
下面是使用直接设定要排除比对属性的方式,以下的测试程式里使用了文件上的设定方式
- Object graph comparison - Awesome Assertions > Selecting Members
[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、甚至全域设定,整体测试程式码的可读性、维护性都能大大提升。
以上
纯粹是在写兴趣的,用写程式、写文章来抒解工作压力