Godot Unit Test (GUT) の知見をおすそ分けします
Godot Unit Test (GUT) は、Godot でユニットテストを行うためのアセットです。この記事を書いた現在のバージョンは 9.3.0 です。Godot は 4.3 です。知見が溜まってきたので、不完全ですが現状をおすそ分けします。
AssetLib から GUT (bitwes氏による) を検索して、インストールします。実際には、ダウンロード内容と場所を確認、ダウンロードして、その後、Godot エディターのメインメニューから、プロジェクト→プロジェクト設定→プラグイン を選択し、Gut の有効をオンにすれば完了です。アンインストールは、これをはずした後に addons フォルダ内の gut フォルダを丸ごと削除すれば良いようです。
その後は、res://test/unit のようにフォルダを作り、そこに新規作成したテスト用スクリプトを置きます。テスト用スクリプトには、test_ で始まるテスト用 func を置きます。具体的には、以下のようになります。(今回は、テストコード test_cases.gd とテスト対象コード main.gd の2つ)
class_name TestCases
extends GutTest
# double 用
var MainScript = null
var main_script = null
# partial_double 用
var MainScene = null
var main_scene = null
func before_all():
MainScript = load("res://main.gd") # script の身代わり(double)
MainScene = load("res://main.tscn") # scene の身代わり(partial_double)
func before_each():
main_script = autofree(MainScript.new()) # double 生成(自動解放付き)
main_scene = partial_double(MainScene).instantiate() # partial_double 生成
func test_hello():
# double
var result: int = main_script.hello()
assert_eq(result, 200)
# partial_double
result = main_scene.hello()
assert_eq(result, 200)
# partial_double では stub が使える
stub(main_scene, "plus_100").to_return(500)
result = main_scene.hello()
assert_eq(result, 500)
# 引数群を指定することで、複数のパターンを試すテスト
var parameterized_test_params = [
[100+100, "100+100 == 200, success"],
[400/2, "400/2 == 200, success"],
]
func test_parameterized_test(params=use_parameters(parameterized_test_params)):
var result = main_scene.hello()
assert_eq(result, params[0], params[1])
func after_all():
queue_free() # 自身を解放して終える
class_name Main
extends Node
func hello() -> int:
return plus_100(100)
func plus_100(value: int) -> int:
return value + 100
ウィンドウ下部の GUT を開き、Test Directories の Include Subdirs にチェックを入れて、0 を有効にして、そこに res://test/unit を入力します。そして、Exit on Finish にチェックを入れます。最後に、Run All ボタン押下で、全テストが実行できます。Current: で表示されているスクリプト名を押せば、個別にテストを実行することもできます。
以上です。ご活用ください!
※公式ドキュメントはこちら
Gut Docs https://gut.readthedocs.io/
2024/09/15a 追記: double 生成が上手くいっていないのが課題ですが、それでも stub が使えない点を除けば、テスト対象がスクリプトだけの場合でも、おおむね予想通りの動きをします。
2024/09/15b 追記: signal のテストも書いて、新作の実動サンプルコードを用意しましたのでどうぞ。(使用に関して私は一切の責任を持ちません。また、GUT は The MIT License (MIT) Copyright (c) 2018 Tom "Butch" Wesley です)
2024/09/18a 追記: partial_double でも、テスト対象内では、子 Node の要素には $NodeName.field といった感じに、直にアクセスしないと上手くいかないみたいです。テストだと _ready() が呼ばれておらず、@onready な行が期待通りに動いていないようです。$NodeName.field だと上手くいきました。
@onready var node_name: NodeName = $NodeName
# テスト対象
func hello():
node_name.field = "hello" # NG # node_name が null なので NG
$NodeName.field = "hello" # OK
2024/09/18b 追記: 上記を考慮した、サンプルの最新版(sandboxgut_20240918b.zip)を公開します。それと上方の前バージョンに問題があったので、ほぼ同じ内容で差し替えました(→sandboxgut_20240915c.zip)、すみません。(どちらのバージョンも、使用に関して私は一切の責任を持ちません。また、GUT は The MIT License (MIT) Copyright (c) 2018 Tom "Butch" Wesley です)
2024/09/25b 追記: GUT では _ready() を呼んでくれないので、_ready() の処理を reset() に切り出して、before_each() で呼ばせるんですが、このとき _ready() 内(reset()内)で Control.grab_focus() するとエラーが出ます。この代替案で、grab_focus.call_deferrred() として呼ぶ必要があります。情報源はこちら ↓
# 例
func _ready() -> void:
reset()
# テストでも呼ばれることを想定した、初期化メソッド
func reset() -> void:
print("in ready.")
$ServerButton.grab_focus.call_deferred() # grab_focus を _ready() で呼ぶときは、call_deferred() を付ける
2024/09/25c 追記: $NodeName を直接指定しないために、@onready を使用せずに、reset() 内で代入しておくという代替案があります。どちらが良いかは、まだ GDScript の経験が浅いため判断がつきません。
#...
var user_line_edit: LineEdit
var password_line_edit: LineEdit
var error_label: Label
var login_button: Button
#...
func _ready() -> void:
reset()
func reset() -> void:
user_line_edit = $UserLineEdit
password_line_edit = $PasswordLineEdit
error_label = $ErrorLabel
login_button = $LoginButton
#...
2024/09/28d 追記: Double と PartialDouble は stub のためにあるのだと分かってきた。Double 対象がローカル変数すら正しく返さないのは、stub のためだけにあるからみたい。だから stub 使わないなら、通常の new() や instantiate() で良い (autofree()を使う)。あと、add_child() (&remove_child()) と (InputSender の) idle シグナルを使うと _ready() や @onready の行を実行してもらえるみたい (add_child_autofree() というのもある)。まだはっきり分かっていないけれど、そのうち記事出せそうなら出します。
あと、バージョンが古いですが、作者さんが YouTube に動画も出しています。分かりづらいですが、ドキュメントにリンクがありました。↓
2024/09/29b タイトル&本文変更 お裾分け→おすそ分け
2024/09/30a 追記: まだ新記事を出せなさそうなので、とりあえず新しいサンプルコードを作成しました。GitHub にあります。今回から、GUT はご自身で用意していただくようにしました。よろしくお願いします。
2024/10/01a 追記: 自動入力を使用したインテグレーションテストのサンプルコードを作成しました。GitHub に、更新した新しいバージョンが置いてあります。clone 等してください。よろしくお願いします。
2024/10/02a 修正: Godot Unit Testing → Godot Unit Test
2024/10/02b 追記: 自動マウス操作でボタンを押すインテグレーションテストのサンプルコードを作成しました。GitHub に置いてあるものを更新です。clone や Zip ダウンロード等してください。よろしくお願いします。
2024/10/03b 追記: 操作可能なキャラクターと地面にキー押下入力を与えた、インテグレーションテストのサンプルコードを作成しました。GitHub に更新して置いてあります。これで、簡単なテストなら大体は自動テストできるかなあと思います。ここまで、ありがとうございました。
2024/10/03c 追記: 今回のコードはこんな感じです。
2024/10/04d add_child() に関して等、即席でコメント増強しました。
# MyCharacter と MyGround と キー入力 の、インテグレーションテスト(統合テスト)
extends GutTest
# テスト対象をそれぞれ設置した、テスト用シーン
const TEST_SCENE_PATH = "res://test/resources/test_my_character_moving_platform.tscn"
var TargetSceneMyCharacterMovingPlatform
var target_scene_my_character_moving_platform
var _sender = InputSender.new(Input) # 自動入力を使う
func before_all():
TargetSceneMyCharacterMovingPlatform = load(TEST_SCENE_PATH)
func before_each():
# partial_double は自動解放される
target_scene_my_character_moving_platform = partial_double(TargetSceneMyCharacterMovingPlatform).instantiate()
# add_child() すれば、_ready() や @onready 行が呼ばれる
add_child(target_scene_my_character_moving_platform)
# リセット処理
Global.cold_reset()
Global.warm_reset()
func after_each():
# 自動入力イベントを全リセット
_sender.release_all()
_sender.clear()
# add_child と対
remove_child(target_scene_my_character_moving_platform)
func after_all():
queue_free() # 自身を解放して終える (これで Orpahns 1 が無くなる)
#---
func test_my_character_moving():
# 少し浮いた状態で、MyCharacter がスポーンする
var pos_x = target_scene_my_character_moving_platform.get_node('MyCharacter').position.x as int
var pos_y = target_scene_my_character_moving_platform.get_node('MyCharacter').position.y as int
_sender.wait_frames(3)
await _sender.idle # 入力イベントの消化を待つ
_sender.release_all()
# 検査: 浮いた状態から始まり、少しの間、落ちる
assert_gt(target_scene_my_character_moving_platform.get_node('MyCharacter').velocity.y, 0.0)
_sender.wait_secs(3)
await _sender.idle # 入力イベントの消化を待つ
_sender.release_all()
# 検査: すでに着地しており、地面から落ちていない
assert_eq(target_scene_my_character_moving_platform.get_node('MyCharacter').velocity.y, 0.0)
# 3秒 右キー押下し続ける
_sender.action_down('ui_right').hold_for(3)
await _sender.idle # 入力イベントの消化を待つ
_sender.release_all()
# 検査: 地面から落ちて落下している
assert_gt(target_scene_my_character_moving_platform.get_node('MyCharacter').position.x as int, pos_x)
assert_gt(target_scene_my_character_moving_platform.get_node('MyCharacter').position.y as int, pos_y)
assert_gt(target_scene_my_character_moving_platform.get_node('MyCharacter').velocity.y, 0.0)
await wait_seconds(1) # 結果が見えるように少し待機