从可维护性角度分析怎么编写优秀的单元测试(下)
以可维护的方式使用 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)).toBe('10:05')
expect(output.getLine(1)).toBe('Open')
expect(output.getLine(1)).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 方法、隔离测试、尽量避免多断言、对象比较、避免多度指定等七个方面对如何提高测试的可维护性进行了介绍,我们都知道可维护性对于软件开发的重要程度,那么对于测试也是如此,不可维护的测试反而带来不了理想的收益,使得团队成员对单元测试失去信心,拖慢开发的进度。希望本文能给大家在写单元测试的时候一些启发,从而写出可维护性的单元测测试。
页:
[1]