您还未登录! 登录 | 注册 | 帮助  

您的位置: 首页 > 软件测试技术 > 单元测试 > 正文

从可维护性角度分析怎么编写优秀的单元测试

发表于:2022-04-25 作者:echolc55873 来源:稀土掘金

可维护性是大多数开发者在编写单元测试时面对的最核心的问题之一,最终,随着项目的发展,测试可能会变得越来越难维护和理解,系统的每一个改变,即使没有缺陷,也可能导致测试失败。本文将从其它以下几个大的方面介绍怎么编写易维护的测试:

· 只测试公共方法;

· 删除重复的测试代码;

· 实施测试隔离。

当然这几个大的方面会包含很多小的测试技术介绍。

测试私有或受保护的方法

开发人员把方法设为私有方法或者受保护的,通常有一些理由,有时是为了隐藏细节,以便将来实现的变化不会影响外部功能,还有可能是出于安全性或者知识产权相关的原因考虑。

测试私有方法,你测试的是系统内部的一个契约,这个契约可能会变化,导致你的测试不可靠。而且私有方法既然被创建,它肯定会被其它公共方法调用,所以在测试其它公共 API 的时候,它会作为系统的一部分一起执行。所以当你测试私有 API 的时候,你应该找到调用私有方法的公有API,并针对这个 API 进行测试。如果一个 API 有需要单独测试的必要,那它应该被设置成公有 API。当然这不是说基础代码中不应该包含私有方法,使用 TDD 开发,通常会对公共方法编写测试,这些公共方法会被重构调用较小的私有方法,在此过程中,对公共的方法测试始终能通过,私有方法也得到了测试。

要提高测试可维护性,另一个方法是去除测试中的重复代码。

去除重复代码

单元测试中的重复代码和产品代码中的重复一样有害,带来维护性问题。DRY原则也适用于测试代码,重复代码意味当需要修改代码的时候需要重复性地修改多个地方,当测试类的函数变更或者使用类的语义变化就会对测试产生很大的影响。下面我们看一个例子:

  class LogAnalyzer {
  isValid (fileName: string) {
    if (fileName.length < 8) {
      return true
      }
      return false
    }
  }
 

该类对应的测试:

  test('LogAnalyzer isValid invalid fileName', () => {
    const logan = new LogAnalyzer()
    const res = logan.isValid('12345')
    expect(res).toBeFalsy()
  })
  test('LogAnalyzer isValid valid fileName', () => {
    const logan = new LogAnalyzer()
    const res = logan.isValid('12345789')
    expect(res).toBeTruthy()
  })

可能大家都觉得上面的测试没有什么问题,但是如果 LogAnalyzer 类的使用语义发生了变化,需要在使用任何 API 之前调用 init 方法,那么所有的测试都要修改。修改 LogAnalyzer实现:

  class LogAnalyzer {
    private initialized = false;
    
    init () {
    // 其它初始化逻辑
      this.initialized = true
    }
    
  isValid (fileName: string) {
      if (!this.initialized) {
      throw new Error('LogAnalyzer must be initialized')
      }
      
    if (fileName.length < 8) {
      return true
      }
      return false
    }
  }

在改造 LogAnalyzer 后,上面的两个测试都会失败。所以,我们需要重构上面例子中的测试,下面介绍几种重构的方式:

1.使用辅助方法去除重复代码,在上面的例子中,我们可以将 LoganAnalyzer 创建的逻辑,使用一个工厂方法封装起来:

  function createLogan () {
    const logan = new LogAnalyzer()
    logan.init()
    return logan
  }
  test('LogAnalyzer isValid invalid fileName', () => {
    const logan = createLogan()
    const res = logan.isValid('12345')
    expect(res).toBeFalsy()
  })

这样,我们将可能变化的部分单独隔离起来,使得测试的维护性提高。

2.使用 setup 方法去除重复代码

在单元测试框架中,一般都会提供可以让你初始化一些代码逻辑的钩子,比如在 Jest 中,就提供了 beforeEach 、 beforeAll 等,你可以通过这些钩子去消除重复的代码:

  let logan: LogAnalyzer
  beforeEach(() => {
    logan = new LogAnalyzer()
    logan.init()
  })
  test('LogAnalyzer isValid invalid fileName', () => {
    const res = logan.isValid('12345')
    expect(res).toBeFalsy()
  })
  test('LogAnalyzer isValid valid fileName', () => {
    const res = logan.isValid('12345789')
    expect(res).toBeTruthy()
  })

这种方式,就不需要在每个测试中单独写创建 logan 的逻辑。当然过度使用 setup 也不一定好,下面就要提到怎么编写可维护的 setup 方法。

以可维护的方式使用 setup 方法

setup 方法使用方便,这使得很多开发人员会滥用,导致测试可读性和可维护性下降。下面是使用 setup 方法的一些建议:

·setup 方法只用于需要进行初始化工作时。

· setup 方法并不总是去除重复的最佳方法,因为有些重复代码不是关于初始化创建的,而是其它断言相关的重复代码。

· setup 方法也有自己的局限,它不支持参数和返回值。

· setup 方法不能用作有返回值的工厂方法,它必须在测试执行前执行,因此工作方式必须更加通用。

· setup 方法应该只包含用于测试当前需要测试的工作单元所有测试的代码,否则该方法的逻辑就会变得难以阅读和理解。

下面是一些 setup 方法滥用的例子:

· 在 setup 方法中初始化只在某些测试中使用的对象;

· setup 代码冗长难懂;

· 在 setup 方法中准备模拟对象和伪对象。

虽然 setup 方便好用,但是我们也要在实际应用中用对,遵守最佳实践。

实施隔离测试

在单元测试中,阻碍测试最大的原因是缺乏测试隔离,测试隔离的基本概念就是:一个测试应该总是在自己的小世界中运行,与其它进行类似或不同的工作的测试隔离,它们之间不应该有任何交集。

如果它们没有隔离,它们会互相影响,会使得你非常悲惨。让你后悔在项目中添加单元测试,再也没有想写单元测试的欲望。因为互相影响的测试,当出现问题的时候,需要花很多时间才能找到。下面是一些反模式:

· 强制的测试顺序,测试需要以某种特定地顺序执行或者需要来自其他测试结果的信息;

· 隐藏的测试调用,测试调用其他测试;

· 共享状态损坏,测试共享内存里的状态,却没有回滚状态;

· 外部共享状态损坏,集成测试共享资源,却没有回滚资源。

上面这些反模式是在实施隔离测试的时候需要严格避免的。

避免对不同关注点多次断言

我们先来看个例子:

  test('multiple assert', () => {
    expect(sum(1001, 1, 2)).toBe(3)
    expect(sum(1, 1001, 2)).toBe(3)
    expect(sum(1, 2, 1001)).toBe(3)
  })

这个测试中包含了多个断言,相当于测试了三个不同的子功能。

尽管在一个测试中测试了三个不同的功能,避免了添加多个测试,节省了一点时间。但是这种做法有什么问题了?如果第一个断言失败了,将会抛出异常,下面的两个断言就不会在执行了,难道你在第一个测试断言失败了的时候,就不关心其它两个断言的结果了?可能有时是这样,但是大多数情况你还是想知道后面两个断言的结果。当然你也可以换个方式来写这个测试:

·给每个断言单独创建一个测试;

· 使用参数化测试;

· 把断言代码放在 try...catch... 块中。

第二种方式只有在一些后端编程语言的测试框架中支持,比如 Java 中的 JUnit 。那我们对比下另外两中方式。

可能有人会觉得把每个断言放在 try...catch 块中是个好主意,这样可以捕获异常,把异常输出到控制台,以避免其它断言测试异常带来的问题。但是使用 try...catch 块包裹每一个断言,会导致代码结构变得复杂,而且可读性也下降了。参数化测试也能解决这个问题,如果你的测试框架支持,使用参数化测试这种方式更好。

最佳的方式,尽量是每一个断言添加一个测试,如果添加多个断言,很可能你的测试就测试了多个关注点,也违反了测试的可靠性原则。

对象比较

有时候在一个测试中我们对同一个对象的多个方面进行断言测试,例如:

  test('test log info', () => {
    const logan = new LogAnalyzer()
    const output = logan.analyze('10:05\tOpen\tRoy')
    expect(output.getLine(1)[0]).toBe('10:05')
    expect(output.getLine(1)[1]).toBe('Open')
    expect(output.getLine(1)[2]).toBe('Roy')
  })

这个测试中验证了对象的每一个字段,这些验证都应该通过。但是为了提高可读性和可维护性,我们可以直接比较对象,而不是去断言对象的每一个属性:

  test('test log info', () => {
    const logan = new LogAnalyzer()
    const output = logan.analyze('10:05\tOpen\tRoy')
    const expected = ['10:05', 'Open', 'Roy']
    expect(output.getLine(1)).toEqual(expected)
  })

这样就不需要使用多个断言,也提高了测试代码的维护性。

避免过度指定

过度指定的测试对一个具体的被测试单元如何实现其内部行为进行了假设,而不是其最终行为。在单元测试中,有下面几种情形属于过度指定:

·测试对一个被测对象纯内部状态进行了断言;

· 测试使用多个模拟对象;

· 测试在需要存根时使用模拟对象;

· 测试在不必要的情况下指定顺序或使用了精确匹配。

指定纯内部行为

我们看一个例子:

  test('When init called, set default delimiter', () => {
    const log = new LogAnalyzer()
    expect(log.getInternalDelimiter()).toBe(null)
    log.init()
    expect(log.getInternalDelimiter()).toBe('\t')
  })

在这个例子中,测试了调用 LogAnalyzer 实例的 init 方法后,检验了其内部状态,而不是检验外部功能。单元测试应该测试对象的公共契约和公共功能,而上面的例子测试的代码不属于任何公共的契约或者公共功能。

在需要存根的时候使用模拟对象

在需要存根的时候使用了模拟对象是另一种常见的过度指定。

下面看一个例子:

  test('test isLogin retrun false if userinfo not existed', () => {
    const stubGetUser = jest.fn().mockImplementation(() => null)
    const login = new LoginManager({ getUser: stubGetUser })
    const res = login.isLogin()
    expect(res).toBeFalsy()
    expect(mockGetUser).toHaveBeenCalled()
  })

这个测试指定了存根 stubGetUser 跟 LoginManager 之间的交互,这种属于过度指定。在这个例子,测试代码应该只关注 isLogin 方法的结果值,这样测试不会过于脆弱。在上面的这种测试方法中,如果被测试方法增加了一个内部调用或者改变了调用参数,测试就会失败。如果最终结果不变,我们就不需要关心调用了什么内部方法,没有调用什么方法。

不必要的顺序指定或者精确匹配

开发人员有时候往往会犯的另一个错误就是:对工作单元的返回值或者属性中的硬编码字符串进行断言,但是实际上只需要验证字符串的一部分或者用 lessThan 、 greaterThan 等断言进行比较。对于字符串你可以用 indexOf ,对于数组也同样适用。

如果你的测试中有精准匹配,你可以对其做一些小调整,只要保证:字符串或者数组包含预期的值,或者有预期的长度,即使字符串顺序或者数据在数组中的顺序发生了改变,也不用让我们一个一个去调整测试。

总结

本文从测试私有或者受保护方法、去除重复代码、以可维护的方式使用 setup 方法、隔离测试、尽量避免多断言、对象比较、避免多度指定等七个方面对如何提高测试的可维护性进行了介绍,我们都知道可维护性对于软件开发的重要程度,那么对于测试也是如此,不可维护的测试反而带来不了理想的收益,使得团队成员对单元测试失去信心,拖慢开发的进度。希望本文能给大家在写单元测试的时候一些启发,从而写出可维护性的单元测测试。