65.9K
CodeProject 正在变化。 阅读更多。
Home

行为学:一个用于更好地组织单元测试的 BDD 库

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.89/5 (6投票s)

2011年7月29日

BSD

9分钟阅读

viewsIcon

28356

downloadIcon

280

一个行为驱动设计 (BDD) 库,为单元测试增加了更清晰的语法和更好的组织方式。目前已测试可与 NUnit 和 MSTest 配合使用。

引言

Behavioral 是一个用 C# 编写的 .NET 程序集,可以与您常用的测试框架(例如:NUnit、MSTest)结合使用,为您的单元测试添加更具 BDD 风格的语法。目前处于 Beta 阶段,因此您的反馈可以使 Behavioral 变得更好。

BehavioralActivity.png

背景

行为驱动开发 (BDD) 是测试驱动开发 (TDD) 之后的自然发展。BDD 包含许多不同的方面,但 Behavioral 解决了 BDD 的一个方面:单元测试的组织。

组织单元测试的常用方法如下:

[TestFixture]
public class CalculatorFixture
{
    [SetUp]
    public void SetUp()
    {
        this.calculator = new Calculator();
    }

    [Test]
    public void CanAddTwoPositiveNumbers()
    {
        int result = this.calculator.Add(13, 45);
        Assert.AreEqual(58, result);
    }

    [Test]
    public void AdditionOverflowCausesException()
    {
        Assert.Throws<OverflowException>(() => 
                      this.calculator.Add(int.MaxValue, 1));
    }

    private Calculator calculator;
}

随着测试变得越来越复杂和牵涉广泛,这种方法存在两个问题,而 Behavioral 解决了这两个问题。

  • 测试不促进代码重用,包括初始化代码、被测操作以及事后断言。
  • 测试可能难以理解,而且由于敏捷项目中的测试构成了代码意图的可靠文档,因此保持测试的简单性非常重要。

使用 Behavioral 后,上面的两个测试将变成这样:

using Behavioral;
using NUnit;

namespace MyTests
{
    [TestFixture]
    public class AddingTwoPositiveNumbersShouldGiveCorrectResult : 
                 UnitTest<Calculator, int>
    {
        [Test]
        public override void Run()
        {
            GivenThat<CalculatorIsDefaultConstructed>()

            .When<TwoNumbersAreAdded>(13, 45)

            .Then<ResultShouldMatch>(58);
        }
    } 

    [TestFixture]
    public class AddingNumbersThatCausesOverflowShouldThrowOverflowException : 
                 UnitTest<Calculator, int>
    {
        [Test]
        public override void Run()
        {
            GivenThat<CalculatorIsDefaultConstructed>()

            .When<TwoNumbersAreAdded>(int.MaxValue, 1)

            .ThenThrow<OverflowException>();
        }
    }
}

这对于任何希望从测试中辨别代码意图或对测试进行维护的人来说都更具可读性。此外,测试通过代码重用可以减少测试错误并加速测试优先的方法。

变更列表

贝塔 (0.9.9.5)
修正了运行开始时清除上下文的错误。非常抱歉。
贝塔 (0.9.9.4)
在运行开始时清除上下文(处理非线程环境下的陈旧上下文)。
修复了未使用 ThenThrow 时异常被吞没的潜在错误。
使 ThenThenThrow 互斥。
迁移到完全的 Fluent 接口 [GivenThat().And().When().Then().And().And().ThenThrow()]
修复了上下文中的线程问题。
添加了更多的初始化集合内容。
开始添加初始化集合。
允许操作作为初始化程序运行。
允许无上下文的初始化程序。
删除了 IInitializeWithTearDown 并替换为 ITearDown
添加了 IErrorHandler
允许初始化程序引用匿名上下文状态。
上下文现在可以命名,允许同类型存在多个值。

Using the Code

快速入门

如果您想快速上手 Behavioral,以下是一些步骤可以帮助您入门:

  1. CodePlex 下载预编译的 beta 程序集,并将其添加到您的测试项目中的引用。
  2. 创建一个新类,该类派生自 Behavioral.UnitTest<TTarget>Behavioral.UnitTest<TTarget, TReturnType>。后者对于测试返回类型的(即非 void)方法是必需的。
  3. 在类的 Run 重写方法中,调用 GivenThat<TInitializer>()When<TAction>()Then<TAssertion>(),为类型参数指定英语句子(Pascal 命名法)。
  4. 定义第 3 部分中的类,分别实现 IInitializerIActionIAssertion

如何使用 Behavioral

单元测试

所有单元测试的起点是继承自 UnitTest 抽象基类之一。

public abstract class UnitTest<TTarget> ...
public abstract class UnitTest<TTarget, int> ...

前者类用于没有返回值的方法,而后者要求被测方法返回指定类型。

您选择的类会对可用于定义操作和断言的接口产生影响。

UnitTest 类有一个 Run 方法,该方法应用于指定测试。

[TestMethod]
public override void Run()
{
    GivenThat<CalculatorIsDefaultConstructed>()
    .When<AddingTwoNumbers>(13, 45)
    .Then<ResultShouldEqual>(58);
}

GivenThat 方法需要一个实现 IInitializer 接口的类型参数。GivenThat 返回一个 Fluent 接口,允许您链式调用前置条件。

GivenThat<CalculatorIsDefaultConstructed>()
        .And<SomeOtherInitializationCode>()
        .And<FurtherInitializationCode>()

When 方法需要一个匹配操作规范的类型参数。但是,您也可以传入 Action<TTarget>Func<TTarget, TReturnType>。请注意,这将绕过类型参数风格的英语语言,但某些操作过于简单,无需定义新类。

Then 方法需要一个实现 IAssertion 接口的类型参数。这同样会返回一个 Fluent 接口,类似于 GivenThat

.Then<ResultShouldEqual>(58)
.And<SomeOtherPostCondition>()
.And<FurtherPostCondition>();

请注意,为 WhenThen 提供了参数。这对于 GivenThat 调用也是可能的。任何参数都可以在此处传递,因为这些方法接受 params object[] 作为参数。这些值将被传递给所提供类型的构造函数。但是请注意,任何类型不匹配都不会在编译时捕获。事实上,未能提供正确的参数数量也不会在编译时捕获。

初始化程序

初始化程序是单元测试的基本构建块。Behavioral 的重点是鼓励初始化代码的重用和组合,使其形成一个可读的脚本来设置测试。初始化程序的粒度完全取决于您,但可以使用 InitializerCollections(见下文)将它们逻辑分组。

标准的初始化程序如下所示:

public class CalculatorIsDefaultConstructed : IInitializer<Calculator>
{
    public void SetUp(ref Calculator calculator)
    {
        calculator = new Calculator();
    }
}

请注意,由于参数是按引用传递的,我们不仅可以修改其属性,还可以更改引用本身。此外,此处提供的类型参数与初始化程序将使用的单元测试的整体类型匹配。然而,这并非总是很有用,因为有些初始化程序根本不依赖任何上下文。以这个真实示例为例:

class UnityContainerIsMocked : IInitializer<SecurityCommandsUser>
{
    public void SetUp(ref SecurityCommandsUser userCommands)
    {
        var unityContainer = Isolate.Fake.Instance<IUnityContainer>();
        this.SetContext(unityContainer);
    }
}

在这种情况下,我们正在模拟 Unity 容器,这是我目前正在处理的 Prism 应用程序中的常见做法。这里的问题很明显——我们将自己绑定到了 SecurityCommandsUser 类,但设置方法完全忽略了它。在 Behavioral 的 alpha 版本中,这阻止了此类初始化程序的重用(例如,相同的初始化程序必须为 SecurityCommandsApplication 重写)。在 beta 版本中,我们不必将初始化程序绑定到单元测试的目标类型:

class UnityContainerIsMocked : IInitializer
{
    public void SetUp()
    {
        var unityContainer = Isolate.Fake.Instance<IUnityContainer>();
        this.SetContext(unityContainer);
    }
}

好多了。现在,让我们进行一点技术性的内容分支……这是它的工作方式,有点取巧但有必要,并且会产生一个潜在的问题,这个问题被转化为一个特性。在 .NET 中,泛型约束不属于方法签名的一部分。这是编译器的设计和完全预期的行为。但是,当您想做类似这样的事情时,这有点不方便:

IInitializationFluent<TTarget> GivenThat<TInitializer>(params object[] constructorArgs)
        where TInitializer : IInitializer<TTarget>;

IInitializationFluent<TTarget> GivenThat<TInitializer>(params object[] constructorArgs)
        where TInitializer : IInitializer;

这是无效的,因为运行时无法区分这两个方法——在它的眼中它们是相同的。这意味着我们必须以一种有点取巧的方式来解决这个问题。长话短说,任何可以用作初始化程序的接口现在都被赋予了标记接口 IInitializerMarker。在运行时,会进行一些类型嗅探,以确切了解我们正在处理的内容以及如何运行它。这打破了开闭原则,这是不幸的,但它给了我们添加更多特性的机会。首先,我们可以支持无上下文的初始化程序,以及作用于目标类型的初始化程序。然而,本来是编译时检查(如果上面的代码有效)现在变成了运行时检查。这意味着您可以在任何 UnitTest中使用 IInitializer<TTarget> 中的任何 TTarget 值。嗯,我们开放得有点过了……为了理解这一点,我们可以利用测试的 Context。如果我们假设测试的目标类型是 ISession(即,您正在测试 NHibernate 映射或类似内容),那么这个初始化程序是完全有效的:

public class TheUserIsMocked : IInitializer<User>
{
    public void SetUp(ref User user)
    {
        user = Isolate.Fake.Instance<User>();
    }
}

在此示例中,User 引用来自当前单元测试的 Context。因此,对于目标类型与 UnitTest 的目标类型不匹配的 IInitializer 来说,存在一个有效的用法,这意味着缺乏编译时检查无关紧要,并且我们获得了一些不错的额外功能。还值得注意的是,Actions 可以用作初始化程序,但反之则不然。当操作用作初始化程序时,任何返回值都会被丢弃。

[TestClass]
public class AddingNumbersThatCauseOverflowShouldThrowOverflowException : 
       UnitTest<Calculator, int>
{
    [TestMethod]
    public override void Run()
    {
        GivenThat<CalculatorIsReadyToRun>()
            .And<AddingTwoNumbers>(23, 32)

        .When<AddingTwoNumbers>(int.MaxValue, 1)

        .ThenThrow<OverflowException>();
    }
}

初始化集合

即使有了 Fluent 接口,每次创建单元测试时都要重复输入相同的几个初始化程序也会变得很麻烦。因此,可以将初始化程序分组到集合中,以便每个测试的基础可以由更离散的部分组成:

public class CalculatorIsReadyToRun : InitializerCollection
{
    protected override void Register()
    {
        GivenThat<CalculatorIsDefaultConstructed>()
            .And<SomeOtherInitialization>()
            .And<MoreInitialization>()
            .And<EvenMoreIntialization>()
            .And<ThankGodForInitializerCollections>();
    }
}
...
[TestClass]
public class AddingNumbersThatCauseOverflowShouldThrowOverflowException : 
             UnitTest<Calculator, int>
{
    [TestMethod]
    public override void Run()
    {
            GivenThat<CalculatorIsReadyToRun>()
                    .And<TestSpecificInitializer>()

            .When<AddingTwoNumbers>(int.MaxValue, 1)

            .ThenThrow<OverflowException>();
    }
}

ITearDown

在 Behavioral alpha 版本中,清理(tear down)与 IInitializer 接口耦合,形成了 IInitializerWithTearDown。这个疏漏违反了接口隔离原则,现已纠正。我本来会标记该接口为过时,但它是一个下载量有限的 alpha 版本,所以我恐怕只是将其完全删除了。因此,这是 alpha 和 beta 版本之间众多破坏性更改之一。清理的目的是在调用单元测试操作后执行一些反初始化。

public class SessionHasBeenStarted : IInitializer<ISession>, ITearDown<ISession>
{
    public void SetUp(ref ISession session)
    {
        this.SetContext(session.BeginTransaction());
    }

    public void TearDown(ISession session)
    {
        var transaction = this.GetContext<ITransaction>();
        if(transaction != null)
        {
            transaction.Commit();
        }
        if(session != null)
        {
            session.Dispose();
        }
    }
}

IErrorHandler

但是,如果操作调用过程中出现错误怎么办?毕竟,有些单元测试的目的是让操作抛出异常,例如。那么,答案就是 IErrorHandler 接口。如果测试抛出异常,那么作为 IInitializerMarker 实现注册的所有 IErrorHandler 接口都将被调用。

public class SessionHasBeenStarted : IInitializer<ISession>, 
            ITearDown<ISession>, IErrorHandler<ISession>
{
    public void SetUp(ref ISession session)
    {
        this.SetContext(session.BeginTransaction());
    }

    public void TearDown(ISession session)
    {
        var transaction = this.GetContext<ITransaction>();
        if(transaction != null)
        {
            transaction.Commit();
        }
        if(session != null)
        {
            session.Dispose();
        }
    }

    public void OnError(ISession session)
    {
        var transaction = this.GetContext<ITransaction>();
        if(transaction != null)
        {
            transaction.Rollback();
        }
        if(session != null)
        {
            session.Dispose();
        }
    }
}

值得注意的是,ITearDownIErrorHandler 本身不足以将它们注册到 Unit Test - 它们必须与 IInitializer 耦合。这是为了确保设置、清理和错误处理保持对称。

Actions

有两种操作接口,您选择哪一种取决于继承的 UnitTest 类。

public interface IAction<TTarget> ...
public interface IAction<TTarget, TReturnType> ...

两个操作接口都有一个方法,但签名不同。

void Execute(TTarget target);
TReturnType Execute(TTarget target);

Execute 方法将在从 UnitTest 子类调用 When 方法后立即被调用。

断言

同样,有两个断言接口,它们必须与 UnitTest 基类匹配。

public interface IAssertion<TTarget> ...
public interface IAssertion<TTarget, TReturnType> ...

接口中有一个方法,Verify

void Verify(TTarget target);

void Verify(TTarget target, TReturnType returnValue);

在实现这些方法时,您应该使用您的单元测试框架的 Assert 方法来验证测试是否通过。

异常

有时,方法的预期行为是抛出异常。在 Behavioral 中,这可以通过在 UnitTest.Run 方法中调用 ThenThrow<TException> 来实现,而不是进行任何 Then 调用。

ThenThrow<OverflowException>();

背景

有时,除了提供的目标类和目标方法的返回值之外,还需要在单元测试中进一步的上下文。

在初始化程序中,您可以调用 SetContext<TContext>(TContext contextValue) 方法,供操作或断言类使用。

public class SessionIsStarted : IInitializerWithTearDown<ISession, int>
{
    public void SetUp(ref ISession session)
    {
        session = SessionFactory.CreateSession();
        SetContext<ITransaction>(session.BeginTransaction());
    }

    public void TearDown(ISession session)
    {
        GetContext<ITransaction>().Commit();
        session.Clear();
        session.Dispose();
    }
}

从 beta 版本开始,您现在可以为上下文命名,这样您就可以拥有多个相同类型的上下文值:

public class CalculatorIsDefaultConstructed : IInitializer<Calculator>
{
    public void SetUp(ref Calculator calculator)
    {
        calculator = new Calculator();

        this.SetContext(int.MaxValue);
        this.SetContext("one", 1);
        this.SetContext("two", 2);
        this.SetContext("three", 3);
    }
}

内部维护着两个独立的集合:一个用于匿名上下文(将保存到 int.MaxValue,与 System.Int32 类型相关联),另一个用于命名上下文。当使用利用上下文的 Initializer 时,始终使用匿名集合。

脚注

您可能已经注意到,您失去了为一组测试执行基本设置/清理功能的能力。当,例如,测试一个数据库或其他需要大量初始化的外部项时,这是一个常见要求。由于每个测试都是一个单独的类,而不是像通常那样将测试打包成每个方法,因此 NUnit 的 [FixtureSetup] 和 MSTest 的 [ClassInitialize] 都变得多余。

值得庆幸的是,有替代方案。在 NUnit 中,您可以使用 [SetupFixture] 属性,它在命名空间级别上运行。MSTest 的 [AssemblyInitialize] 类似,但它在每个程序集级别上运行。

历史

  • 2011/07/28:发布 Alpha 版本 0.9.0.0。
  • 2011/08/23:发布 Beta 版本 0.9.9.4。
  • 2011/08/24:发布 Beta 版本 0.9.9.5。
© . All rights reserved.