25、测试驱动API设计:先写测试再设计API,如何写出易用且健壮的接口?

说实话,做了这么多年C++开发,我见过太多「看起来很漂亮,用起来想骂人」的API了。

为什么会这样?因为很多人在设计接口时,是先想「我要提供什么功能」,而不是先想「调用方会怎么用」。

今天我们就聊聊测试驱动API设计——说白了,就是先写调用代码,再设计接口签名。这个方法我用了快十年,每次都能帮我避开不少坑。

传统API设计的问题在哪?

我记得刚入行那会儿,有个同事设计了一个日志库的接口。他花了两周时间,把类层次、继承关系、模板参数搞得特别复杂。结果呢?

  • 调用方要写5行代码才能打一条日志
  • 构造函数有7个参数,其中4个有默认值
  • 想换个输出目标,得继承三个抽象类

我当时就问他:「你写这个接口的时候,有没有自己先模拟调用一下?」他愣住了。

这就是典型的问题——设计者站在「提供者」的角度思考,而不是「使用者」的角度

核心观点:API的易用性,取决于调用方的体验,而不是实现方的优雅程度。

测试驱动API设计:三步法

我个人习惯把API设计分成三步。每一步都围绕「先写测试」这个核心。

第一步:写一段「理想中的调用代码」

别管能不能编译通过。先问自己:如果这个API已经存在了,我希望怎么用它?

举个例子。假设我要设计一个配置文件解析器。我不会先想类结构,而是先写一段测试代码:

// 这是我「希望」的调用方式
ConfigParser parser("app.conf");
std::string host = parser.Get("database.host", "localhost");
int port = parser.GetInt("database.port", 3306);
bool debug = parser.GetBool("debug", false);

看到没?这段代码甚至还没定义ConfigParser类。但它已经告诉我几件事:

  • 构造函数接受一个文件名
  • Get方法有默认值参数
  • 需要支持类型转换(GetInt, GetBool)
  • 键名支持点号分隔的层级结构

我在项目中遇到过好几次这样的情况:写调用代码时才发现,原来我需要的是「带默认值的Get」,而不是「抛异常的Get」。如果先写实现,很容易忽略这种细节。

第二步:让测试通过——只实现必要的接口

有了调用代码,接下来就是让它编译通过、运行通过。这时候有个原则:只实现测试需要的功能,不要提前设计扩展点

比如上面的例子,我只需要实现:

  • ConfigParser(const std::string& path)
  • std::string Get(const std::string& key, const std::string& defaultVal)
  • int GetInt(const std::string& key, int defaultVal)
  • bool GetBool(const std::string& key, bool defaultVal)

不需要什么虚函数、模板、策略模式。等测试通过了,再考虑要不要抽象。

我的经验:过早抽象是万恶之源。先让测试跑起来,再考虑「万一以后要支持YAML怎么办」。实际上,很多「万一」永远不会发生。

第三步:重构——从测试中提炼接口设计

测试通过后,我会回头看看调用代码。问自己几个问题:

  • 调用方需要记住的参数顺序是否合理?
  • 错误处理是否直观?
  • 有没有不必要的耦合?

举个例子。有一次我设计一个网络请求库,测试代码长这样:

HttpClient client;
auto resp = client.Request("GET", "https://api.example.com/users", 
                           Headers{{"Authorization", "Bearer xxx"}},
                           Body(""));

嗯,看着还行。但当我写第二个测试时,发现每次都要写Headers和Body,哪怕不需要。于是我改成了:

auto resp = client.Get("https://api.example.com/users")
                  .WithHeader("Authorization", "Bearer xxx")
                  .Execute();

这就是测试驱动API设计的好处——你会在写测试的过程中,自然发现接口的「别扭之处」

如何写出健壮的接口?

易用性是一方面,健壮性是另一方面。我总结了几个关键点。

1. 用类型系统防止误用

我曾经遇到过一个bug,原因是调用方把参数顺序搞反了:

// 原始接口
void Connect(const std::string& host, int port);

// 调用方写成了
Connect(8080, "localhost");  // 编译通过,运行崩溃

怎么解决?用强类型:

struct Host { std::string value; };
struct Port { int value; };

void Connect(Host host, Port port);

// 调用方写错会编译失败
Connect(Port{8080}, Host{"localhost"});  // 编译错误

说白了,能在编译期发现的错误,就不要拖到运行时

2. 提供「不可能用错」的默认行为

我见过太多API,调用方必须记住「先调用Init,再调用Start,最后调用Cleanup」。这种顺序依赖就是bug的温床。

更好的做法是:让构造函数完成所有初始化,析构函数完成所有清理。如果实在需要多步操作,用RAII包装起来。

注意:不要用「调用方应该看文档」来为自己的设计开脱。好的API不需要文档也能用对。

3. 错误处理要「显式且可预测」

我个人的习惯是:

  • 预期内的错误(如文件不存在)用返回值或std::optional
  • 预期外的错误(如内存不足)用异常
  • 永远不要静默失败

举个例子:

// 不好的设计
bool LoadConfig(const std::string& path);  // 返回false表示失败,但为什么失败?

// 好的设计
std::optional<Config> LoadConfig(const std::string& path);
// 或者
Config LoadConfig(const std::string& path);  // 失败时抛异常,带具体原因

一个完整的例子:设计一个定时器API

咱们用测试驱动的方式,一步步设计一个简单的定时器接口。

第一步:写调用代码

// 我希望这样用
Timer timer;

timer.Start(1000, []() {
    std::cout << "1秒到了" << std::endl;
});

// 5秒后停止
std::this_thread::sleep_for(5s);
timer.Stop();

第二步:让测试通过

class Timer {
public:
    void Start(int intervalMs, std::function<void()> callback) {
        running_ = true;
        thread_ = std::thread([this, intervalMs, callback]() {
            while (running_) {
                std::this_thread::sleep_for(std::chrono::milliseconds(intervalMs));
                if (running_) callback();
            }
        });
    }
    
    void Stop() {
        running_ = false;
        if (thread_.joinable()) thread_.join();
    }
    
    ~Timer() { Stop(); }
    
private:
    std::atomic<bool> running_{false};
    std::thread thread_;
};

第三步:重构

测试通过后,我发现一个问题:如果回调函数抛异常,整个线程会崩溃。于是加了一个try-catch:

try {
    callback();
} catch (const std::exception& e) {
    // 记录日志,或者通知调用方
    std::cerr << "Timer callback failed: " << e.what() << std::endl;
}

你看,这个设计是从测试中「长」出来的,不是凭空想出来的。

知识体系总览

下面这张图总结了测试驱动API设计的核心流程:

测试驱动API设计流程 第一步 写理想调用代码 第二步 实现必要接口 第三步 重构优化 反馈循环 关键原则 • 从调用方视角出发 • 只实现测试需要的功能 • 用类型系统防止误用 • 提供默认安全行为 • 显式错误处理 • 避免顺序依赖

避坑指南

最后分享几个我踩过的坑:

  • 不要为了「灵活性」牺牲「易用性」。我曾经设计过一个模板参数多到爆炸的API,结果团队里没人愿意用。后来改成简单的运行时多态,大家都开心了。
  • 不要假设调用方会读文档。接口签名应该自解释。比如 Connect(Host, Port)Connect(string, int) 好得多。
  • 不要过早优化。先让接口好用,再考虑性能。我见过太多为了「节省一次拷贝」而设计出反人类接口的例子。

嗯,总结一下。测试驱动API设计的核心就一句话:先写调用代码,再设计接口。这个方法不能保证你设计出完美的API,但至少能保证它「能用」且「好用」。

下次设计接口时,不妨试试先写一段测试代码。你会发现,很多设计问题在写测试的时候就暴露出来了。


公众号:蓝海资料掘金营,微信deep3321