文章目录
- 2.6 试用 beforeEach() 消除冗余代码 Trying the beforeEach() route
- 2.6.1 beforeEach() 的滚屏疲劳效应 beforeEach() and scroll fatigue
- 2.7 尝试工厂方法消除冗余代码 Trying the factory method route
- 2.8 回到 test() 方法 Going full circle to test()
- 2.9 重构为参数化的测试 Refactoring to parameterized tests
- 2.10 对抛出错误的检查 Checking for expected thrown errors
- 2.11 设置不同的测试配置 Setting test categories
(接上篇)
2.6 试用 beforeEach() 消除冗余代码 Trying the beforeEach() route
利用 beforeEach()
可将上面的重复代码提取出来(L2-3、L5-9):
describe('PasswordVerifier', () => {let verifier;beforeEach(() => verifier = new PasswordVerifier1()); // 创建一个 verifier 实例供每个测试用例引用describe('with a failing rule', () => {let fakeRule, errors;beforeEach(() => {fakeRule = input => ({ passed: false, reason: 'fake reason' }); // 在当前 describe 方法内创建一个伪规则备用verifier.addRule(fakeRule);});it('has an error message based on the rule.reason', () => {errors = verifier.verify('any value');expect(errors[0]).toContain('fake reason');});it('has exactly one error', () => {const errors = verifier.verify('any value');expect(errors.length).toBe(1);});});
});
上述重构的问题:
errors
数组未在beforeEach()
方法中重置,后续会带来问题;Jest
默认以并行方式运行测试,可能导致verifier
的值被并行运行的其他测试重写,从而破坏当前测试状态。
2.6.1 beforeEach() 的滚屏疲劳效应 beforeEach() and scroll fatigue
无论是查看 verifier
的声明还是对其添加的校验规则,it()
方法都无法直接提供相关信息,只能上翻到 beforeEach()
进行查看,然后再切回 it()
。这将导致 滚屏疲劳(scroll fatigue) 效应。beforeEach()
的设计或许对制作测试报表很有用,但对于需要不断查找某段代码出处的人而言无疑是痛苦的。
滥用 beforeEach()
,可能陷入更严重、更不易重构的代码冗余。beforeEach()
往往会沦为测试文件的“垃圾桶”,里面充斥着各种初始化逻辑——测试需要的东西、干扰其他测试的东西、甚至是没人使用的东西(未及时清理)。
比如,将 AAA
模式中的准备(Arrange
)和执行(Act
)丢给 beforeEach()
,看似消除了冗余,其实导致了更严重的代码重复:
describe('PasswordVerifier', () => {let verifier;beforeEach(() => verifier = new PasswordVerifier1());describe('with a failing rule', () => {let fakeRule, errors;beforeEach(() => {fakeRule = input => ({ passed: false, reason: 'fake reason' });verifier.addRule(fakeRule);errors = verifier.verify('any value');});it('has an error message based on the rule.reason', () => {expect(errors[0]).toContain('fake reason');});it('has exactly one error', () => {expect(errors.length).toBe(1);});});
});
此时再加几个测试,滥用 beforeEach()
的弊端就显现出来了:
describe('PasswordVerifier', () => {let verifier;beforeEach(() => verifier = new PasswordVerifier1());describe('with a failing rule', () => {let fakeRule, errors;beforeEach(() => {fakeRule = input => ({ passed: false, reason: 'fake reason' });verifier.addRule(fakeRule);errors = verifier.verify('any value');});it('has an error message based on the rule.reason', () => {expect(errors[0]).toContain('fake reason');});it('has exactly one error', () => {expect(errors.length).toBe(1);});});describe('with a passing rule', () => {let fakeRule, errors;beforeEach(() => {fakeRule = input => ({ passed: true, reason: '' });verifier.addRule(fakeRule);errors = verifier.verify('any value');});it('has no errors', () => {expect(errors.length).toBe(0);});});describe('with a failing and a passing rule', () => {let fakeRulePass, fakeRuleFail, errors;beforeEach(() => {fakeRulePass = input => ({ passed: true, reason: 'fake success' });fakeRuleFail = input => ({ passed: false, reason: 'fake reason' });verifier.addRule(fakeRulePass);verifier.addRule(fakeRuleFail);errors = verifier.verify('any value');});it('has one error', () => {expect(errors.length).toBe(1);});it('error text belongs to failed rule', () => {expect(errors[0]).toContain('fake reason');});});
});
此时不仅冗余严重,滚动疲劳效应也更显著了。因此 beforeEach()
在作者这里很不受待见。
2.7 尝试工厂方法消除冗余代码 Trying the factory method route
此时可以尝试工厂方法,将校验工具的实例化和校验规则的配置都放进工厂方法里:
const makeVerifier = () => new PasswordVerifier1();
const passingRule = (input) => ({ passed: true, reason: '' });const makeVerifierWithPassingRule = () => {const verifier = makeVerifier();verifier.addRule(passingRule);return verifier;
};const makeVerifierWithFailedRule = (reason) => {const verifier = makeVerifier();const fakeRule = input => ({ passed: false, reason: reason });verifier.addRule(fakeRule);return verifier;
};
然后在测试用例中确保每个测试都按照 工具实例化、校验输入、执行断言 的结构进行重构,将得到更加紧凑的单元测试,同时滚屏疲劳的问题也得到了良好控制:
describe('PasswordVerifier', () => {describe('with a failing rule', () => {it('has an error message based on the rule.reason', () => {const verifier = makeVerifierWithFailedRule('fake reason');const errors = verifier.verify('any input');expect(errors[0]).toContain('fake reason');});it('has exactly one error', () => {const verifier = makeVerifierWithFailedRule('fake reason');const errors = verifier.verify('any input');expect(errors.length).toBe(1);});});describe('with a passing rule', () => {it('has no errors', () => {const verifier = makeVerifierWithPassingRule();const errors = verifier.verify('any input');expect(errors.length).toBe(0);});});describe('with a failing and a passing rule', () => {it('has one error', () => {const verifier = makeVerifierWithFailedRule('fake reason');verifier.addRule(passingRule);const errors = verifier.verify('any input');expect(errors.length).toBe(1);});it('error text belongs to failed rule', () => {const verifier = makeVerifierWithFailedRule('fake reason');verifier.addRule(passingRule);const errors = verifier.verify('any input');expect(errors[0]).toContain('fake reason');});});
});
可以看到,重构后的测试代码不含 beforeEach()
方法,所有相关信息都可以从 it()
方法直接获取。这里的关键,是将各测试的状态严格限制在 it()
方法内,而不是放在嵌套的 describe()
方法内。
2.8 回到 test() 方法 Going full circle to test()
如果只要求简洁,对测试的结构性和层次性要求不高,则可以用 test()
方法来编写测试用例。结合刚才的工厂方法,写作:
test('pass verifier, with failed rule, has an error message based on the rule.reason', () => {const verifier = makeVerifierWithFailedRule('fake reason');const errors = verifier.verify('any input');expect(errors[0]).toContain('fake reason');
});
test('pass verifier, with failed rule, has exactly one error', () => {const verifier = makeVerifierWithFailedRule('fake reason');const errors = verifier.verify('any input');expect(errors.length).toBe(1);
});
test('pass verifier, with passing rule, has no errors', () => {const verifier = makeVerifierWithPassingRule();const errors = verifier.verify('any input');expect(errors.length).toBe(0);
});
test('pass verifier, with passing and failing rule, has one error', () => {const verifier = makeVerifierWithFailedRule('fake reason');verifier.addRule(passingRule);const errors = verifier.verify('any input');expect(errors.length).toBe(1);
});
test('pass verifier, with passing and failing rule, error text belongs to failed rule', () => {const verifier = makeVerifierWithFailedRule('fake reason');verifier.addRule(passingRule);const errors = verifier.verify('any input');expect(errors[0]).toContain('fake reason');
});
那种写法更合适,需要自行决定。
2.9 重构为参数化的测试 Refactoring to parameterized tests
所谓 参数化的测试(parameterized tests),就是一种特殊的软件测试技术,用于在同一测试用例中运行多个不同的 输入组合,从而有效地减少代码冗余、提高测试的覆盖率和可维护性。
Jest
支持好几种参数化测试,书中介绍了两个,随书源码则给出了三个。首先构造一个新的目标函数 oneUpperCaseRule
:
// password-rules.js
const oneUpperCaseRule = (input) => {return {passed: (input.toLowerCase() !== input),reason: 'at least one upper case needed'};
};module.exports = {oneUpperCaseRule
};
然后导入单元测试文件 __tests__/password-rules.spec.js
:
const { oneUpperCaseRule } = require('../password-rules');describe('v1 one uppercase rule', () => {test('given no uppercase, it fails', () => {const result = oneUpperCaseRule('abc');expect(result.passed).toEqual(false);});test('given one uppercase, it passes', () => {const result = oneUpperCaseRule('Abc');expect(result.passed).toEqual(true);});test('given a different uppercase, it passes', () => {const result = oneUpperCaseRule('aBc');expect(result.passed).toEqual(true);});
});
可以看到,上述测试用例出现了大量代码冗余,这里其实只测试了一种情况:对存在大写字母的输入内容进行测试。
为此,Jest
提供了 test.each()
方法,可以简化上述写法。
第一种:将 输入 以数组形式传入
describe('v2 one uppercase rule', () => {test('given no uppercase, it fails', () => {const result = oneUpperCaseRule('abc');expect(result.passed).toEqual(false);});test.each(['Abc','aBc'])('given one uppercase, it passes', (input) => {const result = oneUpperCaseRule(input);expect(result.passed).toEqual(true);});
});
第二种:将 输入和期望值 以数组形式同时传入
describe('v3 one uppercase rule', () => {test.each([['Abc', true],['aBc', true],['abc', false]])('given %s, %s ', (input, expected) => {const result = oneUpperCaseRule(input);expect(result.passed).toEqual(expected);});
});
第三种:将 输入和期望值 以 Jest
表格形式拼接(仅在源代码中展示,原书未介绍)
describe('v4 one uppercase rule, with the fancy jest table input', () => {test.each`input | expected${'Abc'} | ${true}${'aBc'} | ${true}${'abc'} | ${false}`('given $input', ({ input, expected }) => {const result = oneUpperCaseRule(input);expect(result.passed).toEqual(expected);});
});
注意:第三种写法中的模板字符串两边 没有使用 小括号!!
如果选用的测试框架不支持参数化测试的语法,也可以借助原生 JS 的循环遍历来实现:
describe('v5 one uppercase rule, with vanilla JS test.each', () => {const tests = {Abc: true,aBc: true,abc: false};for (const [input, expected] of Object.entries(tests)) {test(`given ${input}, ${expected}`, () => {const result = oneUpperCaseRule(input);expect(result.passed).toEqual(expected);});}
});
警告
参数化测试固然方便,使用不当则可能严重降低测试的可读性与可维护性(双刃剑)。
2.10 对抛出错误的检查 Checking for expected thrown errors
改造 verify()
方法,让它抛出一个错误(第 12 行):
class PasswordVerifier1 {constructor () {this.rules = [];}addRule (rule) {this.rules.push(rule);}verify (input) {if (this.rules.length === 0) {throw new Error('There are no rules configured');}const errors = [];this.rules.forEach(rule => {const result = rule(input);if (result.passed === false) {errors.push(result.reason);}});return errors;}
}module.exports = { PasswordVerifier1 };
这类测试的一种传统写法,是放入 try-catch
结构:
test('verify, with no rules, throws exception', () => {const verifier = makeVerifier();try {verifier.verify('any input');fail('error was expected but not thrown');} catch (e) {expect(e.message).toContain('no rules configured');}
});
上述代码中的 fail()
函数是 Jasmine
框架的历史遗留 API,目前已不在 Jest
官方 API 文档中维护,官方建议用 expect.assertions(1)
进行替换,并且在未触发 catch()
块运行时让测试不通过。不推荐使用 fail()
及 try-catch
结构。
推荐写法:从 expect()
断言中调用 toThrowError()
方法:
test('verify, with no rules, throws exception', () => {const verifier = makeVerifier();expect(() => verifier.verify('any input')).toThrowError(/no rules configured/);
});
关于
Jest
快照(snapshots)主要用于
React
等框架,让当前渲染出的组件与该组件的一个快照进行比较。但由于不够直观,容易测试一些无关内容,可读性和可维护性都不高,因此 并不推荐使用。一旦不慎,还很容易造成滥用,让测试可信度大打折扣:it('renders',() => {expect(<MyComponent/>).toMatchSnapshot(); });
2.11 设置不同的测试配置 Setting test categories
可以通过两种方式让 Jest
启用不同的配置文件:
- 命令行参数:使用
--testPathPattern
参数,详见Jest
官方文档:https://jestjs.io/docs/cli - 使用独立的
npm
运行脚本。
第二种方法,先在各自的配置文件中设置好具体的配置内容(testRegex
):
// jest.config.integration.js
var config = require('./jest.config');
config.testRegex = "integration\\.js$";
module.exports = config;// jest.config.unit.js
var config = require('./jest.config');
config.testRegex = "unit\\.js$";
module.exports = config;
然后在 npm
脚本中进行配置:
//Package.json
// ...
"scripts": {"unit": "jest -c jest.config.unit.js","integ": "jest -c jest.config.integration.js"
// ...