单元测试的动态组合 – xUnit.net 和 AutoFixture
4.92/5 (20投票s)
本文介绍了两个免费的开源工具,它们将使您的单元测试更加声明式、紧凑,并且更容易阅读和理解。
引言
引用“担心 TDD 会减慢程序员的速度?别担心。他们可能本来就需要放慢速度。”
不可否认,多年来,在我自己的世界里,测试驱动开发(TDD)和单元测试是“别人”做的事情。
然后,几年前,我开始关注依赖注入(DI)作为确保代码松耦合和高可维护性的一种方法。事实证明,使用 DI 的一个副产品是它使您的代码库更加可测试。从那时起,我实际上开始更积极地使用单元测试,这变得有些自然。

那是我第一次接触单元测试的“动态组合”——单元测试框架xUnit.net 和自动化工具AutoFixture。它们都是免费的、开源的、社区驱动的工具,可以通过 Visual Studio 扩展的NuGet 包管理器获得。
我不会声称自己遵循严格的 TDD(红-绿-重构),但如今单元测试已成为我流程的一部分。归根结底,我努力实现领域和数据层成员的完整代码覆盖。
在本文中,我将通过几个使用示例,简要介绍这个动态组合的强大功能。
本文中我正在进行单元测试的代码库是我之前在另一篇 CodeProject 文章中介绍的轻量级领域服务库的简化版本。
xUnit.net
您可能想知道为什么我发现使用 xUnit.net 比 Microsoft 自带的 MSTest(作为 Visual Studio 的集成部分)更有益。这有很多好理由——例如在这篇文章中有描述。幸运的是,测试列表(.vsmdi 文件)自 Visual Studio 2012 起在 MSTest 中已弃用。然而,仅凭 xUnit.net(不像 MSTest)是可扩展的——这允许 AutoFixture 等第三方工具为 xUnit.net 提供有用的扩展——就值得进行迁移。
有关 xUnit.net 功能和与 MSTest 相比的差异的概述,我喜欢这个比较表。
使用 xUnit.net 进行数据驱动测试
xUnit.net 对参数化测试(也称为数据驱动测试)有非常好的支持。要使用 xUnit.net 中的数据驱动测试,只需在 Visual Studio 中创建一个新项目,并通过 NuGet 包管理器添加对 xUnit.net 的引用。

请注意,与 MSTest 不同,无需创建特定的单元测试项目。
要能够使用 Visual Studio Test Explorer 运行 xUnit.net 测试,请安装 xUnit.net Runner NuGet 包。

虽然常规的 xUnit.net 测试方法通过应用 [Fact] 属性来识别,但数据驱动测试使用 [Theory] 属性。Theory 用于仅对给定输入数据集有效的测试。因此,测试 Theory 的一部分是提供合规数据集。这样,单个测试定义可以在运行时被调用多次。
让我们为 AccountService 类中的 GetRoles 方法创建一些测试用例。每个账户都有一个角色集。有 4 种不同的角色定义如下:
[Flags]
public enum Roles
{
Guest = 1,
User = 2,
Editor = 4,
Administrator = 8
}
GetRoles 方法应该以字符串数组的形式返回角色集。
使用 [InlineData] 属性,您可以在运行时指定传递给测试方法参数的测试数据。
[Theory]
[InlineData(Roles.Guest | Roles.User | Roles.Editor, new[] { "Guest", "User", "Editor" })]
[InlineData(Roles.Administrator, new[] { "Administrator" })]
[InlineData(Roles.Guest | Roles.Editor, new[] { "Guest", "Editor" })]
[InlineData(Roles.Editor | Roles.Guest, new[] { "Guest", "Editor" })]
public void GetRolesIsOk(Roles roles, string[] expected)
{
var account = new Account(Guid.NewGuid(), "John Doe") {Roles = roles};
var accountService = new AccountService(new FakeAccountRepository());
accountService.Add(account);
Assert.Equal(expected, accountService.GetRoles(account.Id));
}
此测试将执行 4 次,每次对应一个 [InlineData] 属性。对于例如具有 Roles.Guest|Roles.Editor 角色集的账户,GetRoles 方法必须返回一个包含“Guest”和“Editor”元素的数组。
xUnit.net 提供了许多其他选项来定义数据驱动测试。[InlineData] 属性只是 abstract DataAttribute 类(代表数据理论的数据源)的几种扩展之一。其他 DataAttribute 扩展包括 [ClassData]、[PropertyData]、[ExcelData] 和 [OleDbData] 属性。
abstract DataAttribute 类是 xUnit.net 定义良好的可扩展点之一,它被 AutoFixture 利用,但稍后将详细介绍。
AutoFixture
通常,在编写单元测试时,最乏味和耗时的任务之一是设置测试环境。测试环境是指运行测试并预期特定结果所需的所有内容。在上面的测试中,测试环境设置包括创建账户、设置账户上的必要属性(角色)、建立账户服务并将账户添加进去。
然而,很多时候您的测试并不需要任何特定数据。也许您只需要一个随机字符串列表或一个具有随机属性的实体存储库。这就是 AutoFixture 的用武之地。AutoFixture 可以为您提供这样的匿名变量。它可以创建几乎任何类型的数值,而无需您明确定义要使用哪些数值。
更有趣的是,AutoFixture 挂钩到 xUnit.net 前面提到的 DataAttribute 可扩展点,提供了出色的 [AutoData] 属性。
顾名思义,此属性将为 xUnit.net 数据理论提供自动生成的数据。
顺带一提,AutoFixture 绝不与 xUnit.net 紧密耦合。AutoFixture 可以与任何单元测试框架一起使用。但是,AutoFixture 提供了一个扩展,可以很好地利用 xUnit.net 的数据理论功能。
要启用此功能,您必须使用 NuGet 包管理器将 AutoFixture with xUnit.net v2 data theories 添加到您的测试项目中。

现在,让我们来看一个用于测试 AccountService 类的自动环境设置的示例,这是一个用于管理用户账户的服务。
AccountService 类使用依赖注入,并通过构造函数注入一个账户存储库。
public class AccountService : BaseService<Account>
{
public AccountService(IRepository<Account> repository)
: base(repository)
{
}
...
}
为此,您可以创建一个 FakeAccountRepository 实例。FakeAccountRepository 继承了泛型 FakeRepository<TEntity>,其中“持久化”是在内存对象(Dictionary 对象)中完成的。这在实际应用中显然没有用,但作为单元测试的伪存储库非常完美。
internal class FakeAccountRepository : FakeRepository<Account>, IRepository<Account>
{
...
}
此外,您可能还希望环境包含一些随机账户的填充。
上述环境设置可以通过自定义 AutoFixture [AutoData] 属性的扩展来自动化,特别是针对测试账户服务功能。
internal class AutoAccountDataAttribute : AutoDataAttribute
{
public AutoAccountDataAttribute()
{
var accountList = Fixture.CreateMany<Account>().ToList();
Fixture.Register<IRepository<Account>>(() => new FakeAccountRepository(accountList));
}
}
在 AutoDataAttribute 类中,Fixture 是 AutoFixture 的主要入口点。
Fixture.CreateMany<Account> 将创建一个匿名变量序列——在本例中是账户。默认情况下,CreateMany 将创建 3 个实例,但可以使用 Fixture 的 RepeatCount 属性更改此数量。
在 Fixture.Register 语句中,您声明每次 Fixture 被要求创建 IRepository<Account> 实例时,它将返回一个新的 FakeAccountRepository 实例。
现在,您可以使用此自定义 [AutoAccountData] 属性来修饰任何将测试 AccountService 类功能的 Data Theory。例如,可以轻松测试在尝试获取不存在的账户时会抛出 KeyNotFoundException。
[Theory, AutoAccountData]
public void GetNonExistingThrows(AccountService accountService)
{
Assert.Throws<KeyNotFoundException>(() => accountService.Get(Guid.NewGuid()));
}
accountService 参数提供了一个带有多个伪账户的伪账户服务,因此只需一行代码即可测试通过获取一个随机生成(因此不存在)ID 的账户会抛出 KeyNotFoundException。
同样,Count 方法也可以用一行代码进行测试。
[Theory, AutoAccountData]
public void CountIsOk(AccountService accountService)
{
Assert.Equal(3, accountService.Count());
}
上面的示例清楚地演示了 AutoFixture 如何极大地减少了测试环境设置的繁琐“修剪枝桠”工作,并使您的单元测试更加声明式、紧凑,并且易于阅读和理解。
这是另一个示例,其中会自动生成一个匿名账户对象来测试将新账户添加到账户存储库时是否正确引发了事件。
[Theory, AutoAccountData]
public void EventsAreRaisedOnAdd(AccountService accountService, Account account)
{
var raisedEvents = new List<string>();
accountService.Adding += (s, e) => { raisedEvents.Add("Adding"); };
accountService.Added += (s, e) => { raisedEvents.Add("Added"); };
accountService.Add(account);
Assert.Equal("Adding", raisedEvents[0]);
Assert.Equal("Added", raisedEvents[1]);
}
在示例代码中,您可以找到更多单元测试。
使用 AutoFixture 进行自动模拟
到目前为止,单元测试示例一直是经典的状态验证单元测试。如果您更倾向于模拟实践者,偏好行为验证单元测试(顺便说一句,我不是……),还有另一个 AutoFixture 扩展可用,它将 AutoFixture 变成一个自动模拟容器,使用流行的模拟框架 Moq 来自动生成测试双精度。
顺带一提,Martin Fowler 在他的Mocks Aren't Stubs 文章中描述了状态验证和行为验证之间的区别。
[AutoData] 属性及其后代(如上面的 [AutoAccountData] 属性)仅适用于具体类型(如 Account)。它不会自动从抽象类或接口生成对象。要启用此类功能,您必须使用 NuGet 包管理器将 AutoFixture with Auto Mocking using Moq 添加到您的测试项目中。

有了这个,您就可以对前面描述的 [AutoData] 属性进行自定义扩展。
public class AutoMoqDataAttribute : AutoDataAttribute
{
public AutoMoqDataAttribute()
: base(new Fixture().Customize(new AutoConfiguredMoqCustomization()))
{
}
}
现在,您拥有了一个真正强大的属性,它结合了 xUnit.net 支持的功能和自动模拟功能。
当 AutoConfiguredMoqCustomization 被添加到 Fixture 实例(如上面的 [AutoMoqData] 属性所示)时,它不仅会像一个自动模拟容器一样,自动从抽象类或接口生成对象。它还会自动配置所有生成的测试双精度(模拟),以便返回的值由 AutoFixture 生成。
现在,例如,您可以测试每当添加一个实体时是否会引发事件,方法是请求抽象 BaseService 类和 IEntity 接口的模拟对象。
[Theory, AutoMoqData]
public void EventsAreRaisedOnAdd(Mock<BaseService<IEntity>> serviceMock, Mock<IEntity> entityMock)
{
IEntity entity = entityMock.Object;
BaseService<IEntity> service = serviceMock.Object;
serviceMock.Setup(s => s.Exists(entity.Id)).Returns(false);
var raisedEvents = new List<string>();
service.Adding += (s, e) => { raisedEvents.Add("Adding"); };
service.Added += (s, e) => { raisedEvents.Add("Added"); };
service.Add(entity);
serviceMock.Verify(s => s.Exists(entity.Id), Times.Exactly(1));
Assert.Equal("Adding", raisedEvents[0]);
Assert.Equal("Added", raisedEvents[1]);
}
因此,在这个测试中,没有涉及抽象类的具体实现(如 Account 或 AccountService)。只使用了模拟对象。
注意 Moq 如何优雅地支持 Linq 表达式来设置 BaseService 类的 Exists 方法的期望。
serviceMock.Setup(s => s.Exists(entity.Id)).Returns(false);
另外,请注意此测试验证了在添加新实体时(因此无法添加具有现有 ID 的实体)实际调用了 Exists 方法。这就是行为验证的实际应用。
serviceMock.Verify(s => s.Exists(entity.Id), Times.Exactly(1));
除了支持 Moq,AutoFixture 还支持其他模拟框架,如NSubstitute 和RhinoMocks。
实用信息
示例代码是在 Visual Studio 2013 中使用 .NET 4.5.1 创建的。
在示例代码中,第三方依赖项(xUnit.net 和 AutoFixture 扩展)已包含在内,因此您无需检索这些依赖项(还原 NuGet 包)。
摘要
xUnit.net 是一个出色的单元测试框架。与其他单元测试框架相比,它的主要优点之一是它是可扩展的。这就是允许 AutoFixture 等工具为 xUnit.net 提供我在本文中描述的巧妙而有用的扩展的原因。
AutoFixture 自动化了琐碎且通常不相关的测试环境设置,使测试开发人员能够专注于每个测试用例的要点。它使您的单元测试更加声明式、紧凑,并且易于阅读和理解。
结合使用这两个出色的工具,单元测试将成为一种真正的乐趣。
历史
- 2015 年 7 月 8 日 - 升级源代码以使用最新版本的 xUnit.net (2.0.0) 和 AutoFixture (3.30.8)
- 2015 年 9 月 9 日 - 修改了损坏的超链接
