코드 기여의 장애물: `test::nginx`
API7.ai
November 17, 2022
테스트는 소프트웨어 개발의 필수적인 부분입니다. 테스트 주도 개발(Test Driven Development, TDD) 개념은 너무나도 인기가 높아져서 거의 모든 소프트웨어 회사에는 테스트 작업을 담당하는 QA(Quality Assurance) 팀이 있습니다.
테스트는 OpenResty의 품질과 훌륭한 평판의 초석이지만, 동시에 OpenResty의 오픈소스 프로젝트에서 가장 소홀히 여겨지는 부분이기도 합니다. 많은 개발자들이 매일 lua-nginx-module
을 사용하고 가끔 플레임 그래프를 실행하지만, 얼마나 많은 사람들이 테스트 케이스를 실행할까요? 심지어 많은 OpenResty 기반 오픈소스 프로젝트들은 테스트 케이스가 없습니다. 그러나 테스트 케이스와 지속적 통합(Continuous Integration)이 없는 오픈소스 프로젝트는 신뢰할 수 없습니다.
그러나 상업 회사와 달리 대부분의 오픈소스 프로젝트에는 전담 소프트웨어 테스트 엔지니어가 없습니다. 그렇다면 그들은 어떻게 코드의 품질을 보장할까요? 답은 간단합니다: "테스트 자동화"와 "지속적 통합"입니다. 핵심은 자동화와 지속성이며, OpenResty는 이 두 가지를 최대한 달성했습니다.
OpenResty에는 70개의 오픈소스 프로젝트가 있으며, 이들의 단위 테스트, 통합 테스트, 성능 테스트, 모의 테스트, 퍼즈 테스트 등의 작업량은 커뮤니티 기여자들이 순수하게 수동으로 해결하기에는 어려운 문제입니다. 따라서 OpenResty는 초기부터 자동화 테스트에 더 많은 투자를 했습니다. 이는 단기적으로는 프로젝트를 느리게 만들 수 있지만, 장기적으로는 이 분야에 대한 투자가 매우 비용 효율적이라고 할 수 있습니다. 그래서 제가 다른 엔지니어들과 OpenResty의 테스트 논리와 도구 세트에 대해 이야기할 때, 그들은 놀라워합니다.
이제 OpenResty의 테스트 철학에 대해 이야기해 보겠습니다.
개념
test::nginx
는 OpenResty 테스트 아키텍처의 핵심으로, OpenResty 자체와 주변 lua-resty
라이브러리에서 테스트 세트를 조직하고 작성하는 데 사용됩니다. 이는 매우 높은 진입 장벽을 가진 테스트 프레임워크입니다. 그 이유는 일반적인 테스트 프레임워크와 달리 test::nginx
는 어설션(assertion) 기반이 아니며 Lua 언어를 사용하지 않기 때문에 개발자들은 test::nginx
를 처음부터 배우고 사용해야 하며, 기존의 테스트 프레임워크에 대한 지식을 뒤집어야 합니다.
저는 OpenResty에 C와 Lua 코드를 제출할 수 있는 몇몇 기여자들을 알고 있지만, test::nginx
를 사용하여 테스트 케이스를 작성하는 것이 어렵다고 느낀다고 합니다. 그들은 테스트 케이스를 작성하는 방법을 모르거나, 테스트 실패 시 어떻게 수정해야 할지 모르는 경우가 많습니다. 따라서 저는 test::nginx
를 코드 기여의 장애물이라고 부릅니다.
test::nginx
는 Perl, 데이터 주도, 그리고 DSL(Domain-specific language)을 결합합니다. 동일한 테스트 케이스 세트에 대해 매개변수와 환경 변수를 제어함으로써 무작위 실행, 여러 번 반복, 메모리 누수 감지, 스트레스 테스트 등 다양한 효과를 달성할 수 있습니다.
설치 및 예제
test::nginx
를 사용하기 전에, 먼저 설치 방법을 배워봅시다.
OpenResty 시스템에서 소프트웨어 설치에 대해 말하자면, 공식 CI 설치 방법만이 가장 시기적절하고 효과적입니다. 다른 설치 방법은 항상 다양한 문제에 직면합니다. 그래서 저는 공식 방법을 참고하도록 권장하며, 여기서 test::nginx
의 설치와 사용법도 찾을 수 있습니다. 네 단계로 이루어져 있습니다.
- 먼저, Perl의 패키지 관리자
cpanminus
를 설치합니다. - 그런 다음,
cpanm
을 통해test::nginx
를 설치합니다.
sudo cpanm --notest Test::Nginx IPC::Run > build.log 2>&1 || (cat build.log && exit 1)
- 다음으로, 최신 소스 코드를 클론합니다.
git clone https://github.com/openresty/test-nginx.git
- 마지막으로, Perl의
prove
명령을 통해test-nginx
라이브러리를 로드하고/t
디렉토리의 테스트 케이스 세트를 실행합니다.
prove -Itest-nginx/lib -r t
설치가 완료된 후, test::nginx
에서 가장 간단한 테스트 케이스를 살펴보겠습니다. 다음 코드는 공식 문서에서 가져온 것이며, 모든 사용자 정의 제어 매개변수를 제거했습니다.
use Test::Nginx::Socket 'no_plan';
run_tests();
__DATA__
=== TEST 1: set Server
--- config
location /foo {
echo hi;
more_set_headers 'Server: Foo';
}
--- request
GET /foo
--- response_headers
Server: Foo
--- response_body
hi
test::nginx
는 Perl로 작성되었고 모듈 중 하나로 작동하지만, 위의 테스트에서 Perl이나 다른 언어의 어떤 것을 볼 수 있나요? 맞습니다. test::nginx
는 NGINX와 OpenResty를 테스트하기 위해 특별히 추상화된 DSL을 Perl로 구현한 것입니다.
따라서 우리가 처음으로 이런 종류의 테스트를 보면, 대부분 이해하지 못할 것입니다. 하지만 걱정하지 마세요. 위의 테스트 케이스를 분석해 보겠습니다.
먼저, use Test::Nginx::Socket;
는 Perl이 라이브러리를 참조하는 방식으로, Lua의 require
와 같습니다. 이는 또한 test::nginx
가 Perl 프로그램임을 상기시켜 줍니다.
두 번째 줄, run_tests();
는 test::nginx
의 Perl 함수로, 테스트 프레임워크의 진입 함수입니다. test::nginx
에서 다른 Perl 함수를 호출하려면 이 함수 앞에 배치해야 유효합니다.
세 번째 줄의 __DATA__
는 플래그로, 이 아래에 있는 모든 것이 테스트 데이터임을 나타내며, Perl 함수는 이 플래그 전에 완료되어야 합니다.
다음으로 === TEST 1: set Server
는 테스트 케이스의 제목으로, 이 테스트의 목적을 나타내며 내부적으로 자동으로 번호를 매기는 도구가 있습니다.
--- config
는 NGINX 구성 필드입니다. 위의 경우에서 우리는 NGINX 명령을 사용했으며, Lua 코드를 추가하려면 여기에 content_by_lua
와 같은 지시문을 사용할 것입니다.
--- request
는 터미널을 시뮬레이트하여 요청을 보내는 데 사용되며, 뒤에 오는 GET /foo
는 요청의 메서드와 URI를 지정합니다.
--- response_headers
는 응답 헤더를 감지하는 데 사용됩니다. 뒤에 오는 Server: Foo
는 응답 헤더에 반드시 나타나야 하는 header
와 value
를 나타냅니다. 그렇지 않으면 테스트가 실패합니다.
마지막으로 --- response_body
는 응답 본문을 감지하는 데 사용됩니다. 뒤에 오는 hi
는 응답 본문에 반드시 나타나야 하는 문자열입니다. 그렇지 않으면 테스트가 실패합니다.
자, 여기까지 가장 간단한 테스트 케이스 분석이 끝났습니다. 따라서 테스트 케이스를 이해하는 것은 OpenResty 관련 개발 작업을 완료하기 위한 전제 조건입니다.
테스트 케이스 작성하기
다음으로, 실제 테스트에 들어갈 시간입니다. 지난 글에서 Memcached 서버를 어떻게 테스트했는지 기억하시나요? 맞습니다. resty
를 사용하여 수동으로 요청을 보냈습니다. 이는 다음 코드로 표현됩니다.
resty -e 'local memcached = require "resty.memcached"
local memc, err = memcached:new()
memc:set_timeout(1000) -- 1 sec
local ok, err = memc:connect("127.0.0.1", 11212)
local ok, err = memc:set("dog", 32)
if not ok then
ngx.say("failed to set dog: ", err)
return
end
local res, flags, err = memc:get("dog")
ngx.say("dog: ", res)'
하지만 수동으로 보내는 것이 충분히 똑똑하지 않다고 생각하시나요? 걱정하지 마세요. test::nginx
를 배운 후에는 수동 테스트를 자동화된 테스트로 전환해 볼 수 있습니다. 예를 들어:
use Test::Nginx::Socket::Lua::Stream;
run_tests();
__DATA__
=== TEST 1: basic get and set
--- config
location /test {
content_by_lua_block {
local memcached = require "resty.memcached"
local memc, err = memcached:new()
if not memc then
ngx.say("failed to instantiate memc: ", err)
return
end
memc:set_timeout(1000) -- 1 sec
local ok, err = memc:connect("127.0.0.1", 11212)
local ok, err = memc:set("dog", 32)
if not ok then
ngx.say("failed to set dog: ", err)
return
end
local res, flags, err = memc:get("dog")
ngx.say("dog: ", res)
}
}
--- stream_config
lua_shared_dict memcached 100m;
--- stream_server_config
listen 11212;
content_by_lua_block {
local m = require("memcached-server")
m.go()
}
--- request
GET /test
--- response_body
dog: 32
--- no_error_log
[error]
이 테스트 케이스에서 --- stream_config
, --- stream_server_config
, --- no_error_log
를 구성 항목으로 추가했지만, 이들은 본질적으로 동일합니다. 즉,
테스트의 데이터와 테스트를 분리하여 구성의 가독성과 확장성을 더 좋게 만드는 것입니다.
이것이 test::nginx
가 다른 테스트 프레임워크와 근본적으로 다른 점입니다. 이 DSL은 양날의 검과 같아서 테스트 논리를 명확하게 하고 쉽게 확장할 수 있게 하지만, 학습 비용을 높여 새로운 구문과 구성을 다시 배워야 테스트 케이스를 작성할 수 있습니다.
요약
test::nginx
는 강력하지만, 많은 경우 항상 당신의 시나리오에 적합하지 않을 수 있습니다. 왜 나비를 바퀴로 깨뜨려야 할까요? OpenResty에서는 어설션 스타일의 테스트 프레임워크인 busted
를 사용할 수도 있습니다. busted
는 resty
와 결합하여 명령줄 도구가 되며, 많은 테스트 요구를 충족할 수 있습니다.
마지막으로, 한 가지 질문을 남기겠습니다. 이 Memcached
테스트를 로컬에서 실행할 수 있나요? 새로운 테스트 케이스를 추가할 수 있다면 더 좋을 것입니다.