【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"
// ...
相关推荐
fangzhanpeng1684 天前
快速掌握C#语言基础知识点(1.基础数据类型)
开发语言·c#·1024程序员节
无己心6 天前
面向多平台 AI 搜索引擎的适配层架构设计
前端·人工智能·搜索引擎·ai·1024程序员节
u01030552712 天前
AI驱动的安卓UI自动生成功能实现
人工智能·1024程序员节
u01030552712 天前
长株潭智能体赋能腾讯元器链接共享
1024程序员节
云贝贝贝14 天前
OceanBase认证体系介绍与备考指南
运维·服务器·oceanbase·1024程序员节
u01030552714 天前
Java图像处理实战指南
人工智能·1024程序员节
u01030552714 天前
Java AWT鼠标事件全解析
人工智能·1024程序员节
u01030552717 天前
Java实用类应用精讲
人工智能·1024程序员节
u01030552717 天前
Java I/O核心操作实战
人工智能·1024程序员节
u01030552718 天前
ArrayList操作详解与实战应用
人工智能·1024程序员节