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,结果团队里没人愿意用。后来改成简单的运行时多态,大家都开心了。
- 不要假设调用方会读文档。接口签名应该自解释。比如
Connect(Host, Port)比Connect(string, int)好得多。 - 不要过早优化。先让接口好用,再考虑性能。我见过太多为了「节省一次拷贝」而设计出反人类接口的例子。
嗯,总结一下。测试驱动API设计的核心就一句话:先写调用代码,再设计接口。这个方法不能保证你设计出完美的API,但至少能保证它「能用」且「好用」。
下次设计接口时,不妨试试先写一段测试代码。你会发现,很多设计问题在写测试的时候就暴露出来了。