THE VALUE OF SELF-TESTING CODE

程序员的大部分时间用于调试工作。

Classes should contain their own tests.
每工作一点就测试,才容易找到bug。
当然,意味着要编写很多额外的代码。
当需要增加一个功能的时候,我从编写测试开始。通过编写测试,我问自己需要做些什么。
编写测试代码,也让我专注于接口而不是实现。
我也有了一个明确的点-当测试工作时,说明我的工作完成了。

Test­Driven Development依赖于编写(失败)测试的短周期,编写代码以使test工作,重构以确保结果尽可能地clean。

SAMPLE CODE TO TEST

下面是一个生产计划的应用UI:
重构 改善既有代码的设计 第二版 - Building Tests

每个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。
最好增加一些处理,返回更好的错误响应。
不过,数据的校验可适可而止。如果是外来的数据,要尽量多做校验。

相关文章: