You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

364 lines
22 KiB
Markdown

2 years ago
# 32 | 如何在一个系统中实现单元测试?
你好我是蒋德钧。今天这节课我来和你聊聊Redis中的单元测试。
单元测试通常是用来测试一个系统的某个特定功能模块通过单元测试我们可以检测开发的功能模块是否正常。对于一个像Redis这样包含很多功能模块的系统来说单元测试就显得更为重要了。否则如果让整个系统开发完成后直接进行整体测试一旦出现问题就很难定位了。
那么,**对于一个包含多功能模块的系统来说,我们该如何进行单元测试呢?**Redis源码中针对其主要功能模块比如不同数据类型操作、AOF和RDB持久化、主从复制、集群等模块提供了单元测试的框架。
今天这节课我就带你来学习了解下Redis实现的单元测试框架。通过学习今天的课程内容你可以掌握如何使用Tcl语言开发一个单元测试框架这些测试开发方法也可以用在你日常的开发测试工作中。
接下来我们就先来看看Redis针对主要功能模块实现的单元测试框架。
## Tcl语言基础
通过课程的[第1讲](https://time.geekbang.org/column/article/399866)我们知道在Redis源码目录中专门有一个[tests子目录](https://github.com/redis/redis/tree/5.0/tests)这个tests目录就包含了Redis单元测试框架的实现代码。而在了解这个单元测试框架之前你首先需要知道这个框架是使用了Tcl语言来开发的。
Tcl的全称是Tool Command Language它是一种功能丰富并且很容易上手的动态编程语言经常会被应用在程序测试、运维管理等场景中。这里我先给你介绍下Tcl语言的一些基础知识和基本操作当然你也可以在Tcl语言的[官网](https://www.tcl.tk/)上学习它更加全面的开发知识。
* **Tcl程序执行**
Tcl语言本身属于解释性编程语言所以我们使用Tcl开发的程序不用编译和链接它会对每条语句解释执行。
* **数据类型与基本操作**
Tcl语言的数据类型很简单就是字符串。我们可以使用set关键字来定义变量并且不需要指定变量的类型。同时我们可以使用puts关键字来进行输出操作。
关于变量的使用,我们还需要了解两个知识点:一是,在输出某个变量的值时,我们需要使用`$`符号来引用该变量;二是,我们可以使用两个冒号开头来定义一个全局变量,比如`::testnum`就定义了一个全局变量。
下面的代码就展示了变量a的定义和输出其中变量a的值被定义为“hello tcl”。
```plain
set a “hello tcl”
puts $a
```
如果你的电脑上安装了tclsh的命令解释器你可以直接在命令行上运行tclsh这样就进入到了Tcl的命令解释执行环境中了。如果你没有安装也可以在Tcl官网上下载安装[源码包](https://www.tcl.tk/software/tcltk/8.6.html),进行编译安装。
然后你可以把刚才介绍的两个语句在tclsh的执行环境中运行如下所示
```plain
tclsh    //运行tclsh命令需安装有tclsh命令解释器
//进入tclsh的执行环境
% set a "hello tcl"
hello tcl
% puts $a 
hello tcl
```
刚才介绍的是Tcl设置和输出变量的基本操作除此之外我们还可以定义**proc子函数**用来执行会经常用到的功能。以下代码就展示了一个proc子函数的定义
```plain
proc sum {arg1 arg2} {
set x [expr $arg1+$arg2];
return $x
}
```
从代码中你可以看到proc关键字后面跟着的是函数名称sum。然后函数参数arg1和arg2会使用花括号括起来表示。这个函数体是设置了变量x的值而变量x的值等于arg1和arg2两个参数的和。
这里,你需要注意的是,**在Tcl语言中方括号可以将一条命令括起来让该命令执行并得到返回结果**。所以,在刚才介绍的代码中,`[expr $arg1+$arg2]`就表示要计算arg1和arg2的和。最后这个函数会返回变量x的值这里也是使用了`$`符号来引用变量x。
现在我们就了解了Tcl语言的一些基础知识和基本操作。接下来我们来看下Redis中使用Tcl开发的单元测试框架。当然在学习单元测试框架的过程中我也会陆续给你介绍一些Tcl开发涉及的基础知识以便你能理解测试框架的实现。
## Redis单元测试框架的实现
当我们使用Redis的单元测试框架时我们要在Redis源码的tests目录这一层执行测试脚本test\_helper.tcl如下所示
```plain
tclsh tests/test_helper.tcl
```
从这里你可以看到单元测试框架的入口是在test\_helper.tcl文件中实现的。因为Tcl是解释性语言所以test\_helper.tcl在执行时会依次解释执行其中的语句。不过你要注意的是这些语句并不是proc子函数proc子函数是要被调用执行的。下面呢我们先来了解下test\_helper.tcl执行时的基本操作。
### test\_helper.tcl运行后的基本操作
我们可以在test\_helper.tcl中查找非proc开头的语句来了解这个脚本运行后的基本操作。
实际上test\_helper.tcl运行后主要会执行以下三步操作。
* **第一步引入其他的tcl脚本文件和定义全局变量**
test\_helper.tcl脚本首先使用source关键字引入tests目录下support子目录中的redis.tcl、server.tcl等脚本文件。
这些脚本文件实现了单元测试框架所需的部分功能比如server.tcl脚本文件中就实现了启动Redis测试实例的子函数start\_server而redis.tcl脚本中实现了向测试用Redis实例发送命令的子函数。
而除了引入脚本文件之外,第一步操作还包括了定义全局变量。比如,测试框架定义了一个全局变量`::all_tests`这个全局变量包含了所有预定义的单元测试。如果我们不加任何参数运行test\_helper.tcl时测试框架就会运行`::all_tests`定义的所有测试。此外第一步定义的全局变量还包括测试用主机IP、端口号、跳过的测试用例集合、单一测试的用例集合等等。
下面的代码展示了这一步执行的部分内容你可以看下。你也可以在test\_helper.tcl文件中查看所有的引入脚本和定义的全局变量。
```plain
source tests/support/redis.tcl
source tests/support/server.tcl
 
set ::all_tests {
    unit/printver
    unit/dump
unit/auth
… }
 
set ::host 127.0.0.1 
set ::port 21111
set ::single_tests {}  //单一测试用例集合
```
了解了引入脚本和全局变量后我们再来看下test\_helper.tcl脚本执行的第二步操作也就是解析脚本参数。
* **第二步,解析脚本参数**
这一步操作是一个for循环它会在test\_helper.tcl脚本引入其他脚本和定义全局变量后接着执行。
这个循环流程本身并不复杂它的目的就是逐一解析test\_helper.tcl脚本执行时携带的参数。不过想要理解这个流程你还需要对Tcl语言的开发知识了解更多一些。比如你要知道llength关键字是用来获取一个列表长度而lindex是从一个列表中获取某个元素。
下面的代码展示了这个循环流程的基本结构你可以看下其中的注释这可以帮助你再多了解些Tcl语言开发知识。
```plain
for {set j 0} {$j < [llength $argv]} {incr j} { // 使用llength获取参数列表argv的长度
    set opt [lindex $argv $j]  //从argv参数列表中使用lindex获取第j个参数
    set arg [lindex $argv [expr $j+1]]  //从argv参数列表中获取第j+1个参数
if {$opt eq {--tags}} { …}     //处理“--tags”参数
elseif {$opt eq {--config}} { …}  //处理“--config”参数
}
```
那么在解析参数过程中如果test\_helper.tcl脚本带有“single”参数就表示脚本并不是执行所有测试用例而只是执行一个或多个测试用例。因此脚本中的全局变量`::single_tests`,就会保存这些测试用例,并且把全局变量`::all_tests`设置为`::single_tests`的值,表示就执行`::single_tests`中的测试用例,如下所示:
```plain
if {[llength $::single_tests] > 0} {
    set ::all_tests $::single_tests
}
```
好了在完成了对运行参数的解析后test\_helper.tcl脚本的第三步就是启动实际的测试流程。
* **第三步,启动测试流程**
在这一步test\_helper.tcl脚本会判断全局变量`::client`的值,而这个值表示是否启动测试客户端。如果`::client`的值为0那么就表明当前不是启动测试客户端因此test\_helper.tcl脚本会来执行test\_server\_main函数。否则的话test\_helper.tcl脚本会执行test\_client\_main函数。这部分逻辑如下所示
```plain
if {$::client} {  //当前是要启动测试客户端
if {[catch { test_client_main $::test_server_port } err]} { //执行test_client_main
}
else {  //当前不是启动测试客户端
  
   if {[catch { test_server_main } err]} { …}  //执行test_server_main
}
}
```
我在这里画了一张图展示了 test\_helper.tcl脚本执行的基本流程你可以再回顾下。
![图片](https://static001.geekbang.org/resource/image/e4/a9/e4f15820ffb3afd24ef2abc543fe36a9.jpg?wh=1920x1080)
其实test\_server\_main和test\_client\_main这两个函数都是为了最终启动测试流程的。那么它们的作用分别是什么呢下面我们就来了解下。
### test\_server\_main函数
test\_server\_main函数的主要工作包括三步操作。
**首先它会使用socket -server命令启动一个测试server。**这个测试server会创建一个socket监听来自测试客户端的消息。那么一旦有客户端连接时测试server会执行accept\_test\_clients函数。这个过程的代码如下所示
```plain
socket -server accept_test_clients -myaddr 127.0.0.1 $port
```
对于accept\_test\_clients函数来说它会调用fileevent命令监听客户端连接上是否有读事件发生。如果有读事件发生这也就表示客户端有消息发送给测试server。那么它会执行read\_from\_test\_client函数。这个过程如下所示
```plain
proc accept_test_clients {fd addr port} {
   
    fileevent $fd readable [list read_from_test_client $fd]
}
```
而read\_from\_test\_client函数会根据测试客户端发送的不同消息来执行不同的代码分支。比如当测试客户端发送的消息是“ready”这就表明当前客户端是空闲的那么测试server可以把未完成的测试用例再发给这个客户端执行这个过程是由signal\_idel\_client函数来完成的你可以仔细阅读下它的源码。
再比如当测试客户端发送的消息是“done”时read\_from\_test\_client函数会统计当前已经完成的测试用例数量而且也会调用signal\_idel\_client函数让当前客户端继续执行未完成的测试用例。关于read\_from\_test\_client函数的不同执行分支你也可以阅读它的代码来做进一步了解。
好了在test\_server\_main函数的第一步它主要是启动了测试server。那么**接下来的第二步,它会开始启动测试客户端。**
test\_server\_main函数会执行一个for循环流程在这个循环流程中它会根据要启动的测试客户端数量依次调用exec命令执行tcl脚本。这里的测试客户端数量是由全局变量`::numclients`决定的默认值是16。而执行的tcl脚本正是当前运行的test\_helper.tcl脚本参数也和当前脚本的参数一样并且还加上了“client”参数表示当前启动的是测试客户端。
下面的代码展示了刚才介绍的这个for循环流程你可以看下。
```plain
for {set j 0} {$j < $::numclients} {incr j} {
   set start_port [find_available_port $start_port] //设定测试客户端端口
   //使用exec命令执行test_helper.tcl脚本script脚本参数和当前脚本一致增加client参数表示启动的是测试客户端增加port参数表示客户端端口
   set p [exec $tclsh [info script] {*}$::argv \
            --client $port --port $start_port &]
   lappend ::clients_pids $p  //记录每个测试客户端脚本运行的进程号
   incr start_port 10 //递增测试客户端的端口号
}
```
这里你要注意下当test\_helper.tcl脚本运行参数包含“client”时它在解析运行参数时会把全局变量`::client`设置为1如下所示
```plain
for {set j 0} {$j < [llength $argv]} {incr j} {
  
   elseif {$opt eq {--client}} {
        set ::client 1
       
}
```
这样一来我们在刚才介绍的循环流程中执行的这个test\_helper.tcl脚本就会根据全局变量`::client`的值实际启动测试客户端也就是会执行test\_client\_main函数如下所示
```plain
if {$::client} {  //如果::client值为1那么执行test_client_main函数
if {[catch { test_client_main $::test_server_port } err]} {…}
}
```
那么,在启动了测试客户端后,**test\_server\_main函数的最后一步就是每隔10s周期性地执行一次test\_server\_cron函数。**而这个函数的主要工作是当测试执行超时的时候输出报错信息并清理测试客户端和测试server。
好了到这里你就了解了测试server的执行函数test\_server\_main主要是启动socket等待客户端连接和处理客户端消息以及启动测试客户端。下图展示了test\_server\_main函数的基本流程你可以再回顾下。
![图片](https://static001.geekbang.org/resource/image/8e/0e/8e04995a359e109480a2183101ea1e0e.jpg?wh=1920x866)
那么接下来我再带你来看下测试客户端对应的执行函数test\_client\_main。
### test\_client\_main函数
test\_client\_main函数在执行时会先向测试server发送一个“ready”的消息。而刚才我提到测试server一旦监听到有客户端连接发送了“ready”消息它就会通过**signal\_idle\_client函数**,把未完成的单元测试发送给这个客户端。
具体来说signal\_idle\_client函数会发送“run 测试用例名”这样的消息给客户端。比如当前未完成的测试用例是unit/type/string那么signal\_idle\_client函数就会发送“run unit/type/string”消息给测试客户端。你也可以看看下面的代码
```plain
send_data_packet $fd run [lindex $::all_tests $::next_test] //从::all_tests中取出下一个未测试的用例发送给客户端发送消息为“run 测试用例名”
```
那么当test\_client\_main函数在发送了“ready”消息之后就会执行一个while循环流程等待从测试server读取消息。等它收到测试server返回的“run 测试用例名”的消息时它就会调用execute\_tests函数执行相应的测试用例。
下面的代码展示了刚才介绍的test\_client\_main函数的基本执行过程你可以看下。
```plain
proc test_client_main fd {
send_data_packet $::test_server_fd ready [pid] //向测试server发送ready消息
    while 1 {   //读取测试server发送的单元测试信息
       
        set payload [read $::test_server_fd $bytes]  //读取测试server的消息
        foreach {cmd data} $payload break //cmd为测试server发送的命令data为cmd命令后的消息内容
        if {$cmd eq {run}} {  //如果消息中有“run”命令
            execute_tests $data   //调用execute_tests执行data对应的测试用例
        }
…}
```
然后这里,我们再来看下**执行测试用例的execute\_tests函数**。这个函数比较简单它就是根据传入的测试用例名用source命令把tests目录下该用例对应的tcl脚本文件引入并执行。最后给测试server发送“done”的消息。
这部分代码如下所示:
```plain
proc execute_tests name {
    set path "tests/$name.tcl"  //在tests目录下找到对应测试用例文件
    set ::curfile $path
    source $path  //引入并执行测试用例的脚本文件
    send_data_packet $::test_server_fd done "$name" //测试用例执行完后发送“done”消息给测试server
}
```
从这里我们能发现单元测试框架在测试时其实就是执行每个测试用例的tcl脚本文件这也就是说每个测试用例对应的测试内容在它的测试脚本中都已经编写好了框架直接执行测试脚本就行。
那么,下面我们就来看看测试用例的实现。
### 测试用例的实现
Redis单元测试框架中的测试用例有很多在刚才介绍的全局变量`::all_tests`中都有定义。这里我们以针对String数据类型的测试用例**unit/type/string**为例,来了解下框架中测试用例的开发实现。
unit/type/string测试用例对应的测试脚本是string.tcl。这个脚本**首先会调用start\_server函数**启动一个测试用Redis实例而start\_server函数是在server.tcl文件中定义的你可以进一步阅读这个函数的源码了解它的实现。
**然后,测试脚本会分别测试不同的测试项**它会调用r函数来给测试用的Redis实例发送具体的命令。比如在下面的代码中测试脚本就发送测试了set和get两个命令。
```plain
start_server {tags {"string"}} {
    test {SET and GET an item} {
        r set x foobar
        r get x
} {foobar}
}
```
那么,这里发送测试命令的**r函数**在test\_helper.tcl文件中它其实会通过srv函数在test\_helper.tcl文件中从框架配置中获取名为`::redis::redisHandle`的函数。
而这个`::redis::redisHandle`函数是在redis.tcl文件中先和`::redis::__dispatch__`函数进行了关联,表示由`::redis::__dispatch__`函数来执行。不过,`::redis::__dispatch__`函数会进一步调用`::redis::__dispatch__raw__`函数,来实际发送测试命令。
这里,你需要注意的是,刚才介绍的这三个函数名中都会带有**id号**。这个id号是脚本在运行过程中动态赋值的并且它表示的是测试命令要发送的测试用Redis实例的socket描述符。
下面的代码展示了`::redis::redisHandle`函数的关联定义,以及`::redis::__dispatch__`函数的基本定义,你可以看下。
```plain
proc redis {{server 127.0.0.1} {port 6379} {defer 0}} {
interp alias {} ::redis::redisHandle$id {} ::redis::__dispatch__ $id
}
 
proc ::redis::__dispatch__ {id method args} {
set errorcode [catch {::redis::__dispatch__raw__ $id $method $args} retval]
}
```
到这里,我们就知道**最终实际发送测试命令的,其实是`::redis::__dispatch__raw__`函数**这个函数会按照RESP协议封装Redis命令并发送给测试用的Redis实例你可以看看下面的代码。
```plain
proc ::redis::__dispatch__raw__ {id method argv} {
set fd $::redis::fd($id)  //获取要发送的测试用Redis实例的socket描述符
//按照RESP协议封装Redis命令
set cmd "*[expr {[llength $argv]+1}]\r\n"  //封装命令及参数个数
append cmd "$[string length $method]\r\n$method\r\n" //封装命令名称
foreach a $argv {  //封装命令参数
   append cmd "$[string length $a]\r\n$a\r\n"
}
::redis::redis_write $fd $cmd  //向测试用Redis实例发送测试命令
…}
```
这样一来,测试客户端就可以把测试用例中的命令发送给测试实例,并根据返回结果判断测试是否正常执行了。
我在画了一张图展示了测试server、测试客户端和测试用例的交互以及它们在测试框架中各自的主要职责你可以再整体回顾下。
![图片](https://static001.geekbang.org/resource/image/50/21/5038488c2eea78507e3aab07c4ea4321.jpg?wh=1920x1080)
## 小结
今天这节课我们学习了Redis的单元测试框架。这个测试框架是用Tcl语言开发的所以在学习这个框架前我们需要先掌握一些Tcl语言的开发基础知识。因为Tcl语言本身的数据类型比较简单所以学习Tcl语言主要就是了解它使用的众多的关键字命令。这也是你接下来可以重点去学习的内容。
而在单元测试框架的实现中,主要是包括了三个角色,分别是**测试server、测试客户端和测试用例**,它们之间的关系是这样的:
* 测试server启动后负责启动测试客户端并和测试客户端交互通过“run 测试用例名”消息向测试客户端发送测试用例。
* 测试客户端和测试server建立连接后会向server发送“ready”消息。在接收到server发送的“run 测试用例名”消息后客户端通过execute\_tests函数引入并执行对应的测试脚本。
* 测试脚本会通过start\_server函数启动测试用的Redis实例然后使用测试客户端提供的r函数向测试实例发送测试命令而r函数实际会调用`::redis::__dispatch__raw__`函数,来完成命令发送。
最后我也想再提醒你一下如果你想要进一步深入学习和掌握Redis单元测试框架的话一定要厘清刚才总结的测试server、测试客户端和测试用例的关系这样你才能理解整个测试过程是如何进行的。另外因为Tcl语言的开发比较简单所以你在学习了Redis单元测试框架后也可以参考它实现自己的测试框架。
## 每课一问
Redis源码中还有一个针对SDS的小型测试框架你知道这个测试框架是在哪个代码文件中吗