【The Art of Unit Testing 3_自学笔记04】第二章:编写第一个单元测试(下)

文章目录

    • [2.6 试用 beforeEach() 消除冗余代码 Trying the beforeEach() route](#2.6 试用 beforeEach() 消除冗余代码 Trying the beforeEach() route)
      • [2.6.1 beforeEach() 的滚屏疲劳效应 beforeEach() and scroll fatigue](#2.6.1 beforeEach() 的滚屏疲劳效应 beforeEach() and scroll fatigue)
    • [2.7 尝试工厂方法消除冗余代码 Trying the factory method route](#2.7 尝试工厂方法消除冗余代码 Trying the factory method route)
    • [2.8 回到 test() 方法 Going full circle to test()](#2.8 回到 test() 方法 Going full circle to test())
    • [2.9 重构为参数化的测试 Refactoring to parameterized tests](#2.9 重构为参数化的测试 Refactoring to parameterized tests)
    • [2.10 对抛出错误的检查 Checking for expected thrown errors](#2.10 对抛出错误的检查 Checking for expected thrown errors)
    • [2.11 设置不同的测试配置 Setting test categories](#2.11 设置不同的测试配置 Setting test categories)

(接上篇)

2.6 试用 beforeEach() 消除冗余代码 Trying the beforeEach() route

利用 beforeEach() 可将上面的重复代码提取出来(L2-3、L5-9):

js 复制代码
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);
    });
  });
});

上述重构的问题:

  1. errors 数组未在 beforeEach() 方法中重置,后续会带来问题;
  2. Jest 默认以并行方式运行测试,可能导致 verifier 的值被并行运行的其他测试重写,从而破坏当前测试状态。

2.6.1 beforeEach() 的滚屏疲劳效应 beforeEach() and scroll fatigue

无论是查看 verifier 的声明还是对其添加的校验规则,it() 方法都无法直接提供相关信息,只能上翻到 beforeEach() 进行查看,然后再切回 it()。这将导致 滚屏疲劳(scroll fatigue 效应。beforeEach() 的设计或许对制作测试报表很有用,但对于需要不断查找某段代码出处的人而言无疑是痛苦的。

滥用 beforeEach(),可能陷入更严重、更不易重构的代码冗余。beforeEach() 往往会沦为测试文件的"垃圾桶",里面充斥着各种初始化逻辑------测试需要的东西、干扰其他测试的东西、甚至是没人使用的东西(未及时清理)。

比如,将 AAA 模式中的准备(Arrange)和执行(Act)丢给 beforeEach(),看似消除了冗余,其实导致了更严重的代码重复:

js 复制代码
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() 的弊端就显现出来了:

js 复制代码
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

此时可以尝试工厂方法,将校验工具的实例化和校验规则的配置都放进工厂方法里:

js 复制代码
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;
};

然后在测试用例中确保每个测试都按照 工具实例化校验输入执行断言 的结构进行重构,将得到更加紧凑的单元测试,同时滚屏疲劳的问题也得到了良好控制:

js 复制代码
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() 方法来编写测试用例。结合刚才的工厂方法,写作:

js 复制代码
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

js 复制代码
// 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

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() 方法,可以简化上述写法。

第一种:将 输入 以数组形式传入

js 复制代码
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);
  });
});

第二种:将 输入和期望值 以数组形式同时传入

js 复制代码
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 表格形式拼接(仅在源代码中展示,原书未介绍)

js 复制代码
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 的循环遍历来实现:

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 行):

js 复制代码
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 结构:

js 复制代码
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() 方法:

js 复制代码
test('verify, with no rules, throws exception', () => {
  const verifier = makeVerifier();
  expect(() => verifier.verify('any input'))
    .toThrowError(/no rules configured/);
});

关于 Jest 快照(snapshots)

主要用于 React 等框架,让当前渲染出的组件与该组件的一个快照进行比较。但由于不够直观,容易测试一些无关内容,可读性和可维护性都不高,因此 并不推荐使用。一旦不慎,还很容易造成滥用,让测试可信度大打折扣:

js 复制代码
it('renders',() => {
    expect(<MyComponent/>).toMatchSnapshot();
});

2.11 设置不同的测试配置 Setting test categories

可以通过两种方式让 Jest 启用不同的配置文件:

  • 命令行参数:使用 --testPathPattern 参数,详见 Jest 官方文档:https://jestjs.io/docs/cli
  • 使用独立的 npm 运行脚本。

第二种方法,先在各自的配置文件中设置好具体的配置内容(testRegex):

js 复制代码
// 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 脚本中进行配置:

json 复制代码
//Package.json
// ...
"scripts": {
  "unit": "jest -c jest.config.unit.js",
  "integ": "jest -c jest.config.integration.js"
// ...
相关推荐
yaosheng_VALVE11 小时前
探究全金属硬密封蝶阀的奥秘-耀圣控制
运维·eclipse·自动化·pyqt·1024程序员节
dami_king12 小时前
SSH特性|组成|SSH是什么?
运维·ssh·1024程序员节
一个通信老学姐5 天前
专业125+总分400+南京理工大学818考研经验南理工电子信息与通信工程,真题,大纲,参考书。
考研·信息与通信·信号处理·1024程序员节
sheng12345678rui5 天前
mfc140.dll文件缺失的修复方法分享,全面分析mfc140.dll的几种解决方法
游戏·电脑·dll文件·dll修复工具·1024程序员节
huipeng9266 天前
第十章 类和对象(二)
java·开发语言·学习·1024程序员节
earthzhang20217 天前
《深入浅出HTTPS》读书笔记(19):密钥
开发语言·网络协议·算法·https·1024程序员节
爱吃生蚝的于勒7 天前
计算机基础 原码反码补码问题
经验分享·笔记·计算机网络·其他·1024程序员节
earthzhang20217 天前
《深入浅出HTTPS》读书笔记(20):口令和PEB算法
开发语言·网络协议·算法·https·1024程序员节
一个通信老学姐8 天前
专业140+总分410+浙江大学842信号系统与数字电路考研经验浙大电子信息与通信工程,真题,大纲,参考书。
考研·信息与通信·信号处理·1024程序员节
earthzhang20218 天前
《深入浅出HTTPS》读书笔记(18):公开密钥算法RSA(续)
网络·网络协议·算法·https·1024程序员节