在可靠软件的开发过程中,错误处理占用了大部分时间。程序员们需要在设计测试时关注各种边界条件和错误场景,力图最大化测试覆盖。

== 测试错误用例

在可靠软件的开发过程中,错误处理占用了大部分时间。程序员们需要在设计测试时关注各种边界条件和错误场景,力图最大化测试覆盖。

上一节介绍了 Test::Nginx::Socket 中的数据节(比如用于检查 NGINX 错误日志的 error_log )。 它们是测试程序正确性的得力助手。 有时我们还需要测试更加极端的场景,比如服务器启动错误、格式错误的响应、错误请求,还有形形色色的超时错误。

=== 期望服务器启动错误

有些场景下,我们期望服务器在启动时出错而非继续运行,比如使用了错误的配置指令,抑或没能满足初始化阶段的一些硬性要求。 如果我们想要测试这种情况,特别是要检查错误日志中是否有某个特定的错误信息,可以使用 must_die 数据节, 告知测试脚手架我们 期望 NGINX 在这次测试中启动失败。

下面的例子测试在 ngx_http_lua 模块的 init_by_lua_block 上下文抛出 Lua 异常的情况。

[source,test-base]

=== TEST 1: dying in init_by_lua_block — http_config init_by_lua_block { error(“I am dying!”) } — config — must_die — error_log I am dying! —-

init_by_lua_block 中的 Lua 代码会在 NGINX 主进程加载 NGINX 配置文件时运行。 在此抛出 Lua 异常会立刻终止 NGINX 的启动过程。 must_die 告诉测试脚手架,仅当 NGINX 启动失败,测试才算通过。 而 error_log 确保服务器确实是因为抛出了“I am dying!”异常而退出的。

如果我们从上面的测试块移除 --- must_die ,那么该测试甚至不能成功运行:

…. t/a.t .. nginx: [error] init_by_lua error: init_by_lua:2: I am dying! stack traceback: [C]: in function ‘error’ init_by_lua:2: in main chunk Bailout called. Further testing stopped: TEST 1: dying in init_by_lua_block - Cannot start nginx using command “nginx -p …/t/servroot/ -c …/t/servroot/conf/nginx.conf > /dev/null”. ….

默认情况下测试脚手架会把 NGINX 服务器启动失败当作测试中的致命错误。 然而 must_die 的存在,会把它变成一项常规检查。

=== 期望格式错误的响应

HTTP 响应理论上应是格式良好的。不幸的是,梦想很丰满,现实很骨感。 有时候出于某些意外, HTTP 响应会被截断,类似这样导致响应格式错误的情况还有很多。 作为设计错误用例的人,我们总是想要正常地测试像这样的异常情况。

通常来说, Test::Nginx::Socket 默认会检查从测试服务器收到的响应的完整性, 把来自 NGINX 的格式错误的响应当作一种错误。 对于期望是格式错误或者被截断的响应,我们需要通过 ignore_response 告知测试脚手架不去检查响应的格式。

考虑下面这个例子,服务器在发送了响应体的开头部分后立刻关闭了下游的连接。

[source,test-base]

=== TEST 1: aborting response body stream — config location = /t { content_by_lua_block { ngx.print(“hello”) ngx.flush(true) ngx.exit(444) } } — request GET /t — ignore_response — no_error_log [error] —-

content_by_lua_block 语句块中的 ngx.flush(true) 确保 NGINX 中缓冲的所有响应数据确实被刷入系统的发送缓冲区了。 对于运行在本地的客户端而言,这就意味着收到了响应数据。 紧接着, ngx.exit(444) 关闭了当前下游的连接,对于 HTTP 1.1 的分块传输编码(chunked encoding),这将导致响应体被截断。 不要忽略 --- ignore_response 这一行,它告诉测试脚手架不要在意响应的完整性。 如果缺了这一行,我们会在运行 prove 时看到这样的错误:

…. # Failed test ‘TEST 1: aborting response body stream - no last chunk found - 5 # hello # ‘ ….

显然,测试脚手架会抱怨,说缺乏标记分块传输编码数据流结束的“last chunk”。 由于在发送响应体数据的中途断开了连接,服务器没法发送分块传输编码所需的完整响应体。

=== 测试超时错误

超时错误是现实生活中最常见的网络问题之一。 导致超时的原因有很多,比如线路上的丢包,抑或接收端的连接问题,以及耗时操作阻塞了事件循环等等。 大多数应用想要确保超时保护机制能正常工作,避免长时间地等待下去。

在一个自包含的测试框架中,测试和模拟超时异常通常要有特别的技巧。 毕竟,测试时所使用的网络通信仅经过本地的回环网络设备,在延迟和带宽上不会有什么问题。 接下来我们会看到,为了稳定地模拟五花八门的超时错误,测试套件中使用的各种技巧。

==== 连接超时

模拟 TCP 连接中的超时是最简单的。 仅需通过防火墙配置或其他方法,准备一个会丢弃所有接收到的 SYN 包的远程地址,然后跟它建立连接。 我们在 agentzh.org 域名的 12345 端口提供了这样的“黑洞”服务。如果你的测试运行环境允许连外网,那么你就可以用上它。 考虑下下面的测试用例:

[source,test-base]

=== TEST 1: connect timeout — config resolver 8.8.8.8; resolver_timeout 1s;

location = /t {
    content_by_lua_block {
        local sock = ngx.socket.tcp()
        sock:settimeout(100) -- ms
        local ok, err = sock:connect("agentzh.org", 12345)
        if not ok then
            ngx.log(ngx.ERR, "failed to connect: ", err)
            return ngx.exit(500)
        end
        ngx.say("ok")
    }
} --- request GET /t --- response_body_like: 500 Internal Server Error --- error_code: 500 --- error_log failed to connect: timeout ----

这里我们配置了 resolver ,因为需要在请求时通过 Lua 代码解析域名 agentzh.org 。 我们使用 error_log 检查 NGINX 错误日志中是否含有 cosocket 对象的 connect() 方法返回的错误信息。

注意在测试用例中设置的超时限制要相对短一些,这样就不会花过多的时间在等待测试结束。 毕竟测试需要被反复运行。运行测试的次数越多,我们从自动化测试获取的收益就越多。

值得说明的是,测试脚手架的 HTTP 客户端也有一个超时限制,默认 3 秒。 如果你的请求耗时超过 3 秒,你会在测试报告中看到这个错误信息:

…. ERROR: client socket timed out - TEST 1: connect timeout ….

如果注释了示例中的 settimeout ,依赖 cosocket 默认 60 秒的超时限制, 我们就会收到这个信息。

通过设置 timeout 数据节的值,我们可以改变测试脚手架客户端默认的超时限制,像这样:

[source,test-base]

— timeout: 10 —-

现在超时限制是 10 秒而不是之前的 3 秒。

==== 读取超时

模拟读取超时也简单。仅需从一个既不写入数据又不断开连接的对端读取内容。 考虑下面的例子:

[source,test-base]

=== TEST 1: read timeout — main_config stream { server { listen 5678; content_by_lua_block { ngx.sleep(10) – 10 sec } } } — config lua_socket_log_errors off; location = /t { content_by_lua_block { local sock = ngx.socket.tcp() sock:settimeout(100) – ms assert(sock:connect(“127.0.0.1”, 5678)) ngx.say(“connected.”) local data, err = sock:receive() – try to read a line if not data then ngx.say(“failed to receive: “, err) else ngx.say(“received: “, data) end } } — request GET /t — response_body connected. failed to receive: timeout — no_error_log [error] —-

这里我们使用 main_config 定义了一个 TCP 服务器,监听在本地的 5678 端口。 这个服务器建立 TCP 连接后倒头就睡,10 秒后才起来关闭连接。 注意我们在 stream {} 配置块中用的是 link:https://github.com/openresty/stream-lua-nginx-module#readme[ngx_stream_lua] 模块。 location = /t 语句块是这个测试块的核心,它连接了前面定义的服务器并试图从中读一行数据。 显然,100ms 的超时限制会先生效,这下我们可以测试到读取超时的错误处理了。

==== 发送超时

触发发送超时比起连接超时和读取超时要难多了。问题在于写套接字的异步特性。

出于性能考虑,在写的过程中至少有两层缓冲区:

. 在 NGINX 核心的用户态发送缓冲区,和 . 操作系统内核 TCP/IP 栈的套接字发送缓冲区

雪上加霜的是,在写的对端还至少存在一层系统层面上的接收缓冲区。

要想触发一次发送超时,最简单粗暴的方法是塞爆所有发送的缓冲区,且确保对端在应用层面上不做任何读取操作。 所以,仅用少量的测试数据就想在日常环境下重现和模拟发送超时,无异于痴人说梦。

好在,存在一个用户态小技巧可以拦截 libc 包装的套接字 I/O 调用,并基于此实现曾经难于上青天的目标。 我们的 link:https://github.com/openresty/mockeagain[mockeagain] 库实现了这个技巧, 支持在用户指定的输出数据位置触发一次发送超时。

下面的例子恰好在响应体发送了“hello world”之后触发一次发送超时。

[source,test-base]

=== TEST 1: send timeout — config send_timeout 100ms; postpone_output 1;

location = /t {
    content_by_lua_block {
        ngx.say("hi bob!")
        local ok, err = ngx.flush(true)
        if not ok then
            ngx.log(ngx.ERR, "flush #1 failed: ", err)
            return
        end

        ngx.say("hello, world!")
        local ok, err = ngx.flush(true)
        if not ok then
            ngx.log(ngx.ERR, "flush #2 failed: ", err)
            return
        end
    }
} --- request GET /t --- ignore_response --- error_log flush #2 failed: timeout --- no_error_log flush #1 failed ----

注意用于设置 NGINX 下游写操作的超时限制的 send_timeout 指令。 这里我们使用较小的限制,100ms,来确保测试用例尽量快地完成并避免触发测试脚手架客户端默认的 3 秒超时。 postpone_output 1 指令关掉 NGINX 的“postpone output buffer”,让输出数据不会被缓冲起来。 最后,Lua 代码中的 ngx.flush() 确保 没有 一个输出过滤器会截留我们的数据。

在运行这个测试用例前,我们必须在 bash 中设置下面的系统环境变量:

[source,bash]

export LD_PRELOAD=”mockeagain.so” export MOCKEAGAIN=”w” export MOCKEAGAIN_WRITE_TIMEOUT_PATTERN=’hello, world’ export TEST_NGINX_EVENT_TYPE=’poll’ —-

让我们一一审视:

. LD_PRELOAD="mockeagain.so" 预先加载 mockeagain 库到当前进程中,当然也包括了测试脚手架启动的 NGINX 服务进程。 如果 mockeagain.so 不在系统库路径中,你可能需要设置 LD_LIBRARY_PATH 来包含它所在的路径。 . MOCKEAGAIN="w" 允许 mockeagain 库拦截并改写非阻塞套接字上的写操作。 . MOCKEAGAIN_WRITE_TIMEOUT_PATTERN='hello, world'mockeagain 在看到给定的字符串 hello, world 出现在输出数据流之后截止数据的发送。 . TEST_NGINX_EVENT_TYPE='poll' 令 NGINX 服务器使用 poll 事件 API 而不是系统默认的 (比如 Linux 上的 epoll )。因为 mockeagain 暂时只支持 poll 事件。 本质上,这个环境变量只是让测试脚手架生成下面的 nginx.conf 片段。 + [source,nginx] —– events { use poll; } —– + 不过,你需要确保你的 NGINX 或 OpenResty 编译的时候添加了 poll 支持。 总而言之,需要在编译时向 ./configure 指定选项 --with-poll_module

现在你应该能让上面的测试通过了!

如果可以的话,我们应该直接在测试文件里设置这些环境变量。 因为一旦缺了它们,这个测试用例就没法通过了。 我们需要在测试文件序言部分一开头(甚至要在 use 语句之前)就添加下面的 Perl 代码片段:

[source,Perl]

BEGIN { $ENV{LD_PRELOAD} = “mockeagain.so”; $ENV{MOCKEAGAIN} = “w”; $ENV{MOCKEAGAIN_WRITE_TIMEOUT_PATTERN} = ‘hello, world’; $ENV{TEST_NGINX_EVENT_TYPE} = ‘poll’; } —-

这里需要使用 BEGIN {} ,因为它会在 Perl 加载任何模块之前运行。 这样当 Test::Nginx::Socket 加载时,设置的环境变量就能生效。

在测试文件中硬编码 mockeagain.so 的路径是个糟糕的主意,毕竟其他测试环境下的 mockeagain 可能位于不同的文件路径。 最好还是让运行测试的人在外面配置包含它的 LD_LIBRARY_PATH 环境变量。

===== 错误排除

如果你在运行上面的测试用例时遇到如下错误,

…. ERROR: ld.so: object ‘mockeagain.so’ from LD_PRELOAD cannot be preloaded (cannot open shared object file): ignored. ….

那么你需要检查下 mockeagain.so 所在的路径是否位于 LD_LIBRARY_PATH 环境变量中。 举个例子,我在自己的系统上是这么做的

…. export LD_LIBRARY_PATH=$HOME/git/mockeagain:$LD_LIBRARY_PATH ….

如果你看到的是类似于下面的错误信息,

…. nginx: [emerg] invalid event type “poll” in …/t/servroot/conf/nginx.conf:76 ….

意味着你的 NGINX 或 OpenResty 编译的时候没有添加 poll 模块。 你需要重新编译 NGINX 或 OpenResty,并在编译时传递 --with-poll_module 选项给 ./configure

在接下来的 Test Modes 一节,我们还会继续讨论到 mockeagain

=== 模拟后端的异常响应

在前面的“读取超时”小节,我们在例子里使用 link:https://github.com/openresty/stream-lua-nginx-module#readme[ngx_stream_lua] 模块模拟了一个仅接受新连接却从不返回数据的后端 TCP 服务器。 毫无疑问,我们还可以在这个模拟服务器中做更多有趣的东西,比如模拟后端服务器返回各种错误响应数据。

举个例子,如果用真实的服务器测试一个 Memcached 客户端,就很难去模拟错误的抑或格式异常的响应。 而用模拟的服务器则易如反掌:

[source,test-base]

=== TEST 1: get() results in an error response — main_config stream { server { listen 1921; content_by_lua_block { ngx.print(“SERVER_ERROR\r\n”) } } } — config location /t { content_by_lua_block { local memcached = require “resty.memcached” local memc = memcached:new()

        assert(memc:connect("127.0.0.1", 1921))

        local res, flags, err = memc:get("dog")
        if not res then
            ngx.say("failed to get: ", err)
            return
        end

        ngx.say("get: ", res)
        memc:close()
    }
} --- request GET /t --- response_body failed to get: SERVER_ERROR --- no_error_log [error] ----

我们可以随心所欲地仿造 Memcached 服务器的任意响应。太棒了!

NOTE: Test::Nginx::Socket 提供了 tcp_listentcp_querytcp_reply 等 数据节,在测试脚手架层面上支持模拟 TCP 服务器。如果你不想在你的测试代码中使用 ngx_stream_lua 抑或 NGINX 流子系统,可以用它们代替。事实上,在 ngx_stream_lua 诞生之前, 我们一直依赖于 Test::Nginx::Socket 内置的 TCP 服务器来完成相关测试。同样地, Test::Nginx::Socket 通过 udp_listenudp_queryudp_reply 等数据节, 内置了 UDP 服务器支持。你能够在 Test::Nginx::Socket link:https://metacpan.org/pod/Test::Nginx::Socket[ 官方文档]中读到更详细的说明。

=== 模拟异常客户端

Test::Nginx::Socket 测试框架提供了特定的数据节来辅助模拟异常的 HTTP 客户端。

==== 仿造异常请求

raw_request 数据节可以用来指定测试时发送的请求。它通常跟 eval 节过滤器成双成对, 以便于编码像 \r 这样的特殊字符。看看下面的例子。

[source,test-nginx]

=== TEST 1: missing the Host request header — config location = /t { return 200; } — raw_request eval “GET /t HTTP/1.1\r Connection: close\r \r “ — response_body_like: 400 Bad Request — error_code: 400 —-

这里我们简单地构造出一个没有 Host 头部的畸形请求。 不出所料, NGINX 返回了 400 响应。

与之相对的,我们一直以来使用的 request 数据节会确保发送给测试服务器的请求格式是正确的。

==== 模拟客户端连接中断

客户端连接中断在网络世界里是个令人着迷的现象。有些时候我们希望即使客户端断开了连接, 服务器也能够继续当前流程;另外一些时候,我们仅仅立刻结束整个请求的处理。 无论如何,我们都需要能够在单元测试用例中可靠地模拟客户端连接中断的方法。

之前讲过,测试脚手架客户端的默认超时行为,可以通过 timeout 数据节进行调整。 借助它的功能,我们也能让客户端提前断开连接。所需的只是设置过小的超时时间。 为了避免测试脚手架报客户端超时的错误,还要指定 abort 数据节告知测试脚手架这一点。 让我们用一个简单的测试用例把上面的内容串起来。

[source,test-nginx]

=== TEST 1: abort processing in the Lua callback on client aborts — config location = /t { lua_check_client_abort on;

    content_by_lua_block {
        local ok, err = ngx.on_abort(function ()
            ngx.log(ngx.NOTICE, "on abort handler called!")
            ngx.exit(444)
        end)

        if not ok then
            error("cannot set on_abort: " .. err)
        end

        ngx.sleep(0.7)  -- sec
        ngx.log(ngx.NOTICE, "main handler done")
    }
} --- request
GET /t --- timeout: 0.2 --- abort --- ignore_response --- no_error_log [error] main handler done --- error_log client prematurely closed connection on abort handler called! ----

在这个例子里,借助 timeout 数据节,我们让测试脚手架客户端在 0.2 秒后断开连接。 同样,为了避免测试脚手架报客户端超时错误,我们指定了 abort 数据节。 最后,在 Lua 应用代码里,我们启用了 lua_check_client_abort 指令来检查客户端超时, 并通过 ngx.on_abort API 注册了一个回调函数,以 ngx.exit(444) 终止服务端处理流程。

==== 客户端永不关闭连接

不像现实生活中大多数举止得当的 HTTP 客户端,Test::Nginx::Socket 使用的客户端 永不 主动关闭连接,除非发生了超时错误(超过了 --- timeout 数据节指定的时间)。 这确保收到“Connection: close”请求头部后,NGINX 服务器总能够正确地关闭连接。

当连接没有被关闭时,服务器会存在“连接泄漏”的问题。举个例子,NGINX 在它的 HTTP 子系统中 使用引用计数(r->main->count)来判断一个连接能否被关闭和释放。如果引用计数出了差错, NGINX 可能永远不会结束请求,造成资源泄漏。在这种情况下,对应的测试用例会因客户端超时错误而失败。 举个例子,

[source]

# Failed test ‘ERROR: client socket timed out - TEST 1: foo # ‘ —-

就这方面来说,Test::Nginx::Socket 不是个遵纪守法的 HTTP 客户端。 事实上,我们的测试脚手架避免使用一个循规蹈矩的 HTTP 客户端。 大多数测试用例都关注于罕见的错误场景,而一个循规蹈矩的客户端会帮忙掩盖这些问题,而非揭露它们。