Game Testとは
Game TestはMinecraftにおけるテスト用フレームワークです。このフレームワークはクライアントとサーバーの両方で動作します。クライアントでの実行では実際にテストが進んでいる様子を見られます。サーバーはGUIなしで動作するためCIの中に組み込んで実行できます。
JavaにはJUnitといった様々なテストフレームワークが存在します。これらのユニットテストのターゲットはMinecraftには狭く,十分なテストをするためには不向きです。ユニットテストでは関数がどのように働くかをテストできます。しかし実際のワールドでの動作を検証するためにはほかの仕組みが必要です。
この文章ではMinecraft Forgeの環境を使用します。バニラではユーザー側でGame Testを実行する手段はありません(バニラを見てください)。FabricはForgeと少し異なる仕組みでGame Testを実行するため,この記事の内容の範囲外です。
環境
Game Testの実行にはこの環境を使用しています。
> ./gradlew --version
------------------------------------------------------------
Gradle 7.5.1
------------------------------------------------------------
Build time: 2022-08-05 21:17:56 UTC
Revision: d1daa0cbf1a0103000b71484e1dbfe096e095918
Kotlin: 1.6.21
Groovy: 3.0.10
Ant: Apache Ant(TM) version 1.10.11 compiled on July 10 2021
JVM: 17.0.4.1 (Eclipse Adoptium 17.0.4.1+1)
OS: Windows 10 10.0 amd64
Game Testを実行する
バニラ
Forgeのパッチを見る限り,SharedConstants.IS_RUNNING_IN_IDE
がtrue
となっている場合にGame Testが有効になります。しかしこの変数に直接アクセスしているコードは存在していないようです(リフレクションのアクセスについてはわかりません)。そのためバニラの環境ではGame Testは実行できないものと思われます。Modのフレームワークを問わず,Minecraft Launcherから実行できるMinecraftではGame Testは実行できません。
Forge
Forge環境のセットアップ
- 公式の配布サイトから最新のMDKを入手してください.
- 解凍して好きな場所に置いてください
ヘッドレスのGame Testの設定を追加する
最近MDKを入手していればこのステップの内容はすでに入っているため,飛ばせます。
また,Clientでのみテストを実行する場合も不要です。Forgeの環境では開発用のClientでテストコマンドが使用できます。
build.gradle
の中の,minecraft.runs
に実行設定が書かれています。
以下の行を追加してGame Test Serverの構成を追加します。
// This code is from official MDK, licensed under the LGPL 2.1.
// This run config launches GameTestServer and runs all registered gametests, then exits.
// The gametest system is also enabled by default for other run configs under the /test command.
gameTestServer {
workingDirectory project.file('run')
property 'forge.logging.markers', 'REGISTRIES'
property 'forge.logging.console.level', 'debug'
property 'forge.enabledGameTestNamespaces', 'YOUR_MOD_ID'
forceExit false
mods {
"YOUR_MOD_ID" {
source sourceSets.main
}
}
}
workingDirectory
を変更すると通常の実行環境とフォルダを分けられます。modsの中のsource
をsourceSets.test
にするとtestフォルダの中のソースコードを読み込んで実行できます。
そうしたら./gradlew runGameTestServer
を実行しましょう. 1つもテストがないのでテストは成功します。
Clientの実行設定の中にもproperty 'forge.enabledGameTestNamespaces', 'YOUR_MOD_ID'
の記述は必要です。
もし/test runall
を実行しても何もテストが行われない場合や,/test run <test name>
で補完が出こてない場合はこの設定が追加されているか確認して下さい。
Game Testを追加する
必要なもの
コード
ここではForgeのMDKをそのまま使用することを想定します。mod idやパッケージはMDKのデフォルトの値を使用しています。例を参考にする際は環境ごとに読み替えてください
ForgeのアノテーションでGame Testを登録する例です。
package com.example.examplemod.gametest;
import com.example.examplemod.ExampleMod;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.world.level.block.Blocks;
import net.minecraftforge.gametest.GameTestHolder;
@GameTestHolder(value = ExampleMod.MODID)
public final class RegisterViaAnnotation {
@GameTest
public void placeDirt(GameTestHelper helper) {
var pos = BlockPos.ZERO.above();
helper.setBlock(pos, Blocks.DIRT);
helper.succeedIf(() -> helper.getBlockState(pos).is(Blocks.DIRT));
}
}
このテストでは基準の位置(テストごとに1つ用意される,ストラクチャーブロックの位置です)の1つ上に土ブロックを置き,土ブロックがその位置に存在するか確かめます。
コードだけではStructureが見つかりませんと言われて実行できません
Structure
上の例ではブロックを手動で設置しました。ブロックを大量に置く複雑なテストでは,ブロックを手動で置いているとミスも起きやすく何より面倒です。そのためMinecraftではテストの構造をsnbt
ファイルから読み込むようになっています。snbt
ファイルは(たとえ手動でブロックを置くとしても)必須です。
snbt
ファイルはセーブデータが保存されているフォルダのgamestructure
に置かれる必要があります。mods
やconfig
と同じ階層です。
snbt
ファイルの生成方法についてはtestコマンドの詳細をみてください。
ストラクチャーブロックから生成されるnbt
ブロックとは違い,snbt
ファイルはJsoncに似た人の読み書きできる形式です。Minecraftの中で実際にブロックを置き,ストラクチャーブロックやtestコマンドを使って出力します。必要となるsnbt
ファイルの名前は<class name>.<method name>.snbt
です。上の例では(class name=RegisterViaAnnotation
, method name=placeDirt
)ファイル名がregisterviaannotation.placedirt.snbt
となります。
サイズが(2, 2, 2)のsnbt
ファイルの例です。
{
DataVersion: 3120,
size: [2, 2, 2],
data: [
{pos: [0, 0, 0], state: "minecraft:polished_andesite"},
{pos: [0, 0, 1], state: "minecraft:polished_andesite"},
{pos: [1, 0, 0], state: "minecraft:polished_andesite"},
{pos: [1, 0, 1], state: "minecraft:polished_andesite"},
{pos: [0, 1, 0], state: "minecraft:air"},
{pos: [0, 1, 1], state: "minecraft:air"},
{pos: [1, 1, 0], state: "minecraft:air"},
{pos: [1, 1, 1], state: "minecraft:air"}
],
entities: [],
palette: [
"minecraft:polished_andesite",
"minecraft:air"
]
}
Run
コードとsnbt
ファイルを用意するとGame TestをClientとServerで実行できます。
Clientで実行する
-
./gradlew runClient
を実行するかIDEからMinecraftを起動してください - 適当にワールドを作成して入ってください。デフォルトの設定のスーパーフラットが見やすいです。
- チャット欄から
/test runall
を実行するとテストされます。結果はチャットやビーコンで知らせてくれます。
Serverで実行する
./gradlew runGameTest
を実行してください。すべてのテストが自動で行われます。
結果はログにこのように表示されます。
========= 2 GAME TESTS COMPLETE ======================
All 2 required tests passed :)
====================================================
gradlew
がデーモンのログを残して失敗するがテストは成功しているように見える場合は,forceExit false
を上の例と同じように追加すると改善されます。
まとめ
必要なもの
-
build.gradle
中の設定- CIで動かすなどヘッドレスでテストしたい場合
- Javaのテストコード
- クラスには
@GameTestHolder(ExampleMod.MODID)
のアノテーション - メソッドには
@GameTest
のアノテーション - アノテーションの詳細は後に記載
- クラスには
-
snbt
ファイル-
run/gameteststructures
に設置-
run
はrunClient
やrunGameTestServer
の作業フォルダ
-
-
snbt
ファイルの作成法はtestコマンドの詳細に記載
-
フォルダ構造
.
├── build.gradle
├── run (runClientやrunGameTestServerのworking directory)
│ └── gameteststructures
│ └── registerviaannotation.placedirt.snbt
└── src (ソースコード Java以下は任意です)
└── main
└── java
└── com
└── example
└── examplemod
└── gametest
└── RegisterViaAnnotation.java
テストの書き方
テストの追加方法
-
@GameTestHolder
のアノテーション- Class
-
RegisterGameTestsEvent
のイベント- Class
- Method
-
GameTestRegistry#register
(deprecated)の呼び出し- Class
- Method
- 上のイベントの呼び出しで代替できる
- Forgeはイベントかアノテーションを推奨している
上の2つの方法で対象となるクラスやメソッドを追加します。そのうえで個々のメソッドにつけられた以下のアノテーションでテストが追加されます。
RegisterGameTestsEvent
でMethodを指定しても以下のアノテーションがついていないと登録されないので注意してください。
-
@GameTest
でメソッドを追加 -
@GameTestGenerator
で動的な追加
@GameTest
での追加
@GameTest
はメソッドにつけるアノテーションです。
- staticでもinstanceメソッドでもOK
- instanceメソッドの場合,インスタンスがテスト実行前に作られる
- フィールドにデータを書き込んでも別のテストからは見えないので注意
- その場合リフレクションで引数なしのコンストラクタが呼び出される
- 明示的にコンストラクタを作成していなければ暗黙的に引数なしのコンストラクタが作られている(Javaの仕様)
- instanceメソッドの場合,インスタンスがテスト実行前に作られる
- 可視性はpublic
- 最終的にリフレクションで呼ばれる
- publicでない場合は
IllegalAccessException
で落ちる
- 引数は
GameTestHelper
を1つ- リフレクションなので過不足ある場合は実行時に落ちる
- 返り値の型は
void
でOK- 返り値は破棄されるので実際はなんでもいい
- 成功時には
GameTestHelper#succeed
を呼ぶ- これがテスト終了の合図になる
- 呼ばないと
timeoutTicks
内に終了しなかったとしてFailedになる
@GameTest
のアノテーションにはテストの実行に関する様々なパラメータを指定できます。詳細はここを見てください。
@GameTestGenerator
での追加
@GameTestGenerator
はメソッドにつけるアノテーションです。
- staticでもinstanceメソッドでもOK
- instanceメソッドの場合,インスタンスがテスト実行前に作られる
- その場合リフレクションで引数なしのコンストラクタが呼び出される
- 可視性はpublic
- 引数はなし
- 返り値の型は
Collection<TestFunction>
-
List
などのCollection
の子クラスならOK
-
TestFunction
ではテストのパラメータを指定し,実行する内容をConsumer<GameTestHelper>
として渡します。渡すパラメータの詳細はここを見てください。
例
@GameTestHolder(value = ExampleMod.MODID)
public class RegisterViaAnnotation {
@GameTest
public void instance_test1(GameTestHelper helper) {
helper.succeed();
}
@GameTest
public static void static_test2(GameTestHelper helper) {
helper.succeed();
}
@GameTestGenerator
public Collection<TestFunction> generator2(){
return List.of(new TestFunction(
"defaultBatch", /* batch name */
"generated_test", /* test name */
"%s:test.structure".formatted(ExampleMod.MODID), /* structure name */
100, /* max ticks */
0L, /* setup ticks */
true, /* required */
g -> {
g.assertBlock(new BlockPos(0, 1, 0), Blocks.AIR::equals, "");
g.succeed();
}
));
}
}
@GameTestHolder
のパラメータ
- value: String
- 対象となるnamespaceを指定する
-
build.gradle
中のproperty 'forge.enabledGameTestNamespaces'
の値と一致させる- それか
forge.enabledGameTestNamespaces
の値を空にする - 全modがテストの対象になる
- それか
- 省略すると
minecraft
が使われる
@GameTest
のパラメータ
- attempts: int
- default:
1
- 試行回数
- この試行回数の中で
requiredSuccesses
の回数以上成功していればテストはPassed - ただし最初の試行で成功しないとCIが失敗する(Exit Codeが1になる)
- この試行回数の中で
- この試行回数を取得する方法は用意されていない
- default:
- batch: String
- default:
"defaultBatch"
- テストの属するバッチの名前
- バッチは実行する単位のようなもの
- 同一のバッチは並列に実行される
- default:
- required: boolean
- default:
true
-
false
だとテストの失敗がskipまたはignoreとして記録される- テストに失敗してもCIなどが通るようになる
- default:
- requiredSuccesses: int
- default:
1
-
attempts
で試行回数をしているしている場合の必要な成功回数
- default:
- rotationSteps: int
- default:
0
- 0-3の値をとる
- それ以外では実行時に落ちる
- テストの構造体の回転を表すパラメータ
- 引数と角度の関係はtestコマンドの説明を参照
- default:
- setupTicks: long
- default:
0L
- 構造体が設置されてからテストが実行されるまでの間隔
- default:
- template: String
- default:
""
- テストの構造体の名前
- 詳細はここ
- default:
- templateNamespace: String
- default:
""
- テストのnamespaceを指定する
-
@GameTestHolder
のnamespaceを上書きする場合に使う -
forge.enabledGameTestNamespaces
に入っていないnamespaceを指定するとテストが読み込まれなくなる
- default:
- timeoutTicks: int
- default:
100
- テストの実行時間
- この時間までに終了しないとFailedになる
- default:
TestFunction
のパラメータ
引数が最も多いコンストラクタの引数の順番通りに記載します。
公式のMappingでは引数の名前は与えられていないので対応するフィールドの名前で紹介しています。
- batch name: String
- テストの属するバッチの名前
- test name: String
- テストの名前
- structure name: String
- テストに使用する構造体の名前
- Stringではあるが,ResouceLocationとして有効な名前である必要がある
- rotation: Rotation
-
net.minecraft.world.level.block.Rotation
のインスタンス - テストの構造体の回転方向
- 引数の少ないコンストラクタでのデフォルトは
Rotation.NONE
-
- max ticks: int
- テストの最大実行時間
- 単位はtick
- テストがこの時間を超えるとFailedになる
- setup ticks: long
- 構造体が設置されてからテストが実行されるまでの時間
- 単位はtick
- required: boolean
- テストが失敗した際にもCIを通すか
- requiredが
true
ならテストが失敗すると全体としても失敗になる -
false
だとテストが失敗しても全体は失敗扱いにならない
- required successes: int
- 複数回実行した際に何回の成功が必要になるかの数
- 次のmax attemptsと組み合わせて使う
- max attempts: int
- テストを実行する回数
- function: Consumer<GameTestHelper>
- テストの本体
構造体の名前について
構造体の名前は@GameTest
のtemplate
などやTestFunction
のstructureName
で指定できます。
@GameTest
での構造体の名前
構造体の名前を決定するのにかかわる要素は以下のものです。
-
@GameTest
のtemplate
-
@GameTestHolder
の値- または
@GameTest
のtemplateNamespace
- または
-
@PrefixGameTestTemplate
の値
構造体の名前はResourceLocationの形式です(namespace:path
)。
- namespaceは
@GameTestHolder
で指定した名前が入ります。@GameTest
のtemplateNamespace
がある場合はこちらが優先されます。どちらもない場合は"minecraft"となります。 - pathは"<ClassName>.<template>"の名前が入ります。
-
@GameTest
でtemplate
を指定しないか""とした場合は代わりにメソッド名を小文字にしたものが<template>になります。 -
@PrefixGameTestTemplate
のアノテーションでfalse
を指定した場合はpathが"<template>"のみとなります。- このアノテーションはクラスかメソッドにつけられます。クラスにつけた場合はそのクラスのメソッドすべてが対象になります。
- アノテーションをつけなかった場合は
true
とみなされ"<ClassName>"が付加されます。 - クラス名に関係ないファイル名が使えるため他クラスとの構造体ファイルの共有が可能になります。
-
- 上のnamespaceとpathから
namespace:path
として決定されます
例
method definition | structure name |
|
m1:c1.testnoarguments |
|
m1:c1.custom |
|
mod2:c1.custom |
|
m1:custom |
TestFunction
での構造体の名前
structureName
として渡した名前がそのまま使われます。
使用される構造体の決定
まず,MinecraftやModで読み込まれる構造体(StructureTemplate)から該当する構造体があるか検索されます。
この構造体はdata/namespace/structures
以下に保存されたnbt
ファイルです。
Minecraftで登録されているのは村やネザー遺跡といった自然生成される構造物に関する構造体です。
もし該当する構造体がなかった場合はテスト用の構造体が検索されます。
ここでの検索には構造体の名前のPathのみが使用されます。
例えば,m1:c1.custom
といった構造体ではgameteststructures/c1.custom.snbt
のファイルから構造体が読み込まれます。
このgameteststructures
はmods
やconfig
といったディレクトリと同じ階層に置きます。
ファイルが存在しない場合はRuntimeException
が投げられます。
バッチ
バッチはテストを実行するかたまりです。
同じバッチに属するテストは並列に実行されます。
天気や時間を設定したうえでテストする場合など,ほかの条件と混ぜてはいけない場合にバッチを分けます。
また,次に説明する前処理や後処理もバッチ単位で設定できます。
テストが登録された後,内部的に100個以下のバッチに再分割されます。
そのときバッチには連番が付与されます。
テストのログにdefaultBatch1
とあればdefaultBatch
の中の1番目ということです。
同一バッチ内にテストが101個以上ある場合はdefaultBatch2
といったように次の番号のバッチが作成されます。
前処理と後処理
バッチごとに1回ずつ前処理と後処理を入れることができます。
前処理には@BeforeBatch(batch = "batchName")
のアノテーションをつけます。@GameTest
と同様staticでもインスタンスメソッドでも使えます。メソッドの引数はServerLevel
が1つです。
同じバッチに対して複数の@BeforeBatch
が存在する場合には登録の時点でエラーになります。
後処理には@AfterBatch(batch = "batchName")
のアノテーションをつけます。
例
@GameTestHolder(value = ExampleMod.MODID)
public final class BeforeAfterBatch {
private static final Logger LOGGER = LogManager.getLogger();
@BeforeBatch(batch = "defaultBatch")
public static void before(ServerLevel level) {
LOGGER.info("BeforeBatch is called with level {}", level.getSeed());
}
@AfterBatch(batch = "defaultBatch")
public static void after(ServerLevel level) {
LOGGER.info("AfterBatch is called with level {}", level.getSeed());
}
}
GameTestHelper
の機能
GameTestHelper
にはテストワールドに関する機能とアサーションの関数が含まれています。
ワールドの機能としてはブロックの設置,エンティティのスポーン,ボタンとレバーの操作があります。
またgetLevel
でServerLevel
のインスタンスを取得し,直接操作することも可能です。
GameTestにおける座標はストラクチャーブロックを(0, 0, 0)とする相対座標(relative position)で表されます。
GameTestHelper
の関数は内部で相対座標から絶対座標に変換されています。
ServerLevel
のインスタンスを直接取得したりBlockEntity
を取得して座標を渡す場合にはこちらで座標の変換処理を忘れずに入れる必要があります。
その変換にはabsolutePos(BlockPos)
とrelativePos(BlockPos)
を使います。
アサーションの関数にはブロックやモブの存在を確認するものやコンテナに関するものがあります。
JUnitによくあるassertTrue
などは存在しないため自分でifの分岐を書く必要があります。
アサーションに失敗した際にはGameTestAssertException
やその子クラスのGameTestAssertPosException
が投げられます。
RuntimeException
の子クラスであればテスト失敗として扱われます。その他(例えばAssertionError
)だとMinecraftのクラッシュとなり,その後のテストが実行されません。
その他の機能については省略します。ソースコードとメソッド名を見れば何ができるかはわかります。
モブの移動など数tickかかる動作を検証したい場合にはGameTestSequence
が使えます。
GameTestHelper#startSequence
で開始できます。
メソッドチェーンでtickごとの処理を書いたり数tick待つといったことができます。
参考
-
https://www.youtube.com/watch?v=vXaWOJTCYNg
- In this video, the concept of Game Test is shown.
-
https://github.com/MinecraftForge/MinecraftForge
- The GitHub code of Minecraft Forge.