THE VALUE OF SELF-TESTING CODE
程序员的大部分时间用于调试工作。
Classes should contain their own tests.
每工作一点就测试,才容易找到bug。
当然,意味着要编写很多额外的代码。
当需要增加一个功能的时候,我从编写测试开始。通过编写测试,我问自己需要做些什么。
编写测试代码,也让我专注于接口而不是实现。
我也有了一个明确的点-当测试工作时,说明我的工作完成了。
TestDriven Development依赖于编写(失败)测试的短周期,编写代码以使test工作,重构以确保结果尽可能地clean。
SAMPLE CODE TO TEST
下面是一个生产计划的应用UI:
每个province都有demand和price。每个province有producers,其中每一个都能以一定的价格生产一定的数量。还显示了每个producer能获得的收入。
在底部,显示了生产不足(需求-产量),和该计划的利润。
用户可以编辑demand、price,每个producer的production和cost。只要修改一个,其他元素都要立即更新。
我关注软件的业务逻辑-计算利润和不足的类,而不是生成HTML的代码。
业务逻辑涉及两个类:一个代表producer、另一个代表整个province。
类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)));
}
addProducer(arg) {
this._producers.push(arg);
this._totalProduction += arg.production;
}
此函数创建合适的JSON数据。可以用该函数的结果构造Province对象,来创建用于测试的实例Province。
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
};
}
Province类有各种数据值的访问器:
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);}
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(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._province.totalProduction += newProduction this._production;
this._production = newProduction;
}
set production方法里更新province的派生数据的办法是非常丑陋的,只要看到它就想重构。但是,我必须先写测试再重构它。
shortfall的计算也很简单(Province类):
get shortfall() {
return this._demand this.totalProduction;
}
对于利润(Province类):
get profit() {
return this.demandValue this.demandCost;
}
get demandCost() {
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 demandValue() {
return this.satisfiedDemand * this.price;
}
get satisfiedDemand() {
return Math.min(this._demand, this.totalProduction);
}
A FIRST TEST
我使用的测试框架是Mocha。
先写个简单的shortfall计算测试:
describe('province', function() {
it('shortfall', function() {
const asia = new Province(sampleProvinceData());
assert.equal(asia.shortfall, 5);
});
});
如果在NodeJS console中执行,输出是
’’’’’’’’’’’’’’
1 passing (61ms)
一旦运行大量的测试,总担心测试并没有像我想像的那样执行。于是,我总要让测试至少失败一次。
如果使用Chai,它会支持assert:
describe('province', function() {
it('shortfall', function() {
const asia = new Province(sampleProvinceData());
assert.equal(asia.shortfall, 5);
});
});
或者“expect”:
describe('province', function() {
it('shortfall', function() {
const asia = new Province(sampleProvinceData());
expect(asia.shortfall).equal(5);
});
});
ADD ANOTHER TEST
要查看类做的所有的事情,每一个可能导致失败的条件都要做测试。
测试应该是风险驱动的。
我不会测试只读取或者写入数据的访问器,他们出现错误的可能性太低了。
下来测试profit计算:
describe('province', function() {
it('shortfall', function() {
const asia = new Province(sampleProvinceData());
expect(asia.shortfall).equal(5);
});
it('profit', function() {
const asia = new Province(sampleProvinceData());
expect(asia.profit).equal(230);
});
});
测试代码有重复,重构测试代码:
describe('province', function() {
const asia = new Province(sampleProvinceData()); // DON'T DO THIS
it('shortfall', function() {
expect(asia.shortfall).equal(5);
});
it('profit', function() {
expect(asia.profit).equal(230);
});
});
上面的代码是错误的,因为两个用例共享了相同的asia。
应该这样写:
describe('province', function() {
let asia;
beforeEach(function() {
asia = new Province(sampleProvinceData());
});
it('shortfall', function() {
expect(asia.shortfall).equal(5);
});
it('profit', function() {
expect(asia.profit).equal(230);
});
});
这样,每个测试运行前,都会执行beforeEach,不会导致共享对象了。
MODIFYING THE FIXTURE
Producer的production setter比较复杂,所以要写测试代码。
it('change production', function() {
asia.producers[0].production = 20;
expect(asia.shortfall).equal(6);
expect(asia.profit).equal(292);
});
PROBING THE BOUNDARIES
应该测试边界值。比如,如果producers为空时,会发生什么。
describe('no producers', function() {
let noProducers;
beforeEach(function() {
const data = {
name: "No proudcers",
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);
});
对于数字,0是很好的测试内容:
it('zero demand', function() {
asia.demand = 0;
expect(asia.shortfall).equal(25);
expect(asia.profit).equal(0);
});
还有负值:
it('negative demand', function() {
asia.demand = 1;
expect(asia.shortfall).equal(26);
expect(asia.profit).equal(10);
});
setters会从UI接受字符串,但是只应该接受数字-对于字符串返回空白。所以做测试:
it('empty string demand', function() {
asia.demand = "";
expect(asia.shortfall).NaN;
expect(asia.profit).NaN;
});
下来的这个测试很有意思:
describe('string for producers', function() {it('', function() {
const data = {
name: "String producers",
producers: "",
demand: 30,
price: 20
};
const prov = new Province(data);
expect(prov.shortfall).equal(0);
});
它的输出是:
’’’’’’’’’!
9 passing (74ms)
1 failing
1) string for producers :
TypeError: doc.producers.forEach is not a function
at new Province (src/main.js:22:19)
at Context.<anonymous> (src/tester.js:86:18)
Mocha认为这是一个failure,其他很多测试框架认为这应该是一个error。这像是一个代码作者没有预料到的一个exception。
最好增加一些处理,返回更好的错误响应。
不过,数据的校验可适可而止。如果是外来的数据,要尽量多做校验。