前言
先说一个必须纠正的前提:Selenium 官方没有提供 C++ 语言绑定。标题里「C++ 方向 + Selenium 实战」这个组合,如果理解成「引入一个 C++ 版的 Selenium 库然后跟着写」,是不成立的——Selenium 官方维护的绑定只有 Java、Python、C#、Ruby、JavaScript 和 Kotlin 这几种,C++ 不在其中。
但这件事有解,而且解法比想象中干净。Selenium 的核心并不是某个语言的库,而是W3C WebDriver 协议:一套基于 HTTP + JSON 的远程控制协议。浏览器厂商提供实现了该协议的驱动(driver)进程,比如 ChromeDriver、geckodriver、msedgedriver。你要做的事情,本质上就是发 HTTP 请求、解析 JSON 响应。任何能发 HTTP 的语言都能做,C++ 当然也行。
所以本文的路线是:先讲清自动化测试的分层和 WebDriver 协议的本质,再用 C++ 加上一个 HTTP 客户端把请求发出去,最后讲工程结构怎么组织才不至于三个月后没人敢改。本文以 C++17 为基准。
| 语言 | 官方绑定 | C++ 可用的方案 |
|---|---|---|
| Java / Python / C# / Ruby / JS / Kotlin | 有 | 不适用 |
| C++ | 没有 | 直接用 WebDriver 协议 + HTTP 客户端;或使用第三方封装库 |
| 任意语言 | 不适用 | 直接实现 W3C WebDriver 协议 |
一、自动化测试的分层与 Selenium 的定位
自动化测试按覆盖范围大致分三层:
| 层次 | 测什么 | 速度 | 稳定性 | 典型工具 |
|---|---|---|---|---|
| 单元测试 | 单个函数/类 | 极快 | 极高 | GoogleTest、Catch2 |
| 集成测试 | 模块间协作、接口 | 中 | 高 | 各类 API 测试框架 |
| 端到端测试(E2E) | 真实浏览器里的完整流程 | 慢 | 低 | Selenium、Playwright、Cypress |
Selenium 属于最上面那层。它的价值是「从用户视角验证真实浏览器里的行为」——这是单元测试永远覆盖不到的:前端路由跳转、跨域、Cookie 与 SameSite 策略、真实渲染时序。它的代价也很明确:慢、脆。一个按钮的 id 改了,几十个用例一起红。
因此第一条工程原则:E2E 用例要少而精。把绝大多数逻辑正确性交给单元测试和集成测试,E2E 只覆盖最关键的几条主流程(登录、下单、支付这类)。反过来,把什么都往 E2E 里塞,是自动化测试项目失败最常见的原因。
在 C++ 项目里,这个分工尤其自然:核心逻辑本来就有 GoogleTest 覆盖,E2E 只用来验证「Web 端能不能跑通」。
二、W3C WebDriver 协议:Selenium 的本质
理解这一点,C++ 做 Web 自动化就不再别扭了。WebDriver 协议的基本形态:
- 启动一个驱动进程(比如 ChromeDriver),它监听一个本地端口,比如 9515。
- 你的测试代码向
http://localhost:9515发 HTTP 请求。 - 驱动进程去操作真实浏览器,把结果以 JSON 返回。
关键端点(具体字段以 W3C WebDriver 规范文本为准):
| 动作 | 方法 | 路径 |
|---|---|---|
| 新建会话 | POST | /session |
| 关闭会话 | DELETE | /session/{session id} |
| 打开网址 | POST | /session/{session id}/url |
| 取当前网址 | GET | /session/{session id}/url |
| 取页面标题 | GET | /session/{session id}/title |
| 查找元素 | POST | /session/{session id}/element |
| 点击元素 | POST | /session/{session id}/element/{element id}/click |
| 输入文本 | POST | /session/{session id}/element/{element id}/value |
请求体和响应体都是 JSON。查找元素时,请求体形如:
{"using": "css selector", "value": "#login-btn"}响应里返回的元素标识是一个固定键名,这是规范定的:
{"value": {"element-6066-11e4-a52e-4f735466cecf": "0f1e2d3c-..."}}看到这里就明白了:所谓「Selenium 的 C++ 支持」,本质上就是「C++ 的 HTTP 客户端 + JSON 解析」。没有任何魔法。
三、用 C++ 发起 WebDriver 请求
C++ 标准库没有 HTTP 客户端和 JSON 解析器(C++17 的std::filesystem管不了网络;网络库 TS 也没进标准)。所以需要第三方库。最常见的组合是libcurl做 HTTP +nlohmann/json做 JSON,两者都是成熟且广泛使用的库。
下面这段代码演示用 libcurl 新建一个 WebDriver 会话。编译需要链接 libcurl:
// 依赖 libcurl;链接时加 -lcurl(MSVC 下为 libcurl.lib) #include <curl/curl.h> #include <iostream> #include <string> static std::size_t write_cb(char* ptr, std::size_t size, std::size_t nmemb, void* userdata) { auto* out = static_cast<std::string*>(userdata); out->append(ptr, size * nmemb); return size * nmemb; } int main() { if (curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK) { std::cerr << "curl_global_init 失败\n"; return 1; } CURL* curl = curl_easy_init(); if (!curl) { std::cerr << "curl_easy_init 失败\n"; curl_global_cleanup(); return 1; } // 请求体:让驱动启动 Chrome const char* body = R"({"capabilities":{"alwaysMatch":{"browserName":"chrome"}}})"; struct curl_slist* headers = nullptr; headers = curl_slist_append(headers, "Content-Type: application/json"); std::string response; curl_easy_setopt(curl, CURLOPT_URL, "http://localhost:9515/session"); curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body); curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb); curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, 30000L); CURLcode rc = curl_easy_perform(curl); if (rc != CURLE_OK) { std::cerr << "请求失败: " << curl_easy_strerror(rc) << '\n'; } else { long status = 0; curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status); std::cout << "HTTP 状态码: " << status << '\n'; std::cout << "响应: " << response << '\n'; } curl_slist_free_all(headers); curl_easy_cleanup(curl); curl_global_cleanup(); return 0; }几个容易被忽略的点:
curl_global_init必须在任何其他 curl 调用之前执行,且只执行一次。多线程下还要注意线程安全边界,具体以 libcurl 官方文档为准。curl_slist是链表,curl_slist_append返回的可能是新的头指针(首个调用时),所以必须用返回值接住,不能写curl_slist_append(headers, ...)然后丢掉返回值。- 会话 id 需要从响应 JSON 里取出来,后续所有请求的路径都带着它。
至于 JSON 解析,nlohmann/json的用法是nlohmann::json::parse(字符串),它会把结果解析成一个可下标访问的对象。这是第三方库的接口,不是标准库的一部分,用之前请以该库文档为准。如果你连第三方依赖都不想引,用最朴素的字符串查找也能取到sessionId字段的值——脆弱但零依赖。
四、工程结构:把测试写成可维护的样子
一个 E2E 测试如果写成「一长串发请求」,三个月后没人敢动。基本的结构化手段是Page Object(页面对象)模式:把「页面上的元素和操作」封装成类,测试用例只调用业务语义的方法,不关心选择器。
下面这段是只依赖标准库的完整可编译示例,用一个假的驱动实现演示这个结构。真实项目里把FakeDriver换成基于 libcurl 的实现即可,测试代码本身一行都不用改。这也说明了 Page Object 的价值:它把「协议细节」和「业务断言」隔离开了。
#include <iostream> #include <map> #include <string> #include <vector> // ---- 抽象驱动接口:真实实现走 W3C WebDriver 协议 ---- class Driver { public: virtual ~Driver() = default; virtual void get(const std::string& url) = 0; virtual std::string title() = 0; virtual bool click(const std::string& selector) = 0; virtual bool type(const std::string& selector, const std::string& text) = 0; virtual std::string text(const std::string& selector) = 0; }; // ---- 假驱动:仅用于演示结构,不是真实实现 ---- class FakeDriver : public Driver { public: void get(const std::string& url) override { at_login_ = url.find("/login") != std::string::npos; elements_["#login-btn"] = {true, ""}; elements_["#msg"] = {true, ""}; } std::string title() override { return at_login_ ? "登录页" : "首页"; } bool click(const std::string& selector) override { auto it = elements_.find(selector); if (it == elements_.end() || !it->second.first) return false; if (selector == "#login-btn") elements_["#msg"].second = "登录成功"; return true; } bool type(const std::string& selector, const std::string& text) override { auto it = elements_.find(selector); if (it == elements_.end() || !it->second.first) return false; it->second.second = text; return true; } std::string text(const std::string& selector) override { auto it = elements_.find(selector); return it == elements_.end() ? std::string{} : it->second.second; } private: struct Element { bool present; std::string value; }; bool at_login_ = false; std::map<std::string, Element> elements_; }; // ---- Page Object:把选择器关在类里 ---- class LoginPage { public: explicit LoginPage(Driver& d, std::string base) : d_(d), base_(std::move(base)) {} void open() { d_.get(base_ + "/login"); } std::string title() { return d_.title(); } bool submit() { return d_.click("#login-btn"); } std::string message() { return d_.text("#msg"); } private: Driver& d_; std::string base_; }; // ---- 测试用例:只表达业务语义 ---- int main() { FakeDriver driver; LoginPage page(driver, "http://localhost:8080"); page.open(); if (page.title() != "登录页") { std::cerr << "FAIL: 标题不符\n"; return 1; } if (!page.submit()) { std::cerr << "FAIL: 提交按钮不可点击\n"; return 1; } if (page.message() != "登录成功") { std::cerr << "FAIL: 提示信息不符,实际=" << page.message() << '\n'; return 1; } std::cout << "PASS\n"; return 0; }这段代码可以直接编译运行,输出PASS。它的意义在于展示了依赖倒置:测试代码依赖抽象的Driver,真实驱动和假驱动都实现它。这样一来,无头模式下跑真浏览器、本地用假驱动快速验证逻辑,切换成本几乎为零。
五、CI 集成与稳定性
把 E2E 放进 CI(持续集成)要处理三件事:
- 无头模式。CI 机器通常没有显示器,Chrome 要用 headless 相关参数启动。具体参数名以对应浏览器版本的文档为准,它们会变。
- 显式等待,不要
sleep。用固定sleep(3)等待元素出现,是 E2E 脆弱性的头号来源:慢的机器上不够,快的机器上白等。WebDriver 协议本身提供了等待机制(各种「隐式等待」设置和「预期条件」模式),应当优先使用。 - 失败留证据。用例失败时截图、保存页面源码、打印当前 URL,比一句「元素找不到」有用得多。
常见坑点
坑点 1:以为 Selenium 有官方 C++ 绑定。
❌ 在构建脚本里找一个 "selenium-cpp" 官方包 —— 找不到,官方不提供✅ 直接用 W3C WebDriver 协议:HTTP 请求 + JSON 解析,用 libcurl 这类通用 HTTP 库坑点 2:驱动进程没起来就发请求。
// ❌ 直接连 9515 端口,ChromeDriver 没启动时连接被拒 // curl_easy_perform 返回 CURLE_COULDNT_CONNECT,但很多人只看响应体不看返回值// ✅ 先检查 curl_easy_perform 的返回值和 HTTP 状态码 CURLcode rc = curl_easy_perform(curl); if (rc != CURLE_OK) { std::cerr << "连接层失败: " << curl_easy_strerror(rc) << '\n'; }坑点 3:忘了释放curl_slist。
// ❌ 每次请求都 append 到同一个 slist 却不释放,长跑必然内存增长 // for (...) { headers = curl_slist_append(headers, "Content-Type: application/json"); }// ✅ 用完释放;多次请求可复用同一个 slist,但同一份头别重复添加 curl_slist_free_all(headers);坑点 4:会话没关闭,进程越积越多。
// ❌ 测试结束直接 return,浏览器和驱动进程留在后台// ✅ 用 RAII 把「关闭会话」绑到对象生命周期上 class Session { public: explicit Session(std::string id) : id_(std::move(id)) {} ~Session() { /* 这里发 DELETE /session/{id_} */ } Session(const Session&) = delete; Session& operator=(const Session&) = delete; private: std::string id_; };坑点 5:用固定 sleep 等待元素。
// ❌ 脆弱:机器慢就误报,机器快就白等 // std::this_thread::sleep_for(std::chrono::seconds(3)); // auto el = driver.find_element("#result");// ✅ 轮询等待直到超时(真实实现应使用 WebDriver 的等待机制) // 伪代码:最多等 N 秒,每 100ms 重试一次查找,找到即返回,超时则报错坑点 6:选择器写得太依赖实现细节。
❌ div > div:nth-child(3) > span > a 前端一改布局,用例全红,而且看不出这是哪个业务元素✅ 给关键元素加稳定的>// ❌ Page Object 里塞 std::cerr << "FAIL",职责混乱,无法复用// ✅ Page Object 只提供操作与取值,断言留在测试用例里 bool ok = page.submit(); // 测试侧:if (!ok) { ... }坑点 8:以为抓到元素就能立刻点击。
元素「存在」不等于「可交互」:可能被遮挡、可能在动画中、可能还没绑定事件。直接点击会抛「元素不可交互」一类错误。正确做法是等待「可点击」状态,而不是等待「存在」。
总结
| 要点 | 结论 |
|---|---|
| Selenium 与 C++ | 官方没有C++ 绑定,但可以用 W3C WebDriver 协议 |
| 协议本质 | HTTP + JSON,任何能发 HTTP 的语言都能用 |
| 推荐技术栈 | libcurl 做 HTTP + 第三方 JSON 库(如 nlohmann/json) |
| 分层原则 | E2E 用例少而精,逻辑正确性交给单元/集成测试 |
| 组织方式 | Page Object 模式隔离选择器与业务断言 |
| 稳定性 | 显式等待替代固定 sleep;失败留截图与页面源码 |
| 资源管理 | 用 RAII 保证会话关闭、curl_slist释放 |
C++ 做 Web 自动化测试并不别扭,别扭的只是「想找一个现成的 C++ Selenium 库」这个期待。把心理模型从「调库」换成「发协议请求」,剩下的就是常规的工程问题:抽象好驱动接口、封装好页面对象、管理好资源生命周期、把等待写对。这几件事做扎实,C++ 的 E2E 测试一样可以稳定跑在 CI 上。