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

我目前正在拥有一个包含多个属性的模型类。 简化模型可能如下所示:

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

现在,我正在使用NUnit实现一些unit testing,并使用AutoFixture创建一些随机数据:

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

这到目前为止完美无缺。 但是要求ValidFrom的日期总是在 ExpirationDate 之前 。 我必须确保这一点,因为我正在实施一些积极的测试。

那么使用AutoFixture有一种简单的方法来实现它吗? 我知道我可以创建一个修复日期并添加一个随机日期间隔来解决这个问题,但如果AutoFixture可以自己处理这个要求,那将会很棒。

我没有很多使用AutoFixture的经验,但我知道我可以通过调用Build方法获得一个ICustomizationComposer

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

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

在此先感谢您的帮助。

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

您可以保留设计并“修复”AutoFixture,但我不认为这是一个特别好的主意。

在我告诉你如何做到这一点之前,根据你的要求,你可能需要做的就是以下几点。

明确的任务

为什么不简单地将有效值分配给ExpirationDate ,就像这样?

 var sc = fixture.Create(); sc.ExpirationDate = sc.ValidFrom + fixture.Create(); // 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的行为

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

 fixture.Customize(c => c .Without(x => x.ValidFrom) .Without(x => x.ExpirationDate) .Do(x => { x.ValidFrom = fixture.Create(); x.ExpirationDate = x.ValidFrom + fixture.Create(); })); // 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(); 

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

改变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(); // 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属性中添加Guard子句,拒绝负时间跨度。

但是,就个人而言,我经常发现,当我认真对待Postel法则时,我会得到更好的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(); // Perform test here... 

AutoFixture.Xunit的情况也是如此,但现在没有客户端可能会错误配置它。

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

这是对Mark的回答的一种“扩展评论”,试图建立在他的Postel法律解决方案上。 构造函数中的参数交换对我来说感到不安,所以我在Period类中明确地进行了日期交换行为。

使用C#6语法简洁

 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(f => f.FromFactory((x, y) => Period.CreateSpanningDates(x, y))); 

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

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

 [Fact] public void UsingGeneratorOfDateTime() { var fixture = new Fixture(); var generator = fixture.Create>(); var sut = fixture.Create(); var seed = fixture.Create(); sut.ExpirationDate = generator.First().AddYears(seed); sut.ValidFrom = generator.TakeWhile(dt => dt < sut.ExpirationDate).First(); Assert.True(sut.ValidFrom < sut.ExpirationDate); } 

FWIW,使用AutoFixture和xUnit.net数据理论 ,上面的测试可以写成:

 [Theory, AutoData] public void UsingGeneratorOfDateTimeDeclaratively( Generator 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); }