【C++ 标准项目】发布订阅消息队列(篇四):sqlite 与 gtest 断言框架介绍及实战应用(学习笔记)

整理一篇学习笔记,把看到的一些要点和自己的理解都记下来。

前面大家介绍了muduo库,接下来大家进行sqlite以及gtest的使用

一.SQLite3

1.什么是 SQLite

SQLite 是一个进程内的轻量级数据库,它实现了自给自足的、无服务器的、零配置的、事务性的 SQL 数据库引擎。它是一个零配置的数据库,这意味着与其他数据库不一样,大家不要在系统中配置。不像其他数据库,SQLite 引擎不是一个独立的进程,可以按应用程序需求进行静态或动态连接,SQLite 直接访问其存储文件。

2.为什么要用 SQLite

不要一个单独的服务器进程或操作的系统(无服务器);SQLite 不要配置;一个完整的 SQLite 数据库是存储在一个单一的跨平台磁盘文件里;SQLite 挺小、轻量级,完全配置时小于 400 KiB,省略可选功能配置时小于 250 KiB;SQLite 是自给自足的,这意味着不需要任何外部依赖;SQLite 事务完全兼容 ACID,允许从多个进程或线程安全访问;SQLite 支持 SQL92(SQL2)标准的大多数查询语言功能;SQLite 使用 ANSI-C 编写,并给出了轻松、易于使用的 API;SQLite 可在 UNIX(Linux、Mac OS-X、Android、iOS)和 Windows(Win32、WinCE、WinRT)中运行。

3.SQLite3 C/C++ API 介绍

C/C++ API 是 SQLite3 数据库的一个客户端,给出一种用 C/C++ 操作数据库的方法。

官方文档:List Of SQLite Functions

1.查看当前数据库在编译阶段是否启动了线程安全

int sqlite3_threadsafe();   // 0-未启用;1-启用

需要要注意的是,SQLite 有三种安全等级:

非线程安全模式;线程安全模式(不同的连接在不同的线程/进程间是安全的,即一个句柄不能用于多线程间);串行化模式(可以在不同的线程/进程间使用同一个句柄)。

2. 创建/打开数据库文件,并返回操作句柄

int sqlite3_open(const char *filename, sqlite3 **ppDb);
// 成功返回 SQLITE_OK

若在编译阶段启动了线程安全,则在程序运行阶段可以通过参数选择线程安全等级:

int sqlite3_open_v2(const char *filename, sqlite3 **ppDb,
                    int flags, const char *zVfs);
// 返回 SQLITE_OK 表示成功

flags 取值:

取值含义SQLITE_OPEN_READWRITE以可读可写方式打开数据库文件SQLITE_OPEN_CREATE不存在数据库文件则创建SQLITE_OPEN_NOMUTEX多线程模式,只要不同的线程使用不同的连接即可保证线程安全SQLITE_OPEN_FULLMUTEX串行化模式


3. 执行语句

int sqlite3_exec(sqlite3 *db, const char *sql,
                 int (*callback)(void*, int, char**, char**),
                 void *arg, char **errmsg);
// 返回 SQLITE_OK 表示成功

回调函数 int (*callback)(void*, int, char**, char**) 的四个参数:

参数含义void*在回调时传入的 arg 参数int一行中数据的列数char**存储一行数据的字符指针数组char**每一列的字段名称

这个回调函数有个 int 返回值:成功处理的情况下务必返回 0,返回非 0 会触发 ABORT 退出程序。


4. 销毁句柄

int sqlite3_close(sqlite3 *db);      // 成功返回 SQLITE_OK
int sqlite3_close_v2(sqlite3 *db);   // 推荐使用——无论如何都会返回 SQLITE_OK

获取错误信息

const char *sqlite3_errmsg(sqlite3 *db);


4.SQLite3 C/C++ API的使用

来看代码,我们直接进行封装,方便我们的使用

代码框架

SqliteHelper
├── 成员
│     ├── _dbfile    : std::string    数据库文件路径
│     └── _handler   : sqlite3*       数据库操作句柄(核心资源)
│
├── open()   → sqlite3_open_v2()      创建/打开,拿到句柄
├── exec()   → sqlite3_exec()         执行任意 SQL
└── close()  → sqlite3_close_v2()     释放句柄

程序运行检测:

我们先开放插入代码运行结果:


接着开放删除语句,进行执行

接着往下运行会发现报错了,原因如下:

因此接下来进行修改:

接着再次运行,看结果:

第二次运行就主键冲突

原因:test.db 是持久化文件,第一次运行插入的数据还在磁盘上。而 sn 是主键:

create table if not exists student(sn int primary key, name varchar(32), age int);

同样的主键 1、2、3 再插一次,必然冲突。

这恰恰是 SQLite 的特性:它的价值就在于"数据不随程序退出而消失"——一个文件就是一个完整数据库。因此调试时需要主动清库。

三种解法

方案做法适用删库重来rm -f test.db && ./main调试阶段最省事有则替换SQL 改成 insert or replace into student values(...)幂等写入先清空表插入前执行 delete from student;数据量小

要注意哈:SQLite 里 int primary key 不会自增,务必写 integer primary key(是 integer 而不是 int)才会成为 rowid 的别名并自动递增。用 int 的话,主键值要自己保证不重复。

assert 在这里暴露了它的问题

assert 检查失败时做了什么?

直接 abort 进程 + core dump。

而更麻烦的是它在 Release 构建下的行为:

构建方式断言失败时Debug(默认,当前)abort + core dump,用户看到的是崩溃Release(-DNDEBUG)断言被整个编译掉 → 插入失败了程序照常往下跑,后续查询拿到的是旧数据,各位完全不知道出过错

两种情况都不是想要的结果。 因为 assert 的正确定位是:

检查"不可能发生的内部逻辑错误"(比如"这个指针按设计绝不可能是空")。

而"打开数据库""执行 SQL"都是可能失败的外部操作——文件可能不存在、磁盘可能满、SQL 可能写错。这类检查应当用 if:

if (!helper.exec(insert_sql, nullptr, nullptr)) {
    std::cerr delete sn=3:(小红被删除)  --->select:(只剩张小明、小黑)


二.Gtest

1.GTest 是什么

GTest 是一个跨平台的 C++单元测试框架,由 google 公司发布。gtest 是为了在不同平台上为编写 C++单元测试而生成的。它给出了丰富的断言、致命和非致命判断、参数化等等

2.GTest 使用

a.两个核心宏

TEST(test_case_name, test_name)
TEST_F(test_fixture, test_name)

宏用途TEST创建一个轻松测试。函数体内可以写任意 C++ 代码,并用框架提供的断言检查TEST_F用于多样测试。多个测试场景需要相同的数据配置时使用——即"相同的数据,测不同的行为"

轻松区分:TEST 每次都是干净的空白动手;TEST_F 基于一个 Fixture 类,可以在每个测试用例执行前自动准备同一份数据。

b.断言:两类,差别很大

系列失败时的行为ASSERT_*退出当前函数(后面的代码不再执行)EXPECT_*继续往下执行(失败也把整个函数跑完)


c.常用断言速查

// bool 值检查
ASSERT_TRUE(参数);      // 期待结果是 true
ASSERT_FALSE(参数);     // 期待结果是 false

// 数值型数据检查
ASSERT_EQ(val1, val2);  // equal,相等才通过
ASSERT_NE(val1, val2);  // not equal,不等才通过
ASSERT_LT(val1, val2);  // less than,小于才通过
ASSERT_GT(val1, val2);  // greater than,大于才通过
ASSERT_LE(val1, val2);  // less equal,小于等于才通过
ASSERT_GE(val1, val2);  // greater equal,大于等于才通过

把上面所有 ASSERT_ 换成 EXPECT_,就是对应的"非致命"版本。

自定义失败信息:如果对自动输出的错误信息不满意,可以用 operator

实战:测试一个求绝对值的函数

运行结果如下:

参数逐个说明

参数作用能不能省-std=c++11GTest 需要 C++11 起不能main.cc源文件—-o main输出可执行文件名可以(默认 a.out)-lgtest链接 GTest 库不能-pthreadGTest 内部用到线程不能

接下来我们呢将第一条语句改成 ASSERT_FALSE 进行错误测试

【重点】为什么只报了一条错?

上面的失败输出里,只冒出来了第 11 行那一条失败,后面 8 条断言一条信息都没有。

不是它们都通过了,而是根本没执行。

TEST(abs_test, test1)
{
    ASSERT_FALSE(abs(1) == 1);   //  事件机制的最大好处就是能够为我们各个测试用例提前准备好测试环境,并在测试完 毕后用于销毁环境,这样有个好处就是如果我们有一端代码需要进行多种不同方法的 测试,则可以通过测试机制在每个测试用例进行之前初始化测试环境和数据,并在测 试完毕后清理测试造成的影响。

- GTest 提供了三种常见的的事件:

**全局事件:**针对整个测试程序。实现全局的事件机制,需要创建一个自己的类,然后继承 testing::Environment 类,然后分别实现成员函数 SetUp 和 TearDown,同时在 main 函数内进行调用 testing::AddGlobalTestEnvironment(newMyEnvironment);函数添加全局的事件机制

![](https://i-blog.)

![](https://i-blog.)

---

**TestSuite 事件**:针对一个个测试套件。测试套件的事件机制我们同样需要去创建一个类,继承自 testing::Test,实现两个静态函数 SetUpTestCase 和TearDownTestCase,测试套件的事件机制不需要像全局事件机制一样在 main 注册,而是需要将我们平时使用的 TEST 宏改为 TEST_F 宏。

> ○ SetUpTestCase() 函数是在测试套件第一个测试用例开始前执行 ○ TearDownTestCase() 函数是在测试套件最后一个测试用例结束后执行 ○ 需要注意 TEST_F 的第一个参数是我们创建的类名,也就是当前测试套件的 名称,这样在 TEST_F 宏的测试套件中就可以访问类中的成员了。

![](https://i-blog.)

> 能够看到在上例中,有一个好处,就是将数据与测试结合到同一个测试环境类中了, 这样与外界的耦合度更低,代码也更清晰。 但是同样的,我们发现在两个测试用例中第二个测试用例失败了,这是为什么呢?这 就涉及到了 TestCase 事件的机制。

---

**TestCase 事件:** 针对一个个测试用例。测试用例的事件机制的创建和测试套件的基本一样,不同地方在于测试用例实现的两个函数分别是 SetUp 和 TearDown, 这两个函数也不是静态函数

○ SetUp()函数是在一个测试用例的开始前执行
 ○ TearDown()函数是在一个测试用例的结束后执行
 也就是说,在 TestSuite/TestCase 事件中,每个测试用例,虽然它们同用同一个事件
 环境类,可以访问其中的资源,但是本质上每个测试用例的环境都是独立的,这样我
 们就不用担心不同的测试用例之间会有数据上的影响了,保证所有的测试用例都使用
 相同的测试环境进行测试。

![](https://i-blog.)

![](https://i-blog.)

### 3.GTest 三层事件

**1.先搞懂"三层"是什么**

GTest 的事件机制,就是**在测试前自动准备、测试后自动收拾**。它分三层,区别只有一件事:**这个"准备和收拾"多久做一次?**

用**上课**打个比方:

层次比方做几次**全局事件**学校**大门**早上开、晚上关全校 **1 次****TestSuite 事件**一个班上课前,老师进教室**开灯、擦黑板**一个班 **1 次****Test 事件**每个学生**各拿一张全新的草稿纸**每个学生 **1 次**

> 一句话就是:全局和 TestSuite 是"一群人共用一次",Test 是"每个人各来一次"。

---

**2.三个例子分别在干什么**

例子一例子二例子三**做什么**全校开一次门一个班擦一次黑板擦黑板 + 每人发新纸**继承谁**`testing::Environment``testing::Test``testing::Test`**写哪些函数**`SetUp` / `TearDown``SetUpTestCase` / `TearDownTestCase`**四个都写****用哪个宏**`TEST``TEST_F``TEST_F`**main 里要注册吗****要**不用不用**结果** 2 个全过 **挂了一个** 2 个全过

---

**3.例子二为什么挂了一个?**

**GTest 有个规矩:每跑一个测试用例,就发一张全新的草稿纸。**


跑 insert_test 这个测试
↓ GTest 发了【草稿纸 A】
在纸 A 上写了 3 条数据(Hello / hello / 雷吼)
查 hello --> 查到了通过
↓ 测试结束,【草稿纸 A 被收走,连同上面的数据一起扔掉】

跑 sizeof 这个测试
↓ GTest 发了【草稿纸 B】—— 一张全新的空纸!
查 dict.size() --> 是 0
ASSERT_GT(0, 0) --> 不成立 --> 失败


**为什么数据会跟着纸走?**

 因为 `dict` 是类的**非静态成员**——它就相当于"写在草稿纸上的内容"。

定义方式相当于结果`std::unordered_map<...> dict;`(非静态)写在**草稿纸**上换张纸就没了 → **不共享**`static std::unordered_map<...> dict;`(静态)写在**黑板**上全班都看得到 → **共享**

> 注意:这个"失败"不是 GTest 的 bug,反而是故意的设计——保证每个测试都从干净的环境开始,不会因为"谁先跑谁后跑"而结果不同。

---

**4.例子三怎么把这个坑填上**

例子三只比例子二**多做了一件事**:加了 `SetUp` / `TearDown`。

**`SetUp` 的作用,相当于"每次发新草稿纸时,老师先替你把基础数据抄上去"。**


跑 insert_test
↓ 先自动执行 SetUp:往新纸上写 2 条(bye / see you)
↓ 测试体再自己加 1 条(hello)
总共 3 条 --> ASSERT_EQ(dict.size(), 3) --> 通过
↓ 自动执行 TearDown:把纸清空
↓ 草稿纸被收走

跑 size_test
↓ 先自动执行 SetUp:又往新纸上写同样的 2 条
查 hello --> 查不到(因为新纸上只有 bye / see you)
总共 2 条 --> ASSERT_EQ(dict.size(), 2) --> 通过
```

结果就是每个测试拿到的初始数据都一模一样,两个都过了。


5.三个例子的运行次数对照

看日志冒出来了几次,就能看出作用范围——这是最直观的证据:

日志例子一例子二例子三全局 SetUp / TearDown1 / 1——套件 SetUpTestCase / TearDownTestCase—1 / 11 / 1用例 SetUp / TearDown——2 / 2


6.例子二 --> 例子三:只改了一处

改了什么效果加了 SetUp / TearDown(每个用例前重填数据)FAILED → PASSED其他全一样:同样 testing::Test、同样 TEST_F、同样非静态 dict—


7.实际写代码时怎么选

各位要做的事用哪一层为什么连接数据库、加载全局配置全局事件整个程序只需要做一次建表、加载一批只读的基础数据TestSuite 事件一组测试共用一份每个测试都要一份干净、一致的数据Test 事件(最常用)避免测试之间互相干扰

为什么推荐优先用 Test 事件?
 因为它能避免最让人头疼的一类问题:"单个测试跑能过,一起跑就挂"。 这类问题排查起来极其耗时,而根源往往就是不同测试共享了可变数据。
总结: SetUpTestCase 管"整组一次",SetUp 管"每个测试各一次"。 例子二的失败不是写错了,而是暴露了"测试之间数据不共享"这个事实;例子三用 SetUp 把它补上,就对了。

就写这么多吧,内容比较基础,适合入门回顾。有补充的地方欢迎留言一起完善。

评论 (0)

暂无评论