可能很想问如何使 AutoFixture 适应我的设计?,但通常,更有趣的问题可能是:我如何使我的设计更健壮吗?
您可以保留设计并“修复”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 设置为 before 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 始终使用absolute 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 的情况与此相同,但现在没有客户端可以错误配置它。
你是否觉得这样的设计合适取决于你,但我希望至少它是值得深思的。