你好,我是蒋德钧。今天这节课,我来和你聊聊Redis中的单元测试。
单元测试通常是用来测试一个系统的某个特定功能模块,通过单元测试,我们可以检测开发的功能模块是否正常。对于一个像Redis这样包含很多功能模块的系统来说,单元测试就显得更为重要了。否则,如果让整个系统开发完成后直接进行整体测试,一旦出现问题,就很难定位了。
那么,对于一个包含多功能模块的系统来说,我们该如何进行单元测试呢?Redis源码中针对其主要功能模块,比如不同数据类型操作、AOF和RDB持久化、主从复制、集群等模块,提供了单元测试的框架。
今天这节课,我就带你来学习了解下Redis实现的单元测试框架。通过学习今天的课程内容,你可以掌握如何使用Tcl语言开发一个单元测试框架,这些测试开发方法也可以用在你日常的开发测试工作中。
接下来,我们就先来看看Redis针对主要功能模块实现的单元测试框架。
通过课程的第1讲我们知道,在Redis源码目录中,专门有一个tests子目录,这个tests目录就包含了Redis单元测试框架的实现代码。而在了解这个单元测试框架之前,你首先需要知道,这个框架是使用了Tcl语言来开发的。
Tcl的全称是Tool Command Language,它是一种功能丰富并且很容易上手的动态编程语言,经常会被应用在程序测试、运维管理等场景中。这里,我先给你介绍下Tcl语言的一些基础知识和基本操作,当然你也可以在Tcl语言的官网上学习它更加全面的开发知识。
Tcl语言本身属于解释性编程语言,所以,我们使用Tcl开发的程序不用编译和链接,它会对每条语句解释执行。
Tcl语言的数据类型很简单,就是字符串。我们可以使用set关键字来定义变量,并且不需要指定变量的类型。同时,我们可以使用puts关键字来进行输出操作。
关于变量的使用,我们还需要了解两个知识点:一是,在输出某个变量的值时,我们需要使用$
符号来引用该变量;二是,我们可以使用两个冒号开头来定义一个全局变量,比如::testnum
就定义了一个全局变量。
下面的代码就展示了变量a的定义和输出,其中变量a的值被定义为“hello tcl”。
set a “hello tcl”
puts $a
如果你的电脑上安装了tclsh的命令解释器,你可以直接在命令行上运行tclsh,这样就进入到了Tcl的命令解释执行环境中了。如果你没有安装,也可以在Tcl官网上下载安装源码包,进行编译安装。
然后,你可以把刚才介绍的两个语句在tclsh的执行环境中运行,如下所示:
tclsh //运行tclsh命令,需安装有tclsh命令解释器
//进入tclsh的执行环境
% set a "hello tcl"
hello tcl
% puts $a
hello tcl
好,刚才介绍的是Tcl设置和输出变量的基本操作,除此之外,我们还可以定义proc子函数,用来执行会经常用到的功能。以下代码就展示了一个proc子函数的定义:
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源码的tests目录这一层执行测试脚本test_helper.tcl,如下所示:
tclsh tests/test_helper.tcl
从这里,你可以看到,单元测试框架的入口是在test_helper.tcl文件中实现的。因为Tcl是解释性语言,所以test_helper.tcl在执行时,会依次解释执行其中的语句。不过你要注意的是,这些语句并不是proc子函数,proc子函数是要被调用执行的。下面呢,我们先来了解下test_helper.tcl执行时的基本操作。
我们可以在test_helper.tcl中查找非proc开头的语句,来了解这个脚本运行后的基本操作。
实际上,test_helper.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文件中,查看所有的引入脚本和定义的全局变量。
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语言开发知识。
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
中的测试用例,如下所示:
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函数。这部分逻辑如下所示:
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脚本执行的基本流程,你可以再回顾下。
其实,test_server_main和test_client_main这两个函数都是为了最终启动测试流程的。那么,它们的作用分别是什么呢?下面我们就来了解下。
test_server_main函数的主要工作包括三步操作。
首先,它会使用socket -server命令启动一个测试server。这个测试server会创建一个socket,监听来自测试客户端的消息。那么,一旦有客户端连接时,测试server会执行accept_test_clients函数。这个过程的代码如下所示:
socket -server accept_test_clients -myaddr 127.0.0.1 $port
对于accept_test_clients函数来说,它会调用fileevent命令,监听客户端连接上是否有读事件发生。如果有读事件发生,这也就表示客户端有消息发送给测试server。那么,它会执行read_from_test_client函数。这个过程如下所示:
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循环流程,你可以看下。
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,如下所示:
for {set j 0} {$j < [llength $argv]} {incr j} {
…
elseif {$opt eq {--client}} {
set ::client 1
…
}
这样一来,我们在刚才介绍的循环流程中,执行的这个test_helper.tcl脚本,就会根据全局变量::client
的值,实际启动测试客户端,也就是会执行test_client_main函数,如下所示:
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函数的基本流程,你可以再回顾下。
那么接下来,我再带你来看下测试客户端对应的执行函数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”消息给测试客户端。你也可以看看下面的代码:
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函数的基本执行过程,你可以看下。
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”的消息。
这部分代码如下所示:
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两个命令。
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__
函数的基本定义,你可以看下。
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实例,你可以看看下面的代码。
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、测试客户端和测试用例的交互,以及它们在测试框架中各自的主要职责,你可以再整体回顾下。
今天这节课,我们学习了Redis的单元测试框架。这个测试框架是用Tcl语言开发的,所以,在学习这个框架前,我们需要先掌握一些Tcl语言的开发基础知识。因为Tcl语言本身的数据类型比较简单,所以学习Tcl语言,主要就是了解它使用的众多的关键字命令。这也是你接下来可以重点去学习的内容。
而在单元测试框架的实现中,主要是包括了三个角色,分别是测试server、测试客户端和测试用例,它们之间的关系是这样的:
::redis::__dispatch__raw__
函数,来完成命令发送。最后,我也想再提醒你一下,如果你想要进一步深入学习和掌握Redis单元测试框架的话,一定要厘清刚才总结的测试server、测试客户端和测试用例的关系,这样你才能理解整个测试过程是如何进行的。另外,因为Tcl语言的开发比较简单,所以你在学习了Redis单元测试框架后,也可以参考它实现自己的测试框架。
Redis源码中还有一个针对SDS的小型测试框架,你知道这个测试框架是在哪个代码文件中吗?
评论