🍤

Ebitengineでビジュアルリグレッションテストをやる

2022/10/03に公開約8,700字

GoのゲームエンジンであるEbitengine(旧称Ebiten) を使ってゲームを作るにあたり、画面描画周辺の動作についてもいい感じにテストしたかったため検討したメモです✍

https://ebitengine.org/ja/

なお、私はUbuntu22.04にて開発しているため、WindowsやMacOSなどの他の環境では別のハマりどころがあるかもしれません
あしからず🦵

調べたこと

ビジュアルリグレッションテストとは、コードの変更前の出力結果(WebアプリであればDOM構造、帳票出力であればPDFなど)をリポジトリに保存しておき、変更後の実装から出力したものと比較して差分がないことを確認する自動テストの一種です

https://zenn.dev/roki_na/articles/6e17079d91f82f

テスト実現にあたって、Ebitengineにおいて画像や表示する画面を表現する ebiten.Image からPNG画像を生成する必要がありますが、
普通に単体テストケースから処理を実行するだけだと、Image内のピクセル列へのアクセスのタイミングで panic: buffered: the command queue is not available yet at ~ のエラーが出てしまいます

解決方法としては、以下Issueで言及されている通り、 TestMain() において ebiten.RunGame() を呼び出し、その中で処理をおこなうとよい…ということのようです

https://github.com/hajimehoshi/ebiten/issues/1264

また、Ebitengineにはヘッドレスモード的なものが現状未実装のため、上記考慮だけだとGitHub ActionsなどのCI環境上では fatal error: X11/Xcursor/Xcursor.h: No such file or directory ~ のエラーが発生します

https://github.com/hajimehoshi/ebiten/issues/353

こちらについては、CI上でヘッドレスで動作させられる仮想ディスプレイの Xvfb を起動するとよさそうです
Xvfbの使い方としては以下がわかりやすかったです

https://blog.amedama.jp/entry/2016/01/03/115602

テストの実装

上記を踏まえた実装イメージを紹介します

まずは、テスト用のGameクラスを用意します

test/game.go
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 という今回のユースケースにちょうどよいものを作られている方がいたので、今回はこちらを利用しました

https://github.com/olegfedoseev/image-diff

test/snapshot.go
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 タグをつけて任意に実行できるようにしています

https://devlights.hatenablog.com/entry/2020/09/24/011502

実行したいときは以下にようにするとよいです

go test -tags=termtests -v ./...
example_test.go
//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しておくとよいでしょう

.gitignore
**/snapshot/*_actual.png
**/snapshot/*_diff.png

内容が期待する差分(今回であれば Hello, と文字の先頭に追加する対応をしたので問題なさそう)であることが確認できたら、
UPDATE_SNAPSHOT=1 go test ~ など、環境変数 UPDATE_SNAPSHOT にTruthyな値を設定して再度テスト実行します
期待していた画像が更新されるので、リポジトリにコミットしてテスト完了です

今後は、関係ない箇所を修正していて挙動を変えてしまった時にエラーになるので、リファクタリング等もしやすくなることでしょう🍮

GitHub Actions上での実行

GitHub Actionsでテストを動かす際には、以下で紹介されている方法で実現できました

https://stackoverflow.com/questions/63125480/running-a-gui-application-on-a-ci-service-without-x11

以下のようなイメージになるものと思います

check.yml
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

ログインするとコメントできます