单元测试如何保证了易用的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.

相关推荐