简而言之,JUnit:测试隔离

作为顾问,我仍然经常遇到程序员,他们对JUnit及其正确用法的理解最多。 这使我有了编写多部分教程的想法,从我的角度解释了要点。

尽管存在一些有关使用该工具进行测试的好书和文章,但是也许可以通过本动手实践的方法来使一两个额外的开发人员对单元测试感兴趣,这将使他们值得付出努力。

注意,本章的重点是基本的单元测试技术,而不是JUnit功能或API。 在后面的帖子中将讨论更多后者。 用于描述这些技术的术语是基于Meszaros的xUnit测试模式 [MES]中提供的定义。

以前简而言之在JUnit上

本教程从“ Hello World”一章开始,介绍了测试的基本知识:如何编写,执行和评估它。 它继续进行后期测试结构 ,解释了通常用于构建单元测试的四个阶段(设置,练习,验证和拆卸)。

这些课程还附有一个一致的示例,以使抽象概念更易于理解。 它被证明了,一个测试用例是如何一点一点地增长的-从幸福的道路开始到极端的案例测试,包括预期的例外。

总的来说,要强调的是,测试不仅是简单的验证机,还可以作为一种低级规范。 因此,应该以人们可能想到的最高编码标准来开发它。

依存关系

一个巴掌拍不响
谚语

本教程中使用的示例都是关于编写一个简单的数字范围计数器,该计数器从给定值开始传递一定数量的连续整数。 指定单元行为的测试用例可能在摘录中看起来像这样:

public class NumberRangeCounterTest {
  
  private static final int LOWER_BOUND = 1000;
  private static final int RANGE = 1000;
  private static final int ZERO_RANGE = 0;
  
  private NumberRangeCounter counter
    = new NumberRangeCounter( LOWER_BOUND, RANGE );
  
  @Test
  public void subsequentNumber() {
    int first = counter.next();
    int second = counter.next();
    
    assertEquals( first + 1, second );
  }
  
  @Test
  public void lowerBound() {
    int actual = counter.next();
    
    assertEquals( LOWER_BOUND, actual );
  }
  
  @Test( expected = IllegalStateException.class )
  public void exeedsRange() {
    new NumberRangeCounter( LOWER_BOUND, ZERO_RANGE ).next();
  }

  [...]
}

注意,这里我使用了一个非常紧凑的测试用例,以节省空间,例如使用隐式夹具设置和异常验证。 有关测试结构化模式的详细讨论,请参见上一章

还要注意,我坚持使用JUnit内置功能进行验证。 我将在另一篇文章中介绍特定匹配器库( HamcrestAssertJ )的优缺点。

虽然NumberRangeCounter的初始描述足以使本教程开始,但细心的读者可能已经注意到,该方法显然有些幼稚。 例如,考虑程序的进程可能会终止。 为了能够在系统重新启动时正确地重新初始化计数器,它至少应保留其最新状态。

但是,保持计数器的状态涉及通过不属于单元(也就是被测系统(SUT))的软件组件(数据库驱动程序,文件系统API等)访问资源(数据库,文件系统等)。 这意味着单位取决于这些组件,Meszaros用术语“ 依赖组件”(DOC)描述

不幸的是,这在许多方面带来了与测试有关的麻烦:

  1. 根据我们无法控制的组件,可能会阻碍对测试规范的体面验证。 试想一下有时可能不可用的真实世界的Web服务。 尽管SUT本身可以正常工作,但这可能是导致测试失败的原因。
  2. DOC也可能会减慢测试的执行速度。 为了使单元测试能够充当安全网 ,正在开发的系统的完整测试套件必须经常执行。 仅当每个测试运行得很快时,这才可行。 再次考虑Web服务示例。
  3. 最后但并非最不重要的一点是,例如,由于使用了较新版本的第三方库,DOC的行为可能会意外更改。 这说明了如何直接依赖我们无法控制的组件使测试变得脆弱

那么,我们该如何解决这个问题呢?

隔离–单元测试员的SEP字段

所谓SEP是我们不能看,或者不看,还是我们的大脑并没有让我们看到的,因为我们认为这公司的S omebodyËLSEP&roblem ...。
福特长官

由于我们不希望单元测试依赖于DOC的行为,也不希望它们过慢或脆弱,因此我们努力使我们的单元尽可能不受软件所有其他部分的影响。 简单地说,我们使这些特殊问题成为其他测试类型的关注点-因此开玩笑的SEP Field报价。

通常,此原理称为SUT隔离,表示希望分别测试关注点并保持测试彼此独立 。 实际上,这意味着应该以一种可以将每个DOC替换为所谓的Test Double的方式来设计单元, Test DoubleTest [MES1]的轻量级替代组件。

与我们的示例相关,我们可能决定不直接从单元本身内部访问数据库,文件系统等。 相反,我们可以选择将此问题分为屏蔽接口类型,而不必关心具体实现的外观。

尽管从低级设计的角度来看,这种选择当然也是合理的,但它并不能说明在整个测试过程中如何创建,安装和使用双重测试。 但是在详细介绍如何使用双打之前,还有一个主题需要讨论。

间接输入和输出

输入输出

到目前为止,我们的测试工作仅以SUT的直接输入和输出面对我们。 即, NumberRangeCounter每个实例都配有一个下限和一个范围值(直接输入)。 并且在每次调用next() ,SUT返回一个值或引发一个异常(直接输出),用于验证SUT的预期行为。

但是现在情况变得更加复杂了。 考虑到DOC为SUT初始化提供了最新的计数器值, next()的结果取决于该值。 如果DOC以这种方式提供SUT输入,我们将讨论间接输入

相反,假设next()每次调用都应保持计数器的当前状态,则我们没有机会通过SUT的直接输出进行验证。 但是我们可以检查计数器的状态是否已委托给DOC。 这种委托称为间接输出

有了这些新知识,我们应该准备继续进行NumberRangeCounter示例。

使用存根控制间接输入

从我们学到的知识来看,将计数器的状态保存分为自己的类型可能是个好主意。 这种类型会将SUT与实际的存储实现隔离开来,因为从SUT的角度来看,我们对如何实际解决保留问题不感兴趣。 因此,我们引入了CounterStorage接口。

尽管到目前为止还没有真正的存储实现,但我们可以使用测试倍数来代替。 由于接口尚无方法,因此此时创建测试双重类型很简单。

public class CounterStorageDouble implements CounterStorage {
}

为了以松散耦合的方式为NumberRangeCounter提供存储,我们可以使用依赖注入 。 通过两次存储测试来增强隐式夹具设置,然后将其注入到SUT中,如下所示:

private CounterStorage storage;

  @Before
  public void setUp() {
    storage = new CounterStorageDouble();
    counter = new NumberRangeCounter( storage, LOWER_BOUND, RANGE );
  }

修复编译错误并运行所有测试后,该栏应保持绿色,因为我们尚未更改任何行为。 但是现在我们希望对NumberRangeCounter#next()的第一次调用尊重存储的状态。 如果存储提供的值n在计数器的定义范围内,则next()的第一次调用也应返回n ,这由以下测试表示:

private static final int IN_RANGE_NUMBER = LOWER_BOUND + RANGE / 2;

  [...]

  @Test
  public void initialNumberFromStorage() {
    storage.setNumber( IN_RANGE_NUMBER );
    
    int actual = counter.next();
    
    assertEquals( IN_RANGE_NUMBER, actual );
  }

我们的测试IN_RANGE_NUMBER必须提供确定性的间接输入,在我们的情况下为IN_RANGE_NUMBER 。 因此,它使用setNumber(int)来配备值。 但是由于尚未使用存储,因此测试失败。 要更改此设置,是时候声明CounterStorage的第一个方法了:

public interface CounterStorage {
  int getNumber();
}

这使我们可以像这样实现双重测试:

public class CounterStorageDouble implements CounterStorage {

  private int number;

  public void setNumber( int number ) {
    this.number = number;
  }

  @Override  
  public int getNumber() {
    return number;
  }
}

如您所见,double通过返回由setNumber(int)馈送的配置值来实现getNumber() setNumber(int) 。 以这种方式提供间接输入的测试双称为存根 。 现在,我们将能够实现NumberRangeCounter的预期行为并通过测试。

如果您认为get / setNumber用不好的名字来描述存储的行为,我同意。 但这简化了职位的演变。 请感到受邀提出构思周到的重构建议…

间谍的间接输出验证

为了能够在系统重启后恢复NumberRangeCounter实例,我们希望计数器的每个状态更改都将保留。 这可以通过在每次调用next()时将当前状态分配到存储中来实现。 因此,我们向DOC类型添加了一个setNumber(int)方法:

public interface CounterStorage {
  int getNumber();
  void setNumber( int number );
}

新方法与用于配置存根的签名具有相同的签名,这真是一个奇怪的巧合! 在使用@Override修改该方法后,很容易将我们的夹具设置重新用于以下测试:

@Test
  public void storageOfStateChange() {
    counter.next();
    
    assertEquals( LOWER_BOUND + 1, storage.getNumber() );
  }

与初始状态相比,我们期望在调用next()之后,计数器的新状态将增加一个。 更重要的是,我们希望这个新状态作为间接输出传递到存储DOC。 不幸的是,我们没有看到实际的调用,因此我们在double的局部变量中记录了调用的结果。

如果记录的值与预期值相匹配,则验证阶段将推断出正确的间接输出已传递到DOC。 上面以最简单的方式描述的记录状态和/或行为以供以后验证,也称为间谍。 因此,使用这种技术的测试两倍被称为间谍

那Mo子呢?

还有一种可能通过使用模拟来验证next()的间接输出。 这种类型的double的最重要的特征是,间接输出验证是在委托方法内部执行的。 此外,它还可以确保实际调用了预期的方法:

public class CounterStorageMock implements CounterStorage {

  private int expectedNumber;
  private boolean done;

  public CounterStorageMock( int expectedNumber ) {
    this.expectedNumber = expectedNumber;
  }

  @Override
  public void setNumber( int actualNumber ) {
    assertEquals( expectedNumber, actualNumber );
    done = true;
  }

  public void verify() {
    assertTrue( done );
  }

  @Override
  public int getNumber() {
    return 0;
  }
}

CounterStorageMock实例通过构造函数参数配置了期望值。 如果setNumber(int) ,则立即检查给定值是否与预期值匹配。 一个标志存储该方法已被调用的信息。 这允许使用verify()方法检查实际的调用。

这就是使用模拟的storageOfStateChange测试的外观:

@Test
  public void storageOfStateChange() {
    CounterStorageMock storage
      = new CounterStorageMock( LOWER_BOUND + 1 );
    NumberRangeCounter counter
      = new NumberRangeCounter( storage, LOWER_BOUND, RANGE );

    counter.next();
    
    storage.verify();
  }

如您所见,测试中没有剩下任何规格验证。 通常的测试结构有些扭曲,这似乎很奇怪。 这是因为验证条件是在夹具设置中间的运动阶段之前指定的。 验证阶段仅保留模拟调用检查。

但是作为回报,模拟可以在行为验证失败的情况下提供精确的堆栈跟踪,这可以简化问题分析。 如果再次查看间谍解决方案,您将认识到失败跟踪只会指向测试的验证部分。 没有关于实际上导致测试失败的生产代码行的信息。

这与模拟完全不同。 跟踪将使我们能够准确识别setNumber(int)调用位置。 有了这些信息,我们可以轻松地设置断点并调试问题。

由于这篇文章的范围,我只限于对存根,间谍和模拟进行双重测试。 有关其他类型的简短说明,您可以查看Martin Fowler的文章TestDouble ,但是可以在Meszaros的xUnit测试模式书[MES]中找到所有类型及其变型的深入说明。

Tomek Kaczanowski的书《 使用JUnit和Mockito [KAC]进行实际单元测试 》中可以找到基于测试双重框架的模拟与间谍的良好比较(请参阅下一节)。

阅读本节后,您可能会觉得编写所有这些测试双打是繁琐的工作。 毫不奇怪,已编写库来大大简化双重处理。

测试双重框架–应许之地?

如果您只有锤子,那么一切看起来就像钉子
谚语

开发了一些框架以简化使用测试双打的任务。 不幸的是,就精确的测试双重术语而言,这些库并不总是一件好事。 例如, JMockEasyMock专注于模拟 ,而Mockito却以间谍为中心。 也许这就是为什么大多数人都在谈论嘲笑 ,而不管他们实际上在使用哪种类型的双人间。

但是,有迹象表明 ,Mockito当时是首选的双重测试工具。 我猜这是因为它提供了良好的阅读流利的接口API,并通过提供详细的验证失败消息来弥补上述间谍提及的缺点。

无需详细介绍,我提供了一个storageOfStateChange()测试版本,该版本使用Mockito进行间谍创建和测试验证。 请注意, mockverifyMockito类型的静态方法。 通常的做法是将静态导入与Mockito表达式一起使用以提高可读性:

@Test
  public void storageOfStateChange() {
    CounterStorage storage = mock( CounterStorage.class );
    NumberRangeCounter counter 
      = new NumberRangeCounter( storage, LOWER_BOUND, RANGE );
    
    counter.next();

    verify( storage ).setNumber( LOWER_BOUND + 1 );
  }

关于是否使用此类工具的文章很多。 例如,罗伯特·C·马丁(Robert C. Martin) 更喜欢手写双打 ,迈克尔·博尔迪沙(Michael Boldischar)甚至认为嘲笑框架有害 。 在我看来,后者只是在简单地滥用 ,而我一次不同意马丁所说的“写那些嘲笑是微不足道的”。

在发现Mockito之前,我多年来一直在使用手写双打。 立刻,我被卖给了流利的存根语法 (一种直观的验证方式),我认为摆脱那些笨拙的双精度类型是一种改进。 但这当然是情人眼中的。

但是,我经历了双重测试工具的诱惑,诱使开发人员过度操作。 例如,用双倍替换第三方组件非常容易,否则创建起来可能会很昂贵。 但这被认为是不好的做法, 史蒂夫·弗里曼Steve Freeman)纳特·普赖斯Nat Pryce )详细解释了为什么模拟自己拥有的类型 [FRE_PRY]。

第三方代码要求进行集成测试和抽象适配器层 。 后者实际上就是我们在示例中通过引入CounterStorage所指示的内容。 而且,由于我们拥有适配器,因此可以安全地将其替换为双适配器。

一个容易进入的第二个陷阱是编写测试,其中一个测试双精度返回另一个测试双精度。 如果到了这一点,您应该重新考虑正在使用的代码的设计。 这可能会破坏demeter定律 ,这意味着对象耦合在一起的方式可能有问题。

最后但并非最不重要的一点是,如果您考虑使用双重测试框架,则应记住,这通常是影响整个团队的长期决策。 由于代码风格的一致性,混合使用不同的框架可能不是最好的主意,即使您仅使用一种框架,每个(新)成员也必须学习特定于工具的API。

在开始广泛使用双打测试之前,您可能会考虑阅读比较经典测试与模拟测试的马丁·福勒的“ 莫克斯不是存根” ,或罗伯特·C·马丁的“ 何时模拟” ,其中介绍了一些启发式方法,以找出双打和太多之间的黄金比例。加倍。 或如Tomek Kaczanowski所说:

“很高兴您可以嘲笑一切,是吗? 放慢速度,并确保您确实需要验证交互。 你可能没有。 [KAC1]

结论

简而言之,JUnit的这一章讨论了单元依赖性对测试的影响。 它说明了隔离的原理,并说明了如何通过用测试双倍替换DOC来将其付诸实践。 在这种情况下,提出了间接输入和输出的概念,并描述了其与测试的相关性。

该示例通过动手示例加深了知识,并介绍了几种测试double类型及其使用目的。 最后,简短介绍了测试双重框架及其优缺点,从而结束了本章。 希望它具有足够的平衡性,可以使您对该主题有一个全面的了解,而又不致于琐碎。 改进建议当然受到高度赞赏。

本教程的下一篇文章将介绍Runners和Rules等JUnit功能并通过正在进行的示例展示如何使用它们。

参考文献

[MES] xUnit测试模式,Gerard Meszaros,2007年


[MES1] xUnit测试模式,第5章,原理:隔离SUT,Gerard Meszaros,2007年


[KAC]使用JUnit和Mockito进行实用单元测试,附录C。TestSpy vs. Mock,Tomek Kaczanowski,2013年

[KAC1]错误测试,良好测试,第4章,可维护性,托梅克·卡扎诺夫斯基,2013年

[FRE_PRY]不断增长的面向对象软件,由测试指导,第8章,史蒂夫·弗里曼(Steve Freeman),纳特·普莱斯(Nat Pryce),2010年

翻译自: https://www.javacodegeeks.com/2014/09/junit-in-a-nutshell-test-isolation.html

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值