单元测试如何保证了易用的API
备注:
文章来源于我的这个答案:TDD的意义. 因为原题目被锁了,所以搬移出来.
本文不强烈区分TDD
和单元测试
, 虽然前者是方法论, 后者是实践这一方法论的具体技术. 不过大多数场景都是用单元测试来践行TDD的.
一般而言TDD的好处是以输出为导向及早发现问题,以及方便重构(单元测试保证).
我理解,还有一个比较重要的意义是: 客观上强制了程序员写出更加友好的接口 方便测试和联调.
问题
这里我以c++举例,需求就用最简单的: 实现一个单例类(比如说一个读取数据库的单例).
好,拿到这个需求了,考虑到c++11之后static本身就是多线程安全的,所以实现一个单例模式就很简单了,如下:
// 手打不保证编译ok class SingleDb { public: int getMoney(){...} SinlgeDb(const SingleDb &) = delete; void operator=(const SingleDb &) = delete; static SingleDb &get() { static SingleDb db; return db; } };
ok, 单例类的主体工作基本上就完成了,代码中直接可以用SingleDb::get()就可以获得这个单例,再补充和业务相关的读取等成员函数即可.
好了,本来这样就ok了,但是老板现在要求大家每个新功能都要求写单元测试,客户端程序员(这里的客户端指代的是使用这个单例模式的程序员)调用了这个API之后就不爽了,因为他想要对他自己的业务代码进行单元测试,但是在读数据库的时候无法进行打桩测试。具体遇到的问题如下:
struct Client{ int doSth() const { SingleDb &db = SingleDb::get(); if(db.getMoney() < 0) return -1; } };
完全无法测试,因为我们在写单元测试的时候无法控制db.getMoney()的输出进行控制. 因为需要做如下改造:
API适配
数据库的对象不通过静态成员函数获取,而修改成注入的方式,这样方便构造输入.
数据库单例类没有抽象基类,无法用构造类(或者说是Mock对象)替换, 需改改写成有继承体系的类.
实现:
针对以上两点,修改之后的样子
struct DbBase { virtual int getMoney() = 0; }; class SingleDb: public DbBase { // 没啥变化 }; //客户端 struct Client { DbBase &db; explicit Client(DbBase &db): db(db) {} int doSth() const { if(db.getMoney() < 0) return -1; } };
使用(单元测试)
这样处理之后,单例类已经变成了一个更加易于单元测试的类了,以一个比较简单的单元测试框架作为例子给出(catch+fakeit)
TEST_CASE("", "") { using namespace fakeit; Mock mock; when(Method(mock, getMoney())).Return(-1); Client c(mock.get()); REQUIRE(c.doSth() == -1); }
这实际上是为了单元测试而把API的接口改了,不仅仅是更加易于单元测试了. 更有意义的是,把接口提升至抽象类,以后想扩展实现类,直接就新增继承类即可,单元测试都不用动(因为传入的是抽象基类).
或者哪一天用到的单元测试框架没人维护了需要切换单元测试代码,那业务代码根本也不需要动,因为抽象接口已经固定. 不用Mock框架,自己打个桩都能单元测试, 例子如下:
// 构造打桩继承类 struct DataBaseMock : public DbBase int getMoney() {return -1;} }; // 测试 ... DataBaseMock db; Client c(&db); REQUIRE(c.doSth() == -1);
总结:
实际上,撰写一个好的API的好处本身又是另外一个话题了(不仅仅有助于单元测试),但是TDD这个开发模式能够强迫程序员写出一个更加易用的API.