重构是很有价值的工具,但只有重构不行。要正确地进行重构,前提是得有一套稳固的测试集合,以帮助我们发现难以避免的疏漏。
编写优良的测试程序,可以极大提高我们的编程速度,即使不进行重构也一样如此。
1. 自测试代码的价值
如果你认真观察大多数程序员的时间,就会发现,他们编写代码的时间仅占所有时间的很少一部分。有时来决定下一步干什么,有时花在设计上,但是花费在调试上的时间是最多的。
一套测试就是一个强大的bug侦测器,能够大大缩减查找bug所需的时间。确保所有测试都完全自动化,让它们检查自己的测试结果。
编写测试代码还能帮我们把注意力集中于接口而非实现(这永远是意见好事)。预先写好的测试代码也为我们的工作安上一个明确的结束标志:一旦测试代码正常运行,工作就可以结束了。
先写测试再写业务代码,即测试驱动开发。这种编程方式依赖于这个短循环:先编写一个失败的测试,编写代码使测试通过,然后进行重构以保证代码的整洁。这个“测试、编码、重构”的循环应该在每个小时内都完成很多次。
2. 待测试示例代码
// class Province...
class Province {
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)));
}
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 = 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 demandCost() {
let remianingDemand = this.demand;
let result = 0;
this.producers
.sort((a, b) => a.cost - b.cost)
.forEach(p => {
const contribution = Math.min(remianingDemand, p.production);
remianingDemand -= contribution;
result += contribution * p.cost;
});
return result;
}
get demandValue() {
return this.satisfiedDemand * this.price;
}
get satisfiedDemand() {
return Math.min(this._demand, this.totalProduction);
}
addProducer(arg) {
this._producers.push(arg);
this._totalProduction += arg.production;
}
}
// 测试数据
function sampleProvinceData() {
return {
name: 'Asia',
producers: [
{name: 'Byzantium', cost: 10, production: 9},
{name: 'Attalia', cost: 12, production: 10},
{name: 'Sinope', cost: 10, production: 6},
],
demand: 30,
price: 20
}
}
// class Producer...
class Producer {
constructor(aProvince, data) {
this._province = aProvince;
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() { this._cost = parseInt(arg); }
get production() { return this._production; }
set production(amountStr) {
const amount = parseInt(amountStr);
const newProduction = Number.isNaN(amount) ? 0 : amount;
this._province.totalProduction += newProduction - this._production;
this._production = newProduction;
}
}
3. 测试代码
测试代码前,需要一个测试框架。Javascript的测试框架有很多,这里选用Mocha。Mocha框架组织测试代码的方式将其分组,每一组包含一套相关的测试。测试需要写在一个it块中。还需要设置一些测试夹具(fixture),即测试所需的数据和对象等;然后验证测试夹具是否具备某些特征(就本例而言是验证算出的缺额应该是期望的值)。
频繁地运行测试。对于你处理的代码,与其对应的测试至少每个几分钟就要运行一次,每天至少运行一次所有的测试。
describe('province', function() {
let asia;
// 创建测试夹具,beforeEach子句会在每个测试之前运行一遍,将asia变量清空,每次
// 都给它赋一个新值,这样保证了测试的独立性。
beforeEach(function() {
asia = new Province(sampleProvinceData());
});
it('shortfall', function() {
// assert.equal(asia.shortfall, 5); // assert风格
expect(asia.shortfall).equal(5); // expect风格
});
it('profit', function() {
expect(asia.profit).equal(230);
});
// 修改测试夹具
it('change production', function() {
asia.producers[0].production = 20;
expect(asia.shortfall).equal(-6);
expect(asia.profit).equal(292);
});
// 边界场景测试
it('zero demand', function() {
asia.demand = 0;
expect(asia.shortfall).equal(-25);
expect(asia.profit).equal(0)
})
it('zero demand', function() {
asia.demand = -1;
expect(asia.shortfall).equal(-26);
expect(asia.profit).equal(-10)
})
// 设值函数接受的字符串是从UI上的字段读来的,有可能为空字符串,
// 因此,同样需要测试来保证对空字符串的处理符合逻辑
it('empty string demand', function() {
asia.demand = '';
expect(asia.shortfall).NaN;
expect(asia.profit).NaN;
});
})
// 探测边界条件
describe('no producers', function() {
let noProducers;
beforeEach(function() {
const data = {
name: 'No producers',
producers: [],
demand: 30,
price: 20,
};
noProducers = new Province(data);
});
it('shortfall', function() {
expect(noProducers.shortfall).equal(30);
});
it('profit', function() {
expect(noProducers.profit).equal(0);
});
})
4. 测试远不止如此
测试既是重构所必要的基础保障,本身也是一个有价值的工具。如今一个架构的好坏,很大程度要取决于它的可测试性。
一个常见的问题是,“要写多少测试才算足够?”这个问题没有很好的衡量标准。一个测试集是否足够好,最好的衡量标准其实是主观的,请你问问自己:如果有人在代码里引入了一个缺陷,你有多大的自信它能被测试集揪出来?这种信心难以被定量分析,盲目自信不应该被计算在内,但自测试代码的全部目标,就是要帮你获得这种自信。
测试同样可能过犹不及。测试写得太多的一个征兆是,相比要改的代码,我在改动测试上花费了更多的时间。不过尽管过度测试时有发生,相比测试不足的情况还是稀少的多。