1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Minecraft Game Test 使い方メモ

Posted at

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_IDEtrueとなっている場合にGame Testが有効になります。しかしこの変数に直接アクセスしているコードは存在していないようです(リフレクションのアクセスについてはわかりません)。そのためバニラの環境ではGame Testは実行できないものと思われます。Modのフレームワークを問わず,Minecraft Launcherから実行できるMinecraftではGame Testは実行できません。

Forge

Forge環境のセットアップ

  1. 公式の配布サイトから最新のMDKを入手してください.
  2. 解凍して好きな場所に置いてください

ヘッドレスの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の中のsourcesourceSets.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に置かれる必要があります。modsconfigと同じ階層です。

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で実行する

  1. ./gradlew runClientを実行するかIDEからMinecraftを起動してください
  2. 適当にワールドを作成して入ってください。デフォルトの設定のスーパーフラットが見やすいです。
  3. チャット欄から/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に設置
      • runrunClientrunGameTestServerの作業フォルダ
    • 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の仕様)
  • 可視性は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になる)
    • この試行回数を取得する方法は用意されていない
  • batch: String
    • default: "defaultBatch"
    • テストの属するバッチの名前
      • バッチは実行する単位のようなもの
      • 同一のバッチは並列に実行される
  • required: boolean
    • default: true
    • falseだとテストの失敗がskipまたはignoreとして記録される
      • テストに失敗してもCIなどが通るようになる
  • requiredSuccesses: int
    • default: 1
    • attemptsで試行回数をしているしている場合の必要な成功回数
  • rotationSteps: int
    • default: 0
      • 0-3の値をとる
      • それ以外では実行時に落ちる
    • テストの構造体の回転を表すパラメータ
    • 引数と角度の関係はtestコマンドの説明を参照
  • setupTicks: long
    • default: 0L
    • 構造体が設置されてからテストが実行されるまでの間隔
  • template: String
    • default: ""
    • テストの構造体の名前
    • 詳細はここ
  • templateNamespace: String
    • default: ""
    • テストのnamespaceを指定する
    • @GameTestHolderのnamespaceを上書きする場合に使う
    • forge.enabledGameTestNamespacesに入っていないnamespaceを指定するとテストが読み込まれなくなる
  • timeoutTicks: int
    • default: 100
    • テストの実行時間
      • この時間までに終了しないとFailedになる

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>
    • テストの本体

構造体の名前について

構造体の名前は@GameTesttemplateなどやTestFunctionstructureNameで指定できます。

@GameTestでの構造体の名前

構造体の名前を決定するのにかかわる要素は以下のものです。

  • @GameTesttemplate
  • @GameTestHolderの値
    • または@GameTesttemplateNamespace
  • @PrefixGameTestTemplateの値

構造体の名前はResourceLocationの形式です(namespace:path)。

  1. namespaceは@GameTestHolderで指定した名前が入ります。@GameTesttemplateNamespaceがある場合はこちらが優先されます。どちらもない場合は"minecraft"となります。
  2. pathは"<ClassName>.<template>"の名前が入ります。
    • @GameTesttemplateを指定しないか""とした場合は代わりにメソッド名を小文字にしたものが<template>になります。
    • @PrefixGameTestTemplateのアノテーションでfalseを指定した場合はpathが"<template>"のみとなります。
      • このアノテーションはクラスかメソッドにつけられます。クラスにつけた場合はそのクラスのメソッドすべてが対象になります。
      • アノテーションをつけなかった場合はtrueとみなされ"<ClassName>"が付加されます。
      • クラス名に関係ないファイル名が使えるため他クラスとの構造体ファイルの共有が可能になります。
  3. 上のnamespaceとpathからnamespace:pathとして決定されます

method definition structure name
@GameTestHolder("m1")
class C1 { 
  @GameTest() 
  public void testNoArguments(GameTestHelper helper) {}
}
m1:c1.testnoarguments
@GameTestHolder("m1")
class C1 { 
  @GameTest(template="custom") 
  public void testCustomTemplate(GameTestHelper helper) {}
}
m1:c1.custom
@GameTestHolder("m1")
class C1 { 
  @GameTest(template="custom", templateNamespace="mod2") 
  public void testCustomNamespace(GameTestHelper helper) {}
}
mod2:c1.custom
@GameTestHolder("m1")
@PrefixGameTestTemplate(value = false)
class C1 { 
  @GameTest(template="custom") 
  public void testNoPrefixAndCustomName(GameTestHelper helper) {}
}
m1:custom

TestFunctionでの構造体の名前

structureNameとして渡した名前がそのまま使われます。

使用される構造体の決定

まず,MinecraftやModで読み込まれる構造体(StructureTemplate)から該当する構造体があるか検索されます。
この構造体はdata/namespace/structures以下に保存されたnbtファイルです。
Minecraftで登録されているのは村やネザー遺跡といった自然生成される構造物に関する構造体です。

もし該当する構造体がなかった場合はテスト用の構造体が検索されます。
ここでの検索には構造体の名前のPathのみが使用されます。
例えば,m1:c1.customといった構造体ではgameteststructures/c1.custom.snbtのファイルから構造体が読み込まれます。
このgameteststructuresmodsconfigといったディレクトリと同じ階層に置きます。
ファイルが存在しない場合は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にはテストワールドに関する機能とアサーションの関数が含まれています。
ワールドの機能としてはブロックの設置,エンティティのスポーン,ボタンとレバーの操作があります。
またgetLevelServerLevelのインスタンスを取得し,直接操作することも可能です。

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待つといったことができます。

参考

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?