このエントリーは、KLab Advent Calendar 2015 の12/4の記事です。

KLabとしては久々のAdvent Calendar参戦です。4番手も緊張しますね。@hasi_tです。よろしくお願いします。

はじめに

今年は、PlaygroundとLuaによる大規模モバイルオンラインゲーム開発のレベルアップという題でCEDEC2015で発表することができました。
スライド原稿を公開したので、よければご覧ください。

この記事では、「Modelとサーバを統合したテスト」を支えるLunatic Pythonを紹介しようと思います。

使用した言語のバージョンは、Python 3.4とLua 5.2です。

Lunatic Pythonとは

Lunatic PythonはPythonとLuaの双方向ブリッジで、PythonからLuaを呼び出して、さらにその中からPythonを呼び出し、さらにその中からLuaを呼び出し、…することができます。

https://github.com/bastibe/lunatic-python にあるものを用います。
"Sadly, Lunatic Python is very much outdated and won't work with either a current Python or Lua." とあるように、
オリジナルのほうは古くなっているので注意が必要です。

クライアント・サーバ統合テスト

Playgroundゲームエンジンでは、ゲームを記述するプログラミング言語としてLuaを用います。
そして、ここではPythonで書かれたHTTPサーバと通信するゲームを考えます。

サーバに閉じたテストは比較的自動化しやすいのですが、クライアント・サーバを統合したテストは、サーバを立てて実際にクライアントアプリを実行してテストするのが従来の方法でした。
従来の方法だと自動化が難しいのが問題です。

そこでまず、クライアントサイドのLuaコードを、Playgroundゲームエンジンに依存する部分と依存しない部分に分けます。
更に、Playgroundゲームエンジンに依存しない部分のうち、通信に関わる部分をクライアント・サーバ統合テストの対象とします。(下図)

クライアント・サーバ統合テストの対象

environmentを使おう

Lunatic Pythonが提供する機能はとても単純なので、ラッパーオブジェクトを作って書きやすくしようと思います。
Lua5.2で導入されたenvironmentを使って、以下のようにLuaObjectというクラスを定義します。

class LuaObject:
    def __init__(self):
        self.env = lua.eval('{}')

    def run_lua_code(self, ld, src='_', **kw):
        return lua.eval('''
            load(python.locals().ld, python.locals().src, 't', setmetatable({}, {
                __index = function(_, key)
                    local w = python.locals().kw[key]
                    if w ~= nil then return w end
                    local v = python.locals().self.env[key]
                    if v ~= nil then return v end
                    return _G[key]
                end,
                __newindex = function(_, key, value)
                    if python.locals().kw[key] ~= nil then error('kw is read only') end
                    python.locals().self.env[key] = value
                end,
            }))()
        ''')

例えば、下のコードを実行すると4が出力されます。

L = LuaObject()
L.run_lua_code('x = 2 + 2')
print(L.run_lua_code('return x'))  # 4が出力される

environmentはインスタンスごとになっているので、グローバル変数への書き込みはそのインスタンスだけに影響します。

L1 = LuaObject()
L2 = LuaObject()
L1.run_lua_code('x = 1')
L2.run_lua_code('x = 2')
print(L1.run_lua_code('return x'))  # 1が出力される
print(L2.run_lua_code('return x'))  # 2が出力される

当然、普通にluaのライブラリ関数を使うこともできます。

print(L1.run_lua_code('return string.rep("hoge", 2)'))  # hogehogeが出力される

また、キーワード引数を使って簡単に値を渡すことができるようにしています。

L = LuaObject()
print(L.run_lua_code('return y', y=10))  # 10が出力される
print(L.run_lua_code('return y'))  # Noneが出力される

こうすることで、複数のクライアントを並行して動作させるテストも簡単に書けるようになりました。

サーバと通信しよう

次に、Luaからサーバと通信する部分を作ります。
といっても、Pythonの中でLuaスクリプトが動いているので、実際に通信することはなく通信に関するテストを実現できます。

例として、以下のようなJSONでデータをやりとりするFlaskを使ったAPIサーバを考えます。

def get_app():
    app = flask.Flask(__name__)

    @app.route('/incr', methods=['POST'])
    def incr():
        request_data = json.loads(flask.request.data.decode('utf-8'))
        return flask.jsonify(n=request_data['n'] + 1)

    return app

pytestとWebTestを使って、以下のようにテストを書くことができますが、これはサーバに閉じたテストです。

def test_api():
    app = get_app()
    test_app = webtest.TestApp(app)
    response = test_app.post(
        url='/incr',
        params='{"n":1}',
        headers=[('Content-Type', 'application/json')],
    )
    assert response.json['n'] == 2

ここで、以下のようにLuaClientというクラスを定義します。

class LuaClient(LuaObject):
    def __init__(self, test_app):
        super().__init__()
        self.test_app = test_app
        self.run_lua_code('''
            function json_encode(data)
                if type(data) == 'table' then
                    local a = {}
                    local r = {}
                    for i, v in ipairs(data) do
                        table.insert(a, json_encode(v))
                    end
                    for k, v in pairs(data) do
                        table.insert(r, json_encode(tostring(k))..':'..json_encode(v))
                    end
                    if #a == #r then
                        return '['..table.concat(a, ',')..']'
                    else
                        return '{'..table.concat(r, ',')..'}'
                    end
                elseif type(data) == 'string' then
                    return python.import('json').dumps(data, false, false)
                else
                    return tostring(data)
                end
            end

            function call_api(path, data, callback)
                local res = python.locals().self.call_api(path, json_encode(data))
                if res then
                    callback(res)
                end
            end
        ''')

    def call_api(self, path, data):
        response = self.test_app.post(
            url=path,
            params=data,
            headers=[('Content-Type', 'application/json')],
        )

        def json_to_lua(x):
            if isinstance(x, dict):
                res = lua.eval('{}')
                for k, v in x.items():
                    res[k] = json_to_lua(v)
                return res
            elif isinstance(x, list):
                res = lua.eval('{}')
                for i, v in enumerate(x, start=1):
                    res[i] = json_to_lua(v)
                return res
            else:
                return x

        return json_to_lua(response.json)

LuaClientを使うことで、Luaからサーバを呼び出すテストを以下のように書くことができます。

def test_incr():
    app = get_app()
    test_app = webtest.TestApp(app)
    client = LuaClient(test_app)
    client.run_lua_code('''
        call_api('/incr', {n = 1}, function(data)
            response = data
        end)
    ''')
    assert client.run_lua_code('return response.n') == 2

ここでは簡単のためにプレイヤー作成や認証に関する処理を省いていますが、
プレイヤーを作成しデータベースを書き換えてログインさせるといったテストも簡単に書くことができます。

データベース書き換えは、クライアントテストを拡張してクライアント・サーバ統合テストを実現しようとしたときに面倒だったところで、
これはサーバテストを拡張したクライアント・サーバ統合テストの長所といえます。

まとめ

  • Lunatic PythonまじLunatic
  • Luaのenvironmentは便利
  • サーバテストにクライアントを組み込むほうが捗る
  • クライアント・サーバ統合テストを書こう

最後に

明日はmecha_g3氏が何か書くそうです。お楽しみに。