Ebitengineでビジュアルリグレッションテストをやる
GoのゲームエンジンであるEbitengine(旧称Ebiten) を使ってゲームを作るにあたり、画面描画周辺の動作についてもいい感じにテストしたかったため検討したメモです✍
なお、私はUbuntu22.04にて開発しているため、WindowsやMacOSなどの他の環境では別のハマりどころがあるかもしれません
あしからず🦵
調べたこと
ビジュアルリグレッションテストとは、コードの変更前の出力結果(WebアプリであればDOM構造、帳票出力であればPDFなど)をリポジトリに保存しておき、変更後の実装から出力したものと比較して差分がないことを確認する自動テストの一種です
テスト実現にあたって、Ebitengineにおいて画像や表示する画面を表現する ebiten.Image
からPNG画像を生成する必要がありますが、
普通に単体テストケースから処理を実行するだけだと、Image内のピクセル列へのアクセスのタイミングで panic: buffered: the command queue is not available yet at ~
のエラーが出てしまいます
解決方法としては、以下Issueで言及されている通り、 TestMain()
において ebiten.RunGame()
を呼び出し、その中で処理をおこなうとよい…ということのようです
また、Ebitengineにはヘッドレスモード的なものが現状未実装のため、上記考慮だけだとGitHub ActionsなどのCI環境上では fatal error: X11/Xcursor/Xcursor.h: No such file or directory ~
のエラーが発生します
こちらについては、CI上でヘッドレスで動作させられる仮想ディスプレイの Xvfb を起動するとよさそうです
Xvfbの使い方としては以下がわかりやすかったです
テストの実装
上記を踏まえた実装イメージを紹介します
まずは、テスト用のGameクラスを用意します
package test
import (
"errors"
"github.com/hajimehoshi/ebiten/v2"
"os"
"testing"
)
var regularTermination = errors.New("regular termination")
type game struct {
m *testing.M
code int
}
func (g *game) Update() error {
g.code = g.m.Run()
return regularTermination
}
func (*game) Draw(*ebiten.Image) {
}
func (g *game) Layout(int, int) (int, int) {
return 1, 1
}
func RunTestGame(m *testing.M) {
ebiten.SetWindowSize(128, 72)
ebiten.SetInitFocused(false)
ebiten.SetWindowTitle("Testing...")
g := &game{
m: m,
}
if err := ebiten.RunGame(g); err != nil && err != regularTermination {
panic(err)
}
os.Exit(g.code)
}
次に、 ebiten.Image
を受け取ってスナップショットのPNGファイルを作成し、差分チェックをおこなうテスト関数を定義します
なお、差分画像の生成については image-diff という今回のユースケースにちょうどよいものを作られている方がいたので、今回はこちらを利用しました
package test
import (
"errors"
"fmt"
"github.com/hajimehoshi/ebiten/v2"
diff "github.com/olegfedoseev/image-diff"
"image"
"image/png"
"log"
"os"
"path"
"runtime"
"strconv"
"strings"
"testing"
)
const (
SnapshotErrorThreshold = 0.0
)
func CheckSnapshot(t *testing.T, actualImage *ebiten.Image) error {
_, callerSourceFileName, _, ok := runtime.Caller(1)
if !ok {
log.Fatalf("failed to read filename: %v", t.Name())
}
basePath := path.Join(path.Dir(callerSourceFileName), "snapshot")
baseFileName := strings.ReplaceAll(t.Name(), "/", "_")
expectedFilePath := path.Join(basePath, fmt.Sprintf("%v.png", baseFileName))
actualFilePath := path.Join(basePath, fmt.Sprintf("%v_actual.png", baseFileName))
diffFilePath := path.Join(basePath, fmt.Sprintf("%v_diff.png", baseFileName))
err := os.MkdirAll(basePath, os.ModePerm)
if err != nil {
log.Fatal(err)
}
var expectedImage image.Image
foundExpectedImage := false
expectedFile, err := os.Open(expectedFilePath)
if err == nil {
expectedImage, _, err = image.Decode(expectedFile)
if err != nil {
log.Fatal(err)
}
foundExpectedImage = true
} else if !errors.Is(err, os.ErrNotExist) {
log.Fatal(err)
}
_ = os.Remove(diffFilePath)
_ = os.Remove(actualFilePath)
updateSnapshot, _ := strconv.ParseBool(os.Getenv("UPDATE_SNAPSHOT"))
if foundExpectedImage && !updateSnapshot {
diffImage, percent, err := diff.CompareImages(actualImage, expectedImage)
if err != nil {
log.Fatal(err)
}
if percent > SnapshotErrorThreshold {
f, _ := os.Create(diffFilePath)
defer func(f *os.File) {
err := f.Close()
if err != nil {
log.Fatal(err)
}
}(f)
err = png.Encode(f, diffImage)
if err != nil {
log.Fatal(err)
}
f, _ = os.Create(actualFilePath)
defer func(f *os.File) {
err := f.Close()
if err != nil {
log.Fatal(err)
}
}(f)
err = png.Encode(f, actualImage)
if err != nil {
log.Fatal(err)
}
return fmt.Errorf(
"snapshot test failed: diff = %v > %v, file = %v",
percent,
SnapshotErrorThreshold,
diffFilePath)
}
}
f, _ := os.Create(expectedFilePath)
defer func(f *os.File) {
err := f.Close()
if err != nil {
log.Fatal(err)
}
}(f)
err = png.Encode(f, actualImage)
if err != nil {
log.Fatal(err)
}
return nil
}
テスト関数は以下のようなイメージです
ポイントとして、RunGameを実行するテストでは現状ディスプレイが一瞬表示されるのが避けられず、またテストの動作自体も環境によって不安定な可能性があるので、 termtests
タグをつけて任意に実行できるようにしています
実行したいときは以下にようにするとよいです
go test -tags=termtests -v ./...
//go:build termtests
// +build termtests
package test_test
import (
"fmt"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
"image/color"
"log"
"testing"
// 自分のパッケージにあわせて変更
"github.com/org/repo/test"
)
func TestExample_PrintMessage(t *testing.T) {
const (
Width = 128
Height = 72
)
tests := []struct {
text string
}{
{text: ""},
{text: "TestABC"},
}
for i, tt := range tests {
t.Run(fmt.Sprintf("text_%v", i), func(t *testing.T) {
// テスト画像作成
image := ebiten.NewImage(Width, Height)
image.Fill(color.Black)
// テスト対象処理の呼び出し
PrintMessage(image, tt.text)
// 結果チェック
err := test.CheckSnapshot(t, image)
if err != nil {
t.Error(err)
}
})
}
}
func TestMain(m *testing.M) {
test.RunTestGame(m)
}
func PrintMessage(image *ebiten.Image, str string) {
ebitenutil.DebugPrint(image, str)
}
上記テストを実行すると、テストコードと同一改装に snapshot/
というディレクトリが作成され、テスト実行後の ebiten.Image
が出力された画像ファイルが格納されます
$ ls -l snapshot/
合計 24
-rw-rw-r-- 1 tkhs tkhs 229 10月 3 15:25 TestExample_PrintMessage_text_0.png
-rw-rw-r-- 1 tkhs tkhs 330 10月 3 15:25 TestExample_PrintMessage_text_1.png
内容はこんな感じ
TestExample_PrintMessage_text_1.png
画像の生成後、関数の内容を修正し再度テスト実行すると、今度は期待する画像と実際の画像の内容が変わるためエラーとなります
例えば関数を以下のように変更したとします
func PrintMessage(image *ebiten.Image, str string) {
ebitenutil.DebugPrint(image, "Hello, "+str)
}
テストは失敗します
=== RUN TestExample_PrintMessage/text_0
example_test.go:40: snapshot test failed: diff = 0.8572048611111112 > 0, file = /your-path/test/snapshot/TestExample_PrintMessage_text_0_diff.png
=== RUN TestExample_PrintMessage/text_1
example_test.go:40: snapshot test failed: diff = 2.528211805555556 > 0, file = /your-path/test/snapshot/TestExample_PrintMessage_text_1_diff.png
--- FAIL: TestExample_PrintMessage (0.02s)
--- FAIL: TestExample_PrintMessage/text_0 (0.01s)
--- FAIL: TestExample_PrintMessage/text_1 (0.01s)
これにより、 snapshot/
ディレクトリ配下に新しく *.actual.png
と *.diff.png
という画像が出力されます
内容はそれぞれ以下のような感じです
TestExample_PrintMessage_text_1_actual.png
TestExample_PrintMessage_text_1_diff.png
これらの画像は目視での確認用でリポジトリにコミットするものではないので、ignoreしておくとよいでしょう
**/snapshot/*_actual.png
**/snapshot/*_diff.png
内容が期待する差分(今回であれば Hello,
と文字の先頭に追加する対応をしたので問題なさそう)であることが確認できたら、
UPDATE_SNAPSHOT=1 go test ~
など、環境変数 UPDATE_SNAPSHOT
にTruthyな値を設定して再度テスト実行します
期待していた画像が更新されるので、リポジトリにコミットしてテスト完了です
今後は、関係ない箇所を修正していて挙動を変えてしまった時にエラーになるので、リファクタリング等もしやすくなることでしょう🍮
GitHub Actions上での実行
GitHub Actionsでテストを動かす際には、以下で紹介されている方法で実現できました
以下のようなイメージになるものと思います
name: Check
on: push
jobs:
test:
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
go-version: 1.19
- run: |
# https://ebitengine.org/ja/documents/install.html#Debian_/_Ubuntu
sudo apt install -y libc6-dev libglu1-mesa-dev libgl1-mesa-dev libxcursor-dev libxi-dev libxinerama-dev libxrandr-dev libxxf86vm-dev libasound2-dev pkg-config
- run: |
# https://stackoverflow.com/questions/63125480/running-a-gui-application-on-a-ci-service-without-x11
export DISPLAY=:99
sudo Xvfb -ac :99 -screen 0 1280x1024x24 > /dev/null 2>&1 &
go test -tags=termtests -v ./...
やってみてわかったこととしては、
特にゲームの一部品みたいなものだと修正→起動→目視確認…とやるのは結構大変ですし、既存動作を破壊した時に気付きやすくなるので安心できるなと思いました
また、これはテスト駆動開発のメリットですが、実装いじる→テスト壊れる→なおす…というサイクル自体がそもそもゲームっぽくて楽しいので、
もはやゲームが完成しなくても別にいいやと感じられるという点でもおすすめです😊
Discussion