ABEMAの広告システムのバックエンド開発をしている黒崎 (@kuro_m88) です。
GoでLuaのユニットテストの実装を試みた事例をご紹介します。

GoでLuaのユニットテストを書くモチベーション

端的に言うとGoで書いているアプリケーションサーバでValkeyを使っており、Valkeyの機能を拡張するのにLuaで処理を記述する必要が出たためです。

Valkeyとは

Valkeyはインメモリデータベースで、2024年に諸般の事情からRedisをforkする形でLinux Foundation傘下で開発がはじまりました。
ValkeyはRedisからforkされたため大半の機能はRedisと互換性がありますが、AWS, Google Cloud, Oracle Cloudなどの大手企業が開発に参加しており、積極的にサポートする姿勢が見てとれます。
Amazon ElastiCache と Amazon MemoryDB の Valkey サポートを発表

Valkey(Redis) Functions

Valkey FunctionsはValkey内で繰り返し使われるロジックを記述し管理できる仕組みです。複数のValkeyコマンドの実行を一度の呼び出しにまとめることでコマンドのラウンドトリップによるオーバーヘッドを削減したり、コマンドの実行結果によって処理を分岐、ループしたり柔軟な処理ができます。
以下は常に1をレスポンスするFunctionの記述例です。現在のところFunctionを記述するプログラミング言語としてはLuaのみがサポートされています。

#!lua name=mylib
server.register_function(
  'one',
  function(keys, args) return 1 end
)

なぜLuaのテストをGoで書くのか

実は数年前に似たような実装に取り組んだのですが、実のところLuaのテストは書いていませんでした。

KVSあるいはKVSベースのNewSQLに高速なAuto Incrementを実装する


当時はそこまで思考が及んでいなかったのと、かなりシンプルな実装しかなかったため、テストは実際にRedisを利用するだけで十分だったためです。

今回はABEMAの広告システムにおいてValkeyのHashesやSorted Setsを利用して独自のデータ型を構築し、それらをクライアントからは低レベルな構造を意識せずに効率よくデータ操作を行うためにValkey Functionsで抽象化しようとしていました。そのためLuaの処理が複雑になりそうだったためテストを書きやすくする方法を考えました。
大半のサーバサイドのアプリケーションはGoで記述されており、Valkey Functionsの登録や呼び出しはGoで行うことからGoでValkey Functionsを管理し、LuaのテストもGoのユニットテストで同時に実行されるようにするのは理にかなっていると考え、検証しました。

Lua VMのGo実装

簡易にテストを回せるようにGoからLuaが実行でき、Luaの中からGoの関数が呼び出せ、それらをLua moduleとしても登録可能であり、CGOに依存しないことを要件としました。

いくつか既存実装を探しましたが、今回は
https://github.com/yuin/gopher-lua
が要件に合いそうだったため、こちらを採用しました。

Lua moduleをモックする

Valkey Functions内では server.call(command [,arg...]) 等の関数が事前ロードされており、これによりValkeyのコマンドがスクリプト内から呼び出せるようになっています。Lua実装をGoからテストするためにはこの関数をGoでモックしてテストケースによって任意の応答を挿入できるようにする必要があります。
gopher-luaには前述のとおりLuaスクリプト内からGoを呼び出す仕組みがあるため、Lua内からValkeyを呼び出す時の関数を挿入できるようにしました。

package main

import (
	lua "github.com/yuin/gopher-lua"
)

type valkeyModuleMock struct {
	exports map[string]lua.LGFunction
}
type ValkeyCommandCall func(cmd string, args []lua.LValue) ([]lua.LValue, error)

func NewValkeyModuleMock(f ValkeyCommandCall) *valkeyModuleMock {
	exports := map[string]lua.LGFunction{
		"call": call(f),
	}
	return &valkeyModuleMock{
		exports: exports,
	}
}

func (v *valkeyModuleMock) Load(L *lua.LState) error {
	L.PreloadModule("valkey", v.loader)
	return L.DoString("server = require('valkey')")
}

func (v *valkeyModuleMock) loader(L *lua.LState) int {
	mod := L.SetFuncs(L.NewTable(), v.exports)
	L.Push(mod)
	return 1
}

func call(f ValkeyCommandCall) lua.LGFunction {
	return func(L *lua.LState) int {
		cmd := L.Get(1).String()
		args := make([]lua.LValue, 0, L.GetTop()-1)
		for i := 2; i <= L.GetTop(); i++ {
			args = append(args, L.Get(i))
		}

		res, err := f(cmd, args)
		if err != nil {
			L.RaiseError("valkey call error: %v", err)
			return 0
		}

		for _, r := range res {
			L.Push(r)
		}
		return len(res)
	}
}

実際にはserver.pcall等もあるため、似たような要領で必要に応じて他の関数もモックできるようにする必要があります。

Luaのユニットテストを書く

準備ができたので、実際にこれらを利用してユニットテストを書いてみます。
以下がテストコードです。

package main

import (
	"testing"

	"github.com/stretchr/testify/assert"
	lua "github.com/yuin/gopher-lua"
)

func TestValkeyFunction(t *testing.T) {
	L := lua.NewState()
	defer L.Close()

	// GETコマンドの呼び出しをモック
	mockCall := func(cmd string, args []lua.LValue) ([]lua.LValue, error) {
		assert.Equal(t, "GET", cmd)
		assert.ElementsMatch(t, []lua.LValue{lua.LString("test_key")}, args)
		return []lua.LValue{lua.LNumber(123)}, nil // 123を返す
	}

	m := NewValkeyModuleMock(mockCall)
	err := m.Load(L)
	assert.NoError(t, err)

	// Luaスクリプト本体
	err = L.DoString(`
function f()
  return server.call("GET", "test_key")
end
`)
	assert.NoError(t, err)

	// Lua関数を呼び出す
	err = L.CallByParam(
		lua.P{
			Fn:      L.GetGlobal("f"),
			NRet:    1,
			Protect: true,
		},
	)
	assert.NoError(t, err)

	result := L.Get(-1)                          // 関数の戻り値を取得
	assert.Equal(t, lua.LTNumber, result.Type()) // 戻り値は数値
	assert.Equal(t, "123", result.String())      // 戻り値は123
}

本物のValkey serverに一切依存せずValkey Functionsの実装のテストが書けることがおわかりいただけるかと思います。
1つのLuaスクリプト内で複数のValkeyコマンドを呼び出す場合はモック関数内で第一引数(Valkeyのコマンド名が入る)や後続の引数の値を見てモックする内容を分けたり、呼び出し回数をカウントして呼び出される順序によってアサーションを入れることができます。

さいごに

Valkey Functionsの記述が複雑になってもユニットテストを記述することで信頼性を担保する方法をご紹介いたしました。テストコードをテスト対象の実装と異なるプログラミング言語で書くのも悪くないですね。

ちなみに私がRedisを初めて知ったきっかけは13年前、まだ私が大学生だった時に
redis、それは危険なほどのスピード | サイバーエージェント 公式エンジニアブログ
の記事を読んだのがきっかけでした。当時その記事がサイバーエージェントのテックブログだとは認識してませんでしたし、今では筆者のWataruさんと同じチームで働くことになってまた自分がRedis(Valkey)の記事を書いているのは面白いですね。
2025年もValkeyで危険なほどのスピードを実装していこうと思います。

アバター画像
2015年新卒入社 ABEMA / サイバーエージェントCTO統括室所属。バックエンドの実装やインフラ、セキュリティを担当。趣味で中古サーバやネットワーク機器を買ってデータセンタに設置して運用しています。