如何使用 AutoFixture 实现日期限制?

How to implement date restrictions with AutoFixture?

我目前有一个模型 class,其中包含多个属性。简化模型可能如下所示:

public class SomeClass
{
    public DateTime ValidFrom { get; set; }
    public DateTime ExpirationDate { get; set; }
}

现在我正在使用 NUnit 实现一些单元测试,并使用 AutoFixture 创建一些随机数据:

[Test]
public void SomeTest()
{ 
    var fixture = new Fixture();
    var someRandom = fixture.Create<SomeClass>();
}

目前为止效果很好。但是有要求ValidFrom的日期总是在ExpirationDate之前。我必须确保这一点,因为我正在实施一些积极的测试。

那么有没有一种简单的方法可以通过使用 AutoFixture 来实现?我知道我可以创建一个固定日期并添加一个随机日期间隔来解决这个问题,但如果 AutoFixture 可以自己处理这个要求,那就太好了。

我对 AutoFixture 没有太多经验,但我知道我可以通过调用 Build 方法获得 ICustomizationComposer:

var fixture = new Fixture();
var someRandom = fixture.Build<SomeClass>()
    .With(some => /*some magic like some.ValidFrom < some.ExpirationDate here...*/ )
    .Create();

也许这是实现这一目标的正确方法?

在此先感谢您的帮助。

由于 SomeClass 是可变的,这里有一种方法:

[Fact]
public void UsingGeneratorOfDateTime()
{
    var fixture = new Fixture();
    var generator = fixture.Create<Generator<DateTime>>();
    var sut = fixture.Create<SomeClass>();
    var seed = fixture.Create<int>();

    sut.ExpirationDate =
        generator.First().AddYears(seed);
    sut.ValidFrom =
        generator.TakeWhile(dt => dt < sut.ExpirationDate).First();

    Assert.True(sut.ValidFrom < sut.ExpirationDate);
}

FWIW,使用AutoFixture with xUnit.net data theories,上面的测试可以写成:

[Theory, AutoData]
public void UsingGeneratorOfDateTimeDeclaratively(
    Generator<DateTime> generator,
    SomeClass sut,
    int seed)
{
    sut.ExpirationDate =
        generator.First().AddYears(seed);
    sut.ValidFrom =
        generator.TakeWhile(dt => dt < sut.ExpirationDate).First();

    Assert.True(sut.ValidFrom < sut.ExpirationDate);
}

可能很想问 如何使 AutoFixture 适应我的设计?,但通常更有趣的问题可能是:如何让我的设计更健壮?

您可以保留设计和 'fix' AutoFixture,但我认为这不是一个特别好的主意。

在我告诉你如何做到这一点之前,根据你的要求,也许你需要做的就是以下内容。

显式赋值

为什么不像这样简单地为 ExpirationDate 分配一个有效值?

var sc = fixture.Create<SomeClass>();
sc.ExpirationDate = sc.ValidFrom + fixture.Create<TimeSpan>();

// Perform test here...

如果你使用AutoFixture.Xunit,它可以更简单:

[Theory, AutoData]
public void ExplicitPostCreationFix_xunit(
    SomeClass sc,
    TimeSpan duration)
{
    sc.ExpirationDate = sc.ValidFrom + duration;

    // Perform test here...
}

这是相当稳健的,因为即使 AutoFixture (IIRC) 创建随机 TimeSpan 值,它们也会保持在正值范围内,除非您对 fixture 做一些改变它的行为。

如果您需要测试 SomeClass 本身,这种方法将是解决您的问题的最简单方法。另一方面,如果您需要 SomeClass 作为无数其他测试的输入值,这不是很实用。

在这种情况下,修复 AutoFixture 可能很诱人,这也是可能的:

更改 AutoFixture 的行为

现在您已经了解了如何将问题作为一次性解决方案来解决,您可以将此作为 SomeClass 生成方式的一般更改告知 AutoFixture:

fixture.Customize<SomeClass>(c => c
    .Without(x => x.ValidFrom)
    .Without(x => x.ExpirationDate)
    .Do(x => 
        {
            x.ValidFrom = fixture.Create<DateTime>();
            x.ExpirationDate = 
                x.ValidFrom + fixture.Create<TimeSpan>();
        }));
// All sorts of other things can happen in between, and the
// statements above and below can happen in separate classes, as 
// long as the fixture instance is the same...
var sc = fixture.Create<SomeClass>();

您还可以将上述对 Customize 的调用打包到 ICustomization 实现中,以供进一步重用。这也将使您能够将自定义的 Fixture 实例与 AutoFixture.Xunit.

一起使用

更改 SUT 的设计

虽然上述解决方案描述了如何更改 AutoFixture 的行为,但 AutoFixture 最初是作为 TDD 工具编写的,而 TDD 的主要目的是提供有关被测系统 (SUT) 的反馈。 AutoFixture 倾向于放大这种反馈, 这里也是如此。

考虑 SomeClass 的设计。没有什么能阻止客户做这样的事情:

var sc = new SomeClass
{
    ValidFrom = new DateTime(2015, 2, 20),
    ExpirationDate = new DateTime(1900, 1, 1)
};

这编译和运行没有错误,但可能不是你想要的。因此,AutoFixture 实际上并没有做错任何事情; SomeClass 没有正确保护其不变量。

这是一个常见的设计错误,开发人员往往过于信任成员姓名的语义信息。想法似乎是没有人会在他们的头脑正常的情况下将 ExpirationDate 设置为 之前 ValidFrom 的值!这种论点的问题在于它假设所有开发人员总是成对分配这些值。

但是,客户也可能会收到传递给他们的 SomeClass 实例,并希望更新其中一个值,例如:

sc.ExpirationDate = new DateTime(2015, 1, 31);

这有效吗?你怎么知道?

客户端可以查看 sc.ValidFrom,但为什么要查看? 封装的整个目的就是减轻客户的这种负担。

相反,您应该考虑更改设计 SomeClass。我能想到的最小的设计变化是这样的:

public class SomeClass
{
    public DateTime ValidFrom { get; set; }
    public TimeSpan Duration { get; set; }
    public DateTime ExpirationDate
    {
        get { return this.ValidFrom + this.Duration; }
    }
}

这会将 ExpirationDate 变成 只读 ,计算 属性。通过此更改,AutoFixture 开箱即用:

var sc = fixture.Create<SomeClass>();

// Perform test here...

您也可以将其与 AutoFixture.Xunit 一起使用:

[Theory, AutoData]
public void ItJustWorksWithAutoFixture_xunit(SomeClass sc)
{
    // Perform test here...
}

这仍然有点脆弱,因为虽然默认情况下,AutoFixture 会创建正 TimeSpan 值,但也可以更改该行为。

此外,该设计实际上允许客户将负 TimeSpan 值分配给 Duration 属性:

sc.Duration = TimeSpan.FromHours(-1);

这是否应该被允许取决于域模型。一旦开始考虑这种可能性,实际上可能会发现定义时间向后移动的时间段在域中是有效的...

根据 Postel 定律设计

如果问题域不允许回到过去,您可以考虑在 Duration 属性 中添加保护条款, 拒绝负时间跨度。

然而,就我个人而言,当我认真对待 Postel's Law 时,我经常发现 API 设计更好。在这种情况下,为什么不更改设计,使 SomeClass 始终使用 绝对 TimeSpan 而不是带符号的 TimeSpan?

在那种情况下,我更喜欢一个不可变的对象,它在知道它们的值之前不会强制执行两个 DateTime 实例的角色:

public class SomeClass
{
    private readonly DateTime validFrom;
    private readonly DateTime expirationDate;

    public SomeClass(DateTime x, DateTime y)
    {
        if (x < y)
        {
            this.validFrom = x;
            this.expirationDate = y;
        }
        else
        {
            this.validFrom = y;
            this.expirationDate = x;
        }
    }

    public DateTime ValidFrom
    {
        get { return this.validFrom; }
    }

    public DateTime ExpirationDate
    {
        get { return this.expirationDate; }
    }
}

与之前的重新设计一样,此 与 AutoFixture 开箱即用

var sc = fixture.Create<SomeClass>();

// Perform test here...

情况与AutoFixture.Xunit相同,但现在没有客户端可以错误配置它。

你觉得这样的设计是否合适取决于你,但我希望至少它是值得深思的。

这是对 Mark 回答的一种 "extended comment" 参考,试图建立在他的 Postel 定律解决方案的基础上。构造函数中的参数交换让我感到不安,所以我在 Period class.

中明确了日期交换行为

为简洁起见使用C#6 syntax:

public class Period
{
    public DateTime Start { get; }
    public DateTime End { get; }

    public Period(DateTime start, DateTime end)
    {
        if (start > end) throw new ArgumentException("start should be before end");
        Start = start;
        End = end;
    }

    public static Period CreateSpanningDates(DateTime x, DateTime y, params DateTime[] others)
    {
        var all = others.Concat(new[] { x, y });
        var start = all.Min();
        var end = all.Max();
        return new Duration(start, end);
    }
}

public class SomeClass
{
    public DateTime ValidFrom { get; }
    public DateTime ExpirationDate { get; }

    public SomeClass(Period period)
    {
        ValidFrom = period.Start;
        ExpirationDate = period.End;
    }
}

然后您需要为 Period 自定义夹具以使用静态构造函数:

fixture.Customize<Period>(f =>
    f.FromFactory<DateTime, DateTime>((x, y) => Period.CreateSpanningDates(x, y)));

我认为这个解决方案的主要好处是它将时间顺序要求提取到它自己的 class (SRP) 中,并让您的业务逻辑根据已经商定的合同来表达,从构造函数签名中可以看出。