Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

【设计】多个测试用例共享初始化代码机制 #10

Open
lymslive opened this issue Apr 11, 2024 · 2 comments
Open

【设计】多个测试用例共享初始化代码机制 #10

lymslive opened this issue Apr 11, 2024 · 2 comments
Labels
enhancement New feature or request

Comments

@lymslive
Copy link
Owner

lymslive commented Apr 11, 2024

问题与背景

有时在写单元测试用例时,每个或多个用例的开始都要写一些通用的初始代码,比如说建
立数据库连接,或其他较消耗较大的加载初始化。

最原始的想法是,抽出一个公用函数,然后由单元测试用例调用之,如:

void InitSub() { ... }

DEF_TAST(test_aaa, "")
{
  InitSub();
  ...
}

DEF_TAST(test_bbb, "")
{
  InitSub();
  ...
}

这样在命令行单独指定运行每一个用例都正常,但默认无参运行所有用例时,可能会有问题。
即使初始化函数是可重入幂等的,重复执行也是一种浪费。

手动初步改进方案是在设计初始化函数时,采用一些技巧,让它只执行一次,类似单例机
制,例如:

void InitSub()
{
  static bool once_ = false;
  if (once_) return;
  once_ = ture;
  ... // 实际初始化代码
}

这样,每个测试用例调用它一次,都是无害的,也是有效的,可以保证只初始化一次。
实际上也为这三行代码定义了一个宏 CALL_FUNCTION_ONCE 可供方便使用。

自动初始化机制提案

以上手动档的方案,明确调用,其实也足够简单有效。

但如果想要往自动化进步一点,允许用户不用手动调用初始化函数,可以如何设计呢?
初步方案如下:

DEF_INIT(...)
{
  ...
}

DEF_TAST(test_aaa, "")
{
  ...
}

DEF_TAST(test_bbb, "")
{
  ...
}

以文件(编译单元)为模块,每个文件内可以定义一个 DEF_INIT 初始化函数,然后在
这个文件内定义的其他单元测试用例,都隐式自动调用这个初始化函数。目前,每个由
DEF_TASTDEF_TOOL 定义的用例,都保存了当前文件名(与行号)信息,所以理
论上是可以找到当前文件内定义的 DEF_INIT ,可以实现该机制。DEF_INIT 也没必
要写在文件开始,其他用例之前,但写在前面也是个好风格与好主意。

略有存疑的是 DEF_INIT 宏内,是否要加参数。如果限定每个文件内唯一,省去命名的
麻烦是个优点。不过能命名的话,虽然要手动保证不出现命名冲突,也有不限唯一的灵活
性,以及在必要时手动显式调用,比如需要调用(依赖)另一个文件模块的初始化。

综合考虑,我还是倾向于,如技术实现方便,还是按简单原则,文件级唯一初始化函数不必
命名,但是加个可选的描叙性参数是允许的。真还想手动显式调用或复杂依赖时,可返回
前面手动调用拆解函数及 CALL_FUNCTION_ONCE 插入标记的机制。

性能代价交换

保存每个文件可能的 DEF_INIT 函数,典型如用 std::map ,在执行每个测试用例时
需要额外执行一次按文件名查找。这需要一定代价,但应在可接受范围内。

共享清理工作的必要性考察

想到这个概念来源于 gtest 的 test fixture ,一类自定义测试套件,除了其中每个测
试用例有独立的初始与清理代码,还有个针对整个套件的只调一次的初始化函数与清理函
数。

在 Issue #3 中,也扩展了对测试套件的简单支持,但出于基本理念的不同,只可定义每
个用例的初始化 setup 与清理 teardown ,并没有针对套件的一次初始化与清理。

如果要对某部分相关测试用例分组,我觉得按这里提案,按文件级分组比用基类分组更直
观。所以在一个文件中若定义了 DEF_INIT ,它既可影响该文件内用 DEF_TAST 定义
的普通用例,也可影响用 DEC_TAST 定义的套件用例。即使没有实现这个 DEF_INIT
,也可以在自定义套件类的初始化 setup 函数中额外调用一个只运行一次的函数
CALL_FUNCTION_ONCE的手动保证)

然而,并没有能简单实现的针对一组相关用例的一次清理函数机制。例如,在文件级分组,
试图定义一个 DEF_UNINIT 会比 DEF_INIT 复杂得多。

但我觉得这个有点是伪需求,只在极端情况下用得到。比如两组以上的单元测试用例,每
组内有大量用例,每组有个共享的一次性初始代码,其中申请了大量资源,需要在这组用
例运行完释放资源,避免在运行下一组测试用例时浪费资源。对此,我的建议要么容忍占
用资源,单元测试程序即使耗时,也不是死循环的长期服务,运行完自然会释放。要么对
每组测试用例分开编译,不必硬塞到同一个测试程序中。

在 couttast 库,若一定要实现文件级分组测试用例的一次清理工作,也并非不可能。在
解析完命令行过滤待运行用例后,制定测试计划,找出同文件的用例进行管理,某个文件
内的用例全运行完后,再调用清理代码。只不过,这个功能实现的性价比,比文件级分组
的一次初始化要低得多。

@lymslive lymslive added the enhancement New feature or request label Apr 11, 2024
@lymslive
Copy link
Owner Author

后来又想了一下,专门设计一个新宏 DEF_INIT 来为文件级分组用例实现一次共享初始
化,也可能是没必要,非紧迫的事。

试想一下,用户这么写个 DEF_INIT 初化函数:

DEF_INIT()
{
  ...
}

与手动写个一次性初始化函数:

static void init_test()
{
  CALL_FUNCTION_ONCE();
  ...
}

工作量差不多的,之前用的 CALL_FUNCTION_ONCE 这个宏名字长不算事,换个名字或不
用,直接展开也就三句直白话。而实现 DEF_INIT 宏的体量与 DEF_TAST 差不多,用
户需要多记一个宏的概念、意义与用法。

至于后面每个测试用例体需要多一行代码显式调用 init_test() 也不是很难的事。正
经人写代码,都常用 CV 大法(或 yp 大法),拷贝上一个测试用例的代码,在其基础上
改改,留着那行 init_test() 就是。而且既然是 static 函数,在不同文件内都可
用相同的初始化函数名字,跨文件拷贝也不必改名。就把这个函数命名 def_init()
可以。

此外,如果某次写个简单的用例,用不着共享初始化,也可以临时注释掉。而用
DEF_INIT 固定机制,就没这个灵活性了。

还有一点,如果想实现 DEF_INIT 上述机制,还要改 tinytast.hpp 核心头文件,这
是紧耦合的基础功能,独立写在他处反而不好。而我想保持 tinytast.hpp 的轻量化,
能不改就不改了。

所以这个功能似乎非必需,普适性不够。真有需求了,按这里的推荐方法手动写初始化函
数,手动显式调用,也算针对这种测试需求的设计模式吧。

@lymslive lymslive added the wontfix This will not be worked on label Apr 11, 2024
@lymslive
Copy link
Owner Author

lymslive commented Apr 26, 2024

还是实现了这个功能。宏名与初设有所不同。

提出了 unit 的概念,双关,表示一个编译单元即源文件包含的一组单元测试,可能构成一个测试模块,可能需要有个统一的一次性初始化函数 ,可以用于申请共享资源,比如关联全局变量,当然最好是文件级静态变量。也可以有个可选的清理函数,非必需。

  • SETUP() 定义初始化函数,表示当前文件将维护一个测试单元。
  • TEARDOWN() 定义清理函数,可选。
  • DEU_TAST(...)DEU_TOOL(...) 定义测试用例存于当前测试单元。必须在 SETUP 之后才能使用。

定义用例的宏名类似 DEF_TASTDEF_TOOL 意义,自动与手动的区别,F 字母改 U 表示 unit 。

在运行任一个 DEU_TASTDEU_TOOL 用例前会自动调用 SETUP ,且只调一次。所有 DEU_TAST 用例运行完毕自动调用 TEARDOWN ,并复原 SETUP 调用状态。未定义 TEARDOWN 或运行 DEU_TOOL 用例时不会触发清理函数。

这个实现方式是为了在不给普通 DEF_TAST 测试用例增加额外负担的权衡。只有 DEU_TAST 系才需要额外检查是否需要调用初始化与清理函数。清理函数在自动运行大量测试用例时才更有意义,手动运行少量 DEU_TOOL 用例时不检查清理函数。

可能存在极端罕见情况,在某些命令行参数筛选之下,所有 DEU_TAST 运行完后,调用了 TEARDOWN,却还要继续运行一些 DEU_TOOL ,如此会再次调用 SETUP 。所以 TEARDOWN 要么不定义,定义了就最好保证语义上是 SETUP 的逆操作,之后允许 SETUP 重入不会有问题。

在当前文件仍可定义自由的普通的 DEF_TAST 。不过从测试用例管理看,一旦启用了 SETUP ,最好随后只用 DEC_TAST 系。不用 SETUP 的文件,就用普通 DEF_TAST

初始化 SETUP 与清理函数 TEARDOW 命名,与测试套件类(suite class #3)保持一致。但注意测试套件类的 setupteardown 对象方法是每个用例都会运行的,setupFirstteardownLoast 静态方法才是只执行一次的。文件级的用例,用户层面不必创建类,仍是基于函数的,所以没有类实例的概念,只有对应静态方法的初始化与清理函数。

另外,测试单元 SETUPDEU_TAST 可放在命名空间中,只影响当作作用域,但不能跨文件的命名空间。测试套件类可以跨文件,只要将类定义放在头文件中在其他文件中包含。SETUP 设计为在源文件使用,没有简洁办法放在头文件中跨文件共享,所以测试单元就该只在一个文件内管理。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

1 participant