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

将单元测试与代码内联组织。

starIconstarIconstarIcon
emptyStarIcon
starIcon
emptyStarIcon

3.93/5 (16投票s)

2016年6月28日

CPOL

7分钟阅读

viewsIcon

28681

如何在库中组织单元测试,而无需单独的测试库。

引言

在我的个人生活中,我算是个业余户外烧烤爱好者。大约一年前,我一个朋友送了我他的旧日式烤炉(kamado smoker),我立刻就着迷了。我最喜欢它的一点(除了能做出美味的食物)就是户外烹饪是多么有艺术性。对于任何一个食谱,从来都没有一个“正确”的烹饪方法;实验是关键。我看待软件工程的方式也大同小异。解决任何一个问题通常都有许多不同的方法;虽然有些方法无疑是“错误”的,但通常不止一种“正确”的方法。

和生活中的大多数事情一样,我喜欢问“为什么?”和“如果怎么样?”,通常是按这个顺序。几天前,我在为工作重构一些代码时,就有了这样的时刻。“为什么?”我问,我们总是为单元测试创建单独的项目,“如果我们不这样做呢?如果我们把测试和代码内联编写呢?”

互联网上的某个地方,一定有位愤怒的开发者正猛拍桌子大喊“异端!”但请听我把话说完。我不是在主张“测试”项目是“错误”的,而是建议解决这个问题或许不止一种“正确”的方法。

测试项目的问题

虽然测试项目在几乎所有项目中都是标准配置,但它们并非没有代价。首先,我们正在创建一个额外的库,现在需要与我们的解决方案一起构建。这个库的布局通常应该遵循它所测试的库的布局(例如,命名空间和类名应该匹配)。该库还需要在框架方面与被测试的库保持同步(我们通常希望匹配.NET框架),如果测试库包含多个库的测试,那么我们就有责任确保它引用了其目标库引用的所有依赖库。如果我们不使用一个单独的测试项目,那么我们通过为解决方案中的每个库/应用程序创建一个新的测试项目,本质上将解决方案中的项目数量翻了一番。

从开发人员的角度来看(尤其是那些试图遵循TDD的人),他们现在每当想编写新代码时,都必须在两个独立的库中创建两个类,如果随后的重构导致类在库内(或完全移动到另一个库)移动,则必须小心确保测试项目反映了这一点。

这并不是说拥有测试项目是个坏主意。许多人每天都在使用它们,而且很少出问题。这里的重点是强调拥有它们是有成本的。这是大多数开发人员很少考虑的。

如果我们取消测试项目会怎样?

假设我们从解决方案中取消了测试项目。那会是什么样子呢?为了探索这个想法,我创建了一个简单的库,其中只包含一个类:Calculator(我知道这很老套)。代码如下所示:

namespace CalculatorLib
{
    using Xunit;

    public class Calculator 
    {
        public int Add(int a, int b)
        {
            throw new NotImplementedException();
        }
   
        public class Tests
        {
            // Create a sub-class for each method we want to test for organization.
            public class Add : Tests
            {
                [Fact]
                public void OnePlusOneEqualsTwo()
                {
                    // Arrange.
                    var calculator = new Calculator();

                    // Act.
                    int result = calculator.Add(1, 1);

                    // Assert.
                    Assert.Equal(2, result);
               }
            }
        }
    }
}
引用

注意:我在此示例中使用xunit。但是,也可以很容易地替换为nunit。

正如我们所看到的——单元测试现在与类本身内联。它们不需要单独的类。另请注意类本身的名称——“Tests”。由于这是一个内部类,我们不需要限定这是哪种测试类。在Reflector中查看此内容,您会注意到类本身的名称是“Calculator+Tests”。实际上,同样的思想贯穿到每个独立的测试中。因此,我们的单个单元测试实际上将具有限定名“CalculatorLib.Calculator+Tests+Add.OnePlusOneEqualsTwo()”。换句话说,测试是自组织的。

但我不想发布测试代码!

从前面的代码示例中可以很明显地看出,当库构建时,测试也会随之包含在内,同时还有引用的 xunit dll(稍后会详细介绍)。我们如何摆脱这些呢?最简单的方法可能就是使用条件编译。由于单元测试通常在调试代码中运行,并且公司很少将调试代码随官方产品一起发布,因此我们可以简单地使用一些恰当的 #if/#endif 块来标记我们的单元测试。让我们更新前面的代码示例来演示这一点:

namespace CalculatorLib
{
#if DEBUG
    using Xunit;
#endif

    public class Calculator 
    {
        public int Add(int a, int b)
        {
            throw new NotImplementedException();
        }

#if DEBUG
        public class Tests
        {
            // Create a sub-class for each method we want to test for organization.
            public class Add : Tests
            {
                [Fact]
                public void OnePlusOneEqualsTwo()
                {
                    // Arrange.
                    var calculator = new Calculator();

                    // Act.
                    int result = calculator.Add(1, 1);

                    // Assert.
                    Assert.Equal(2, result);
               }
            }
        }
#endif
    }
}

就这样。当我们在调试模式下编译代码时,测试将存在。然而,当在发布模式(或任何未定义 DEBUG 的模式)下编译时,它们将自动被剥离。

那些引用的DLL呢?

如果你在构建解决方案后查看“Release”文件夹,你会发现即使我们已经删除了所有测试,xunit 库仍然被复制到输出文件夹中。此外,如果你使用 JetBrains dotPeek 或 Redgate 的 Reflector 等工具检查 DLL 本身,应该会清楚地发现,即使这些库实际上并未被使用,它们仍然被引用。我们希望做的是,以某种方式配置我们的项目,使其只在调试模式下链接这些库,并忽略任何其他构建配置。

不幸的是,我们无法在 Visual Studio 中做到这一点。幸运的是,在 MSBuild 中实现这一点相当简单。

引用

注:本文的这一部分假设读者对 msbuild 有基本了解。读者无需成为专家,但对该工具的一些熟悉非常有价值。

我们首先需要做的是卸载项目,然后在 Visual Studio 中编辑它的 csproj 文件(VS 是理想选择,因为它会自动提供智能提示,尽管任何文本编辑器都可以)。

向下滚动一点,你应该会看到一个名为 <ItemGroup /> 的标签。在其中你会看到几个 <Reference /> 标签。如果你一直跟着操作,它应该看起来有点像这样:

  <ItemGroup>
    <Reference Include="System" />
    <Reference Include="System.Core" />
    <Reference Include="System.Xml.Linq" />
    <Reference Include="System.Data.DataSetExtensions" />
    <Reference Include="Microsoft.CSharp" />
    <Reference Include="System.Data" />
    <Reference Include="System.Net.Http" />
    <Reference Include="System.Xml" />
    <Reference Include="xunit.abstractions, Version=2.0.0.0, Culture=neutral, PublicKeyToken=8d05b1bb7a6fdb6c, processorArchitecture=MSIL">
      <HintPath>..\packages\xunit.abstractions.2.0.0\lib\net35\xunit.abstractions.dll</HintPath>
      <Private>True</Private>
    </Reference>
    <Reference Include="xunit.assert, Version=2.1.0.3179, Culture=neutral, PublicKeyToken=8d05b1bb7a6fdb6c, processorArchitecture=MSIL">
      <HintPath>..\packages\xunit.assert.2.1.0\lib\dotnet\xunit.assert.dll</HintPath>
      <Private>True</Private>
    </Reference>
    <Reference Include="xunit.core, Version=2.1.0.3179, Culture=neutral, PublicKeyToken=8d05b1bb7a6fdb6c, processorArchitecture=MSIL">
      <HintPath>..\packages\xunit.extensibility.core.2.1.0\lib\dotnet\xunit.core.dll</HintPath>
      <Private>True</Private>
    </Reference>
    <Reference Include="xunit.execution.desktop, Version=2.1.0.3179, Culture=neutral, PublicKeyToken=8d05b1bb7a6fdb6c, processorArchitecture=MSIL">
      <HintPath>..\packages\xunit.extensibility.execution.2.1.0\lib\net45\xunit.execution.desktop.dll</HintPath>
      <Private>True</Private>
    </Reference>
  </ItemGroup>

我们要做的是,如果配置*不是*调试模式,则排除 xunit 引用。最简单的方法是将它们放在自己的 <ItemGroup /> 元素中,并添加一个条件。例如:

  <ItemGroup>
    <Reference Include="System" />
    <Reference Include="System.Core" />
    <Reference Include="System.Xml.Linq" />
    <Reference Include="System.Data.DataSetExtensions" />
    <Reference Include="Microsoft.CSharp" />
    <Reference Include="System.Data" />
    <Reference Include="System.Net.Http" />
    <Reference Include="System.Xml" />
  </ItemGroup>

  <!-- Instruct MSBuild to include these only in the 'DEBUG' configuration. -->
  <ItemGroup Condition=" '$(Configuration)' == 'DEBUG' ">
    <Reference Include="xunit.abstractions, Version=2.0.0.0, Culture=neutral, PublicKeyToken=8d05b1bb7a6fdb6c, processorArchitecture=MSIL">
      <HintPath>..\packages\xunit.abstractions.2.0.0\lib\net35\xunit.abstractions.dll</HintPath>
      <Private>True</Private>
    </Reference>
    <Reference Include="xunit.assert, Version=2.1.0.3179, Culture=neutral, PublicKeyToken=8d05b1bb7a6fdb6c, processorArchitecture=MSIL">
      <HintPath>..\packages\xunit.assert.2.1.0\lib\dotnet\xunit.assert.dll</HintPath>
      <Private>True</Private>
    </Reference>
    <Reference Include="xunit.core, Version=2.1.0.3179, Culture=neutral, PublicKeyToken=8d05b1bb7a6fdb6c, processorArchitecture=MSIL">
      <HintPath>..\packages\xunit.extensibility.core.2.1.0\lib\dotnet\xunit.core.dll</HintPath>
      <Private>True</Private>
    </Reference>
    <Reference Include="xunit.execution.desktop, Version=2.1.0.3179, Culture=neutral, PublicKeyToken=8d05b1bb7a6fdb6c, processorArchitecture=MSIL">
      <HintPath>..\packages\xunit.extensibility.execution.2.1.0\lib\net45\xunit.execution.desktop.dll</HintPath>
      <Private>True</Private>
    </Reference>
  </ItemGroup>

继续重新加载解决方案,并尝试使用“调试”和“发布”配置重新构建项目。您会注意到所有 xunit 库都被复制到调试文件夹中,而它们没有被复制到发布文件夹中。

引用

注意:如果你安装了 xunit.visualstudio.runner nuget 包,你可能会在发布文件夹中发现一些 xunit dll。这些并不是项目引用的,而是作为包本身的一个产物被复制的。这可以通过在 dotPeek 或 Reflector 中检查 dll 来轻松确认。

这到底给我们带来了什么?

几件事。回顾一下:

  • 我们消除了为测试创建单独库的需要。
  • 我们不再需要同步库和测试库之间的依赖关系。
  • 测试库不可能再面向与库本身不同版本的 .net 框架。
  • 由于测试是建立在被测试类之上的,因此它们现在是自组织的。
  • 由于代码和测试在同一位置一起编写,TDD 变得更简单。
  • 重构代码现在简单得多。如果一个类被移动,测试也会自动随之移动。

我们可以更进一步吗?

当然可以!一个显而易见的方法是将这些 csproj 文件更改放到它们自己的 msbuild 文件中。这样我们就可以简单地将它们 <Import /> 到我们的 csproj 文件中,然后就完成了。另一个想法是将这些更改提取到 powershell 脚本中,并从中创建一个 nuget 包。这样,通过一个包,你可以确保所有库都使用相同的测试框架,并且项目文件可以自动修改以支持这一点,而无需任何开发人员干预。

最终想法

本文无意批评大多数开发人员所遵循的标准测试库实践。许多公司通过这种模式取得了巨大成功,且问题极少。话虽如此,我认为不断地问“为什么”我们以某种方式做事,以及“如果”我们尝试不同的方式,这很重要。就像户外烧烤一样,实验是关键。并非每个实验都会成功,但至少尝试新事物很重要。毕竟,AAA 单元测试在某个时候也只是一个“想法”。我很想听听是否有人有兴趣尝试这个。到目前为止,我只在小型项目上进行过实验,所以我很好奇它在规模化方面表现如何。

© . All rights reserved.