阅读 350
【前端】重构,有品位的代码 02 ── 构筑测试体系

【前端】重构,有品位的代码 02 ── 构筑测试体系

写在前面

代码重构是一个产品不断的功能迭代过程中,不可避免的一项工作。所谓重构,是在不改变程序的输入输出即保证现有功能的情况下,对内部实现进行优化和调整。这是《重构,有品位的代码》系列的第二篇文章,上一篇是《重构,有品位的代码 01 ── 走上重构之道》

构建测试体系

工欲善其事,必先利其器

重构是很有价值很重要的工具,能够让我们增强代码的可读性和鲁棒性。但是光有重构也不行,因为我们在重构过程中会存在难以避免的纰漏,所以构建一套完善稳固的测试体系是很有必要的。

自测代码很重要

在进行项目开发中,进行编写代码的时间花费比较少,但是修改BUG时对其进行查找所花费的时间成本比较高,对于很多人而言是个噩梦。有些程序员在写完一大片代码后再进行测试,这寻找代码潜在的BUG还是比较难,而是要在写好一点功能后就进行测试。其实,想说服大家这样做有点难度,但是在你发现潜在问题时就会事半功倍,不要因为图省事而导致耗费更多时间。

当然,很多程序员可能根本没有学习过软件测试,压根不会编写测试代码,这对于程序员发展而言是很不利的。其实,撰写测试代码最好的时机是在开始动手编码前,在你需要添加新功能前就可以编写对应的测试代码,因为这样能让你把注意力集中在接口而非实现上。预先编写的测试代码能给开发工作设置标志性的结束,即一旦测试代码正常运行,意味着工作也结束了。

测试驱动开发:“测试->编码->重构”,所以测试对于重构而言是非常重要的。

示例

在示例中主要有两个类:工厂类和供应商类。工厂客类的构造函数接收一个JS对象,此时我们可以想象是一个JSON数据提供。

工厂类

// 工厂类
class Factory{
  constructor(doc){
    this._name = doc.name;
    this._producers = [];
    this._totalProduction = 0;
    this._demand = doc.demand;
    this._price = doc.price;
    doc.producers.forEach(d => this.addProducer(new Producer(this,d)))
  }

  // 添加供应商
  addProducer(arg){
    this._producers.push(arg);
    this._totalProduction += arg.production;
  }

  // 各类数据的取值和设置函数
  get name(){
    return this._name;
  }
  get producers(){
    return this._producers.slice();
  }

  get totalProduction(){
    return this._totalProduction;
  }
  set totalProduction(arg){
    this._totalProduction = arg;
  }
  
  get demand(){
    return this._demand;
  }
  set demand(arg){
    this._demand = parseInt(arg);
  }
  get price(){
    return this._price;
  }
  set price(arg){
    this._price = parseInt(arg);
  }

  // 缺额的计算
  get shortfall(){
    return this._demand - this.totalProduction;
  }

  // 利润的计算
  get profit(){
    return this.demandValue - this.demandCost;
  }
  // 设置预计销售额
  get demandValue(){
    let remainingDemand = this.demand;
    let result = 0;
    this.producers
    .sort((a,b)=>a.cost - b.cost)
    .forEach(p => {
      const contribution = Math.min(remainingDemand, p.production);
      remainingDemand -= contribution;
      result += contribution * p.cost;
    });
    return result;
  }
  // 设置花费
  get demandCost(){
    return this.satisfiedDemand * this.price;
  }
  get satisfiedDemand(){
    return Math.min(this._demand , this.totalProduction);
  }
};
复制代码

供应商类

// 供应商类--存放数据的容器
class Producer{
  constructor(aFactory, data){
    this._factory = aFactory;
    this._cost = data.cost;
    this._name = data.name;
    this._production = data.production || 0;
  }

  // 各种值得取值和设值函数
  get name(){
    return this._name;
  }
  get cost(){
    return this._cost;
  }
  set cost(arg){
    this._cost = parseInt(arg);
  }

  get production(){
    return this._production;
  }
  set production(amountStr){
    const amount = parseInt(amountStr);
    const newProduction = Number.isNaN(amount) ? 0 : amount;
    this._factory.totalProduction += newProduction - this._production;
    this._production = newProduction;
  }
}
复制代码

在上面的两个类中,我们可以看到更新派生数据的方式有点丑陋,代码繁复,后面我们将会对其进行重构。

创建JSON数据的函数:

function sampleFactoryData(){
  return {
    name:"Apple",
    producers:[{
      name:"FOXCOM",
      cost:10,
      production:9
    },{
      name:"SAMSUNG",
      cost:12,
      production:10
    },{
      name:"BYD",
      cost:10,
      production:6
    }],
    demand:200,
    price:16000
  }
};
复制代码

整体代码如下:

computer.js


// 工厂类
class Factory{
  constructor(doc){
    this._name = doc.name;
    this._producers = [];
    this._totalProduction = 0;
    this._demand = doc.demand;
    this._price = doc.price;
    doc.producers.forEach(d => this.addProducer(new Producer(this,d)))
  }

  // 添加供应商
  addProducer(arg){
    this._producers.push(arg);
    this._totalProduction += arg.production;
  }

  // 各类数据的取值和设置函数
  get name(){
    return this._name;
  }
  get producers(){
    return this._producers.slice();
  }

  get totalProduction(){
    return this._totalProduction;
  }
  set totalProduction(arg){
    this._totalProduction = arg;
  }
  
  get demand(){
    return this._demand;
  }
  set demand(arg){
    this._demand = parseInt(arg);
  }
  get price(){
    return this._price;
  }
  set price(arg){
    this._price = parseInt(arg);
  }

  // 缺额的计算
  get shortfall(){
    return this._demand - this.totalProduction;
  }

  // 利润的计算
  get profit(){
    return this.demandValue - this.demandCost;
  }
  // 设置预计销售额
  get demandValue(){
    let remainingDemand = this.demand;
    let result = 0;
    this.producers
    .sort((a,b)=>a.cost - b.cost)
    .forEach(p => {
      const contribution = Math.min(remainingDemand, p.production);
      remainingDemand -= contribution;
      result += contribution * p.cost;
    });
    return result;
  }
  // 设置花费
  get demandCost(){
    return this.satisfiedDemand * this.price;
  }
  get satisfiedDemand(){
    return Math.min(this._demand , this.totalProduction);
  }
};

// 供应商类--存放数据的容器
class Producer{
  constructor(aFactory, data){
    this._factory = aFactory;
    this._cost = data.cost;
    this._name = data.name;
    this._production = data.production || 0;
  }

  // 各种值得取值和设值函数
  get name(){
    return this._name;
  }
  get cost(){
    return this._cost;
  }
  set cost(arg){
    this._cost = parseInt(arg);
  }

  get production(){
    return this._production;
  }
  set production(amountStr){
    const amount = parseInt(amountStr);
    const newProduction = Number.isNaN(amount) ? 0 : amount;
    this._factory.totalProduction += newProduction - this._production;
    this._production = newProduction;
  }
}


function sampleFactoryData(){
  return {
    name:"Apple",
    producers:[{
      name:"FOXCOM",
      cost:10,
      production:9
    },{
      name:"SAMSUNG",
      cost:12,
      production:10
    },{
      name:"BYD",
      cost:10,
      production:6
    }],
    demand:200,
    price:16000
  }
};

module.exports = {
  sampleFactoryData,
  Factory,
  Producer
}
复制代码

初次测试

在进行测试代码前,需要使用测试框架Mocha。当然你得先进行相关包的安装:

npm install mocha
复制代码

安装mocha> = v3.0.0,npm的版本应该> = v2.14.2。除此,确保使用Node.js的版本> = v4来运行mocha.

test.js

const {Factory, sampleFactoryData} = require("./computer");
var assert = require('assert');
describe("factory",function(){
  it("shortfall",function(){
    const doc = sampleFactoryData();
    const apple = new Factory(doc);
    assert.equal(apple.shortfall, 175);
  })
})
复制代码

切记要在package.json中设置启动方式:

 "scripts": {
    "test": "mocha"
  },
复制代码

在命令行中输入测试指令:

$ npm test
> test@1.0.0 test G:\oneu\test
> mocha

  factory
    ✔ shortfall
  1 passing (7ms)
复制代码

Mocha框架组织测试的方式是对代码进行分组,每组包含一个相关的测试,测试需要写在一个it块中。在上面的例子中,测试主要包含两个步骤:

  • 设置夹具(fixture),即测试所需要的数据和对象等
  • 验证测试夹具是否具备某些特征

我们看到的是测试正确的,那么如果想看到测试失败时候的报错:

assert.equal(apple.shortfall, 5);
复制代码

运行结果:

$ npm test
> test@1.0.0 test G:\oneu\test
> mocha

  factory
    1) shortfall
  0 passing (10ms)
  1 failing

  1) factory
       shortfall:

      AssertionError [ERR_ASSERTION]: 175 == 5
      + expected - actual

      -175
      +5

      at Context.<anonymous> (test.js:7:12)
      at processImmediate (internal/timers.js:461:21)
      
npm ERR! Test failed.  See above for more details.
复制代码

我们看到,Mocha框架报告了哪个测试失败,并给出测试失败的原因,方便查找,其实这里的错误就是实际计算的值与期望值不相等。

在实际测试系统中会有多个测试,能够帮助我们快速运行查找到BUG。好的测试能够给予我们简单清晰的错误反馈,对于自测代码而言尤为重要。在实际开发中,建议频繁运行测试代码,检验新代码的进展和重构过程是否报错。

再次测试

在每添加新功能时,遵循的风格是:对被测试类做的所有事情进行观察,而后对这个类的每个行为进行测试,包括各种可能使它发生异常的边界条件。测试是一种风险驱动的行为,测试的目标时希望找出现在下奶或未来可能出现的BUG。

切记:如果尝试撰写过多测试,可能得不到预期的结果,会导致测试不充分。事实上,即使只做一点测试,也能从中获益良多。

接着,我们再写一个测试计算总利润的代码,可以在测试夹具对总利润进行基本测试。

const {Factory, sampleFactoryData} = require("./computer");
var assert = require('assert');
var expect = require("expect");
describe("factory",function(){
  const apple = new Factory(sampleFactoryData());
  it("shortfall",function(){
    assert.equal(apple.shortfall, 175);
  })

  it("profit",()=>{
   
    expect(apple.profit).equal(230);
  })
})
复制代码

修改测试夹具

加载完测试夹具后,可以编写些测试代码来探查它的特性,但在实际应用中该夹具会被频繁更新,因为用户会在界面上修改数值。要知道多数更新都是通过设置函数完成的,但是不太可能出现什么BUG。

it("change production",()=>{
  apple.producers[0].production = 20;
  assert.equal(apple.shortfall,164);
  assert.equal(apple.profit,-575620);
})
复制代码

上述代码常见的测试模式,拿到beforeEach配置好的初始标准夹具,再对其进行必要检查,最后验证它是否表现出期望的行为。总而言之就是“配置->检查->验证”、“准备->行为->断言”等。

探测边界条件

在前面一系列的描述中,所有的测试均是聚焦在正常的行为上,通常称为“正常路径”,指的是在一切工作正常、用户使用方式符合规范的场景,其实也就是理想条件下。将测试推演到这些条件的边界处,可以检查出操作出错时软件的表现。

无论何时,当拿到一个集合时,可以去看看集合为空时会发生什么。

describe("no producers",()=>{
  let noProducers;
  beforeEach(()=>{
    const data = {
      name: "no producers",
      producers:[],
      demand:30,
      price:20
    }
    noProducers = new Factory(data);
  });

  it("shortfall",()=>{
    assert.equal(noProducers.shortfall,30);

  })

  it("profit",()=>{
    assert.equal(noProducers.profit,0);
  })
  
})
复制代码

如果拿到的是数值类型,0会是不错的边界条件:

it("zero demand",()=>{
  apple.demand = 0;
  assert.equal(apple.shortfall,-36);
  assert.equal(apple.profit,0);
})
复制代码

同样的,可以测试负值是不是边界类型:

it("negativate demand",()=>{
  apple.demand = -1;
  assert.equal(apple.shortfall,-37);
  assert.equal(apple.profit,15990 );
})
复制代码

我们知道设置函数接收的字符串是用户输入得到,虽然已经被限制了只能填写数字,但是仍然有可能是空字符串。

it("empty string demand",()=>{
  apple.demand = "";
  assert.equal(apple.shortfall,NaN);
  assert.equal(apple.profit,NaN);
})
复制代码

以上都是考虑到可能出错的边界条件,把测试火力集中在那儿。重构应该保证可以观测的行为不发生改变,不要因为测试无法捕获所有的BUG就不写测试,因为测试的确可以捕获大多数BUG。任何测试都不能证明一条程序没有BUG,但是测试可以提高编程速度。

测试远不止如此

在本文中介绍的测试属于单元测试,它们各自负责测试部分代码单元,运行快速便捷。单元测试是自测代码的主要方式,是测试系统中占据最多的测试类型。其他测试类型的作用是:

  • 专注于组件间的集成测试
  • 检验软件跨越几个层级的运行结果
  • 测试查找性能问题。

在编写测试代码时,要养成良好习惯:当遇到BUG时,要先写一个测试去清楚的复现它,仅当测试通过时方可视为BUG修理完毕。

当然,要写多少测试才能保证系统的稳定性,这个没有绝对的衡量标准,个人而言是在代码中引入一个缺陷,要有多大自信能够将其从测试集合中查找出来并解决。

小结

编写良好的测试程序,能够极大提高我们的编程速度,即使不进行重构也是如此。

参考文章

《重构──改善既有代码的设计(第2版)》 《前端重构感想》

写在最后

我是前端小菜鸡,感谢大家的阅读,我将继续和大家分享更多优秀的文章,此文参考了大量书籍和文章,如果有错误和纰漏,希望能给予指正。

更多最新文章敬请关注笔者掘金账号一川萤火和公众号前端万有引力

文章分类
前端
文章标签