本文主要介绍了我在开发中引入单元测试的实践

写在前面(我遇到的现状)

我想,在大多数对软件质量没有严格管理或者本身产品要求不高的情况下,有很多的嵌入式开发人员也许都是遵循了这样一套开发模式:

  • 编码实现一个功能/算法
  • 手工调用这个功能/算法,工程师自主判断结果是否与预期符合
  • 如果通过,继续编码实现下一个功能
  • 重复上述直到整体开发完成
  • 对整体的软件进行测试

不去讨论上述开发模式存在的隐患与风险,也不去评价此模式好坏。万事万物皆有其生存之道,黑猫白猫能抓到猫就是好猫。

"敏捷"开发示例

我想,很多开发人员都不可能一次将功能开发正确与完整(天才忽略)。为了引入单元测试的技能和思想,我们先来模拟一下上面的开发过程。

以一个简易的示例来说:

日常开发中,求平均值是很常见的计算方法。在现实世界,求平均值的思路就是明确的,给定数据集,求和,最后总和除以数据集的个数就能得到平均值;然而编码的实现远没有那么简单,我们需要去圈定一个范围:

  • 数据集的构成?小数还是整数
  • 数据集的上下限?最大值跟最小值的可能情况

闲话少说,这里我直接圈定一个集合:

  • 数据集都是正数且都是整数
  • 数据集最小值0,最大值1000
  • 数据集的最大数据量为64个数据

我们用C语言实现求平均值的计算:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* @brief 计算指定数据集的平均值
*
* @param buffer 数据集的集合
* @param size 数据集数据的个数
* @return uint16_t
*/
uint16_t u16_calculate_buffer_average(uint16_t* buffer, uint8_t size) {
uint16_t* v = buffer;
uint16_t average = 0u;
for(uint8_t i = 0; i < size; i++) {
average += v[i];
}
average = (uint16_t)(average / size);
return average;
}

程序实现很简单,因为数据在0到1000之间,我们选择无符号16位的变量,考虑到最大值1000,即使64次累加也不会溢出,所以average变量也选择16位无符号数据。

实现之后我们需要手动测试下这个计算方法是否正确,在main函数中指定一个数据集,并测试计算结果是否与预期一致

1
2
3
4
5
6
7
8
9
10
11
12
static uint16_t data_fifo[64] = {0};
int main(void) {
/* 先手动赋值0,2,4,8,...,126 */
for (uint8_t i = 0; i < 64; i++) {
data_fifo[i] = i * 2;
}
/* 调用计算函数测试算法正确性 */
uint16_t res = u16_calculate_buffer_average(data_fifo, 64);

/* 打印结果 */
printf("result=%d\n", res);
}

从代码上看,从0开始,步进1的等差数列的平均值计算结果,口算为:

(0+126)×642×64=63\frac{(0+126)\times 64}{2\times 64}=63

按照手动自测模式,计算平均值的功能就算初步实现完成了,在随机测试几组数据集,就可以进一步开发别的功能了。

潜在的Bug风险

或者说,上面这个计算平均值的策略真的万无一失吗?还真不一定。我们以从0开始,步进为1得等差数列为例:

(0+63)×642×64=31.5\frac{(0+63)\times 64}{2\times 64}=31.5

该数据集的实际平均值为31.5,在计算机程序中,整型数据会直接向下取整,没有四舍五入的操作,最终的结果是31,由此也可判断,如果某个数据集的计算结果为31.9999…,最终也会为31整数值;实际上,原数值应该更加靠近32才对。

这种情况下,就需要评估该误差是否对最终功能有没有影响,如果误差不可忽略,那么这就是一个潜在的bug,简单的手工测试并不能每次都会测出来这样的结果,或者说不同的工程师对该问题的敏感度不同,容易忽略。

更新与迭代

假设上述计算平均值方法运行良好,且无bug出现,但是,某一天,使用该方法的需求变了,数据集因为某些原因变成如下:

  • 数据集都是正数且都是整数
  • 数据集最小值0,最大值1000
  • 数据集的最大数据量为500个数据

因为在未来的时间点,我想大部分人都不会记得该平均值计算的具体实现的,这时候又会重新检查每一行代码,然后再手工测试。

回到代码实现本身,原先某些实现就不适用了,需要在此考虑溢出和取值的范围问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* @brief 计算指定数据集的平均值
*
* @param buffer 数据集的集合
* @param size 数据集数据的个数
* @return uint16_t
*/
- uint16_t u16_calculate_buffer_average(uint16_t* buffer, uint8_t size) {
+ uint16_t u16_calculate_buffer_average(uint16_t* buffer, uint16_t size) {
uint16_t* v = buffer;
- uint16_t average = 0u;
+ uint32_t average = 0u;
- for(uint8_t i = 0; i < size; i++) {
+ for(uint16_t i = 0; i < size; i++) {
average += v[i];
}
average = (uint16_t)(average / size);
return average;
}

再为新的数据集修正了实现后,依旧需要进行手工测试。

引入单元测试后

如果我们事先建立一个标准的测试流程来测试计算平均值的策略,在代码有所更改的情况下,自动化执行该测试流程,就省去了二次手工测试的麻烦;并且,标准的测试流程应尽可能覆盖住多数的测试情况。

单元测试应用示例(潜在bug覆盖性测试)

以GoogleTest框架(测试框架和配置使用不是本章讨论的内容)为例,我们针对计算平均值指定这样一套完备的测试流程:

  • 输入数据集的最大临界值,所有值都是1000的情况,数据个数为64
  • 输入数据集的最小临界值,所有值都是0的情况,数据个数为64
  • 输入数据集为常规值,固定为从0开始,步进为1的数据集,共64个数据
  • 输入数据集为常规值,固定为从0开始,步进为1的数据集,共64个数据,但是最后一个数据为37(验证平均值向下接近的情况)
  • 输入数据集为常规值,固定为从0开始,步进为1的数据集,共64个数据,但是最后一个数据为90(验证平均值向上接近的情况)
  • 输入数据集为常规值,固定为从0开始,步进为17的数据集,共64个数据
  • 输入数据为256,输入数据长度为1
  • 输入数据集为常规值,固定为从0开始,步进为21的数据集,输入数据长度为32

以上测试覆盖了输入参数的上下临界值测试,以及非正常数据的测试,代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
TEST(u16_calculate_buffer_average, data_max_input) {
uint16_t data_fifo[64] = {0};
for (uint8_t i = 0; i < 64; i++) {
data_fifo[i] = 1000;
}
uint16_t res = u16_calculate_buffer_average(data_fifo, 64);
EXPECT_EQ(res, 1000);
}

TEST(u16_calculate_buffer_average, data_min_input) {
uint16_t data_fifo[64] = {0};
for (uint8_t i = 0; i < 64; i++) {
data_fifo[i] = 0;
}
uint16_t res = u16_calculate_buffer_average(data_fifo, 64);
EXPECT_EQ(res, 0);
}

TEST(u16_calculate_buffer_average, result_mid_round) {
uint16_t data_fifo[64] = {0};
for (uint8_t i = 0; i < 64; i++) {
data_fifo[i] = i;
}
uint16_t res = u16_calculate_buffer_average(data_fifo, 64);
EXPECT_EQ(res, 32);
}

TEST(u16_calculate_buffer_average, result_down_round) {
uint16_t data_fifo[64] = {0};
for (uint8_t i = 0; i < 63; i++) {
data_fifo[i] = i;
}
data_fifo[63] = 37;
uint16_t res = u16_calculate_buffer_average(data_fifo, 64);
EXPECT_EQ(res, 31);
}

TEST(u16_calculate_buffer_average, result_up_round) {
uint16_t data_fifo[64] = {0};
for (uint8_t i = 0; i < 63; i++) {
data_fifo[i] = i;
}
data_fifo[63] = 90;
uint16_t res = u16_calculate_buffer_average(data_fifo, 64);
EXPECT_EQ(res, 32);
}

TEST(u16_calculate_buffer_average, size_max) {
uint16_t data_fifo[64] = {0};
for (uint8_t i = 0; i < 64; i++) {
data_fifo[i] = i * 17;
}
uint16_t res = u16_calculate_buffer_average(data_fifo, 64);
EXPECT_EQ(res, 536);
}

TEST(u16_calculate_buffer_average, size_min) {
uint16_t data = 256;
uint16_t res = u16_calculate_buffer_average(&data, 1);
EXPECT_EQ(res, 256);
}

TEST(u16_calculate_buffer_average, size_mid) {
uint16_t data_fifo[32] = {0};
for (uint8_t i = 0; i < 32; i++) {
data_fifo[i] = i * 21;
}
uint16_t res = u16_calculate_buffer_average(data_fifo, 32);
EXPECT_EQ(res, 326);
}

执行单元测试后,我们发现,只剩下四舍五入没有通过,要实现四舍五入有很多方式:

  • 用浮点数计算后进行round操作
  • 求和的结果追加0.5个数据长度值

这里我们采用第二种方法来修正这个bug:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* @brief 计算指定数据集的平均值
*
* @param buffer 数据集的集合
* @param size 数据集数据的个数
* @return uint16_t
*/
uint16_t u16_calculate_buffer_average(uint16_t* buffer, uint8_t size) {
uint16_t* v = buffer;
uint16_t average = 0u;
for(uint8_t i = 0; i < size; i++) {
average += v[i];
}
+ average = average + size / 2;
average = (uint16_t)(average / size);
return average;
}

随后我们重新执行整个测试流程:

在修正了四舍五入的bug之后,测试就全部通过了

更新与迭代

在未来某一天,因为底层数据集个数发生变化:

  • 数据集的最大数据量为64个数据
  • 数据集的最大数据量为500个数据

在之前测试用例的基础上,只需要稍加修改测试临界值的一个测试用例即可:

  • 输入数据集的最大临界值,所有值都是1000的情况,数据个数为64
  • 输入数据集的最大临界值,所有值都是1000的情况,数据个数为500
1
2
3
4
5
6
7
8
9
10
11
TEST(u16_calculate_buffer_average, size_max) {
- uint16_t data_fifo[64] = {0};
+ uint16_t data_fifo[500] = {0};
- for (uint8_t i = 0; i < 64; i++) {
+ for (uint16_t i = 0; i < 500; i++) {
data_fifo[i] = 1000;
}
- uint16_t res = u16_calculate_buffer_average(data_fifo, 64);
+ uint16_t res = u16_calculate_buffer_average(data_fifo, 500);
EXPECT_EQ(res, 1000);
}

在原有版本的基础上测试无法通过,需要去修改相应的计算逻辑了。

当然,这里也许会有疑问,为啥不一开始就将计算平均值考虑到非常大的计算值呢?

这是个好问题,如果经常写嵌入式代码,特别是在资源不那么充足的单片机,甚至8位机上,数据的位宽影响是很明显的;当然本文这里也有为了举例而举例的嫌疑。

总之,考虑到人工测试中一测而过不留痕迹容易忽视潜在bug,以及功能迭代,在嵌入式开发中引入单元测试框架还是很有必要的。

结束语

下一章,我将介绍如何搭建适用于嵌入式开发的单元测试框架。