코드 기여의 장애물: `test::nginx`

API7.ai

November 17, 2022

OpenResty (NGINX + Lua)

테스트는 소프트웨어 개발의 필수적인 부분입니다. 테스트 주도 개발(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의 설치와 사용법도 찾을 수 있습니다. 네 단계로 이루어져 있습니다.

  1. 먼저, Perl의 패키지 관리자 cpanminus를 설치합니다.
  2. 그런 다음, cpanm을 통해 test::nginx를 설치합니다.
sudo cpanm --notest Test::Nginx IPC::Run > build.log 2>&1 || (cat build.log && exit 1)
  1. 다음으로, 최신 소스 코드를 클론합니다.
git clone https://github.com/openresty/test-nginx.git
  1. 마지막으로, 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는 응답 헤더에 반드시 나타나야 하는 headervalue를 나타냅니다. 그렇지 않으면 테스트가 실패합니다.

마지막으로 --- 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를 사용할 수도 있습니다. bustedresty와 결합하여 명령줄 도구가 되며, 많은 테스트 요구를 충족할 수 있습니다.

마지막으로, 한 가지 질문을 남기겠습니다. 이 Memcached 테스트를 로컬에서 실행할 수 있나요? 새로운 테스트 케이스를 추가할 수 있다면 더 좋을 것입니다.