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;
        }
    }
}

 

单元测试

下面是使用直接设定要排除比对属性的方式,以下的测试程式里使用了文件上的设定方式

  • 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、甚至全域设定,整体测试程式码的可读性、维护性都能大大提升。

以上

纯粹是在写兴趣的,用写程式、写文章来抒解工作压力

关于作者: 网站小编

码农网专注IT技术教程资源分享平台,学习资源下载网站,58码农网包含计算机技术、网站程序源码下载、编程技术论坛、互联网资源下载等产品服务,提供原创、优质、完整内容的专业码农交流分享平台。

热门文章

5 点赞(415) 阅读(67)