Godot Unit Test (GUT) と LLM でフレーキーテストを自動分析する仕組みを構築してみた

Godot Unit Test (GUT) と LLM でフレーキーテストを自動分析する仕組みを構築してみた

Godot のテストフレームワーク Godot Unit Test (GUT) と LLM を組み合わせ、フレーキーテストの結果を分析するシステムを構築しました。意図的に不安定な要素を含む戦闘システムを用意し、テスト実行時の HP 同期問題やアニメーション競合などの問題パターンを Claude に送信することで、根本原因の特定と具体的な修正方法の提案を自動化しました。Amazon Bedrock を使用したエンドポイント構築から、GDScript でのテスト実装まで、実際の動作例とともに解説します。

はじめに

本記事では、Godot ゲームエンジンの単体テストフレームワーク GUT (Godot Unit Test) と大規模言語モデル (LLM) を組み合わせて、フレーキーテストの結果を分析するシステムを構築します。フレーキーテストとは、同じコードに対して実行するたびに成功したり失敗したりする不安定なテストのことです。従来、このようなテストの原因を特定するには開発者の経験と勘に依存していました。LLM を活用することで、テスト結果のパターンから根本原因を推定し、具体的な修正方法を提案できるようになります。

動作イメージ

Godot とは

Godot は、オープンソースのクロスプラットフォーム対応ゲームエンジンです。 Unity や Unreal Engine と並ぶ主要なゲーム開発ツールのひとつで、 GDScript という Python ライクな独自のスクリプト言語を採用しています。エンジン本体のサイズが軽量で、起動が高速という特徴があります。

Godot Unit Test (GUT) とは

GUT は Godot 専用の単体テストフレームワークで、GDScript によるテストコードの実行環境を構築することができます。JUnit や Jest のような他言語のテストフレームワークと同様に、アサーション、モック、テストスイートの機能を提供します。

対象読者

  • Godot でのゲーム開発経験がある方
  • テスト駆動開発に興味がある方
  • LLM を活用した開発効率化に関心がある方
  • フレーキーテストの問題に悩んでいる方

参考

前提として用意したゲームの実装

フレーキーテストを再現するため、意図的に不安定な要素を含む戦闘システムを用意しました。このシステムでは、HP 同期問題、アニメーション競合、タイミング依存の処理など、実際のゲーム開発で発生しがちな問題を意図的に組み込んでいます。

戦闘システム例

実装ファイル一覧

project.godot (プロジェクトの基本設定)
; Engine configuration file.
; It's best edited using the editor UI and not directly,
; since the parameters that go here are not all obvious.
;
; Format:
;   [section] ; section goes between []
;   param=value ; assign values to parameters

config_version=5

[application]

config/name="dev-gut-llm"
run/main_scene="res://Main.tscn"
config/features=PackedStringArray("4.4", "GL Compatibility")
config/icon="res://icon.svg"

[rendering]

renderer/rendering_method="gl_compatibility"
renderer/rendering_method.mobile="gl_compatibility"
Main.gd (メインシーンの制御とコンポーネント間の連携)
extends Node2D

# 各コンポーネントへの参照
@export var battle_manager: Node
@export var enemy: Node2D
@export var ui: CanvasLayer

func _ready():
	# 子ノードから各コンポーネントを取得
	battle_manager = $BattleManager
	enemy = $Enemy
	ui = $UI

	setup_battle_system()

func setup_battle_system():
	"""戦闘システムの初期化とコンポーネント間の連携設定"""
	if ui and battle_manager and enemy:
		# BattleManager に Enemy の参照を設定
		battle_manager.set_enemy_reference(enemy)

		# UI システムのセットアップ
		ui.setup(battle_manager, enemy)

		# Enemy の HP バーを UI と連携
		if ui.enemy_hp_bar:
			enemy.hp_bar = ui.enemy_hp_bar
			ui.enemy_hp_bar.max_value = enemy.max_hp
			ui.enemy_hp_bar.value = enemy.current_hp

# テスト用: システム全体の状態を取得
func get_system_state() -> Dictionary:
	return {
		"battle_manager": {
			"player_hp": battle_manager.player_hp,
			"enemy_hp": battle_manager.enemy_hp,
			"is_processing": battle_manager.is_processing_attack,
			"battle_active": battle_manager.battle_active
		},
		"enemy": enemy.get_debug_state(),
		"ui": ui.get_debug_state()
	}
BattleManager.gd (戦闘ロジックの中核)
extends Node
class_name BattleManager

# 戦闘イベント通知用シグナル
signal battle_ended(winner: String)
signal damage_dealt(target: String, damage: int)

# 戦闘パラメータ設定
@export var player_max_hp: int = 100
@export var enemy_max_hp: int = 50
@export var base_damage: int = 15

# 戦闘状態管理
var player_hp: int
var enemy_hp: int
var is_processing_attack: bool = false
var battle_active: bool = true
var enemy_ref: Node2D

func _ready():
	# HP の初期化
	player_hp = player_max_hp
	enemy_hp = enemy_max_hp

func set_enemy_reference(enemy: Node2D):
	"""Enemy オブジェクトへの参照を設定"""
	enemy_ref = enemy

func attack_enemy():
	"""敵への攻撃処理 (フレーキー要素を含む)"""
	if not battle_active or is_processing_attack:
		return

	is_processing_attack = true

	# フレーキー要素1: ランダム境界値でのダメージ計算
	var damage = calculate_damage()

	# フレーキー要素2: フレーム依存のタイミング処理
	var wait_frames = randi() % 3 + 1  # 1-3フレーム待機
	for i in wait_frames:
		await get_tree().process_frame

	# HP 更新 (BattleManager 側)
	enemy_hp = max(0, enemy_hp - damage)

	# Enemy オブジェクトにもダメージを適用 (重複管理によるフレーキー要素)
	if enemy_ref:
		enemy_ref.take_damage(damage)

	damage_dealt.emit("enemy", damage)

	# フレーキー要素3: アニメーション完了を待たずに次の処理実行
	if enemy_hp <= 0:
		await simulate_heavy_calculation()
		handle_enemy_death()

	is_processing_attack = false

func calculate_damage() -> int:
	"""ダメージ計算 (クリティカル判定の境界値問題を含む)"""
	var base = base_damage
	var random_bonus = randi() % 10  # 0-9のランダムボーナス

	# フレーキー要素: 境界値での不安定な挙動
	var critical_threshold = 7
	if random_bonus >= critical_threshold:
		return base * 2 + random_bonus  # クリティカルダメージ
	else:
		return base + random_bonus

func simulate_heavy_calculation():
	"""重い処理のシミュレート (処理時間にばらつきを持たせる)"""
	var calculation_time = randf() * 0.1 + 0.05  # 50-150ms
	await get_tree().create_timer(calculation_time).timeout

func handle_enemy_death():
	"""敵の死亡処理"""
	if battle_active:
		battle_active = false
		battle_ended.emit("player")
Enemy.gd (敵キャラクターの HP 管理とアニメーション)
extends Node2D
class_name Enemy

# アニメーション状態通知用シグナル
signal hp_animation_started
signal hp_animation_finished
signal death_animation_finished

# 敵の基本パラメータ
@export var max_hp: int = 50
@export var hp_bar: ProgressBar
@export var sprite: Sprite2D

# HP とアニメーション管理
var current_hp: int
var is_animating: bool = false
var animation_queue: Array[Dictionary] = []

func _ready():
	# HP の初期化
	current_hp = max_hp

	# スプライトの取得
	sprite = $Sprite2D

	# HP バーの初期設定
	if hp_bar:
		hp_bar.max_value = max_hp
		hp_bar.value = current_hp

func take_damage(damage: int):
	"""ダメージ処理 (アニメーションキューによる競合状態を含む)"""
	if current_hp <= 0:
		return

	var old_hp = current_hp
	current_hp = max(0, current_hp - damage)

	# フレーキー要素1: アニメーションキューの競合状態
	var animation_data = {
		"old_hp": old_hp,
		"new_hp": current_hp,
		"damage": damage
	}
	animation_queue.append(animation_data)

	# 既にアニメーション中なら競合が発生
	if not is_animating:
		process_animation_queue()

func process_animation_queue():
	"""アニメーションキューの処理 (複数のフレーキー要素を含む)"""
	if animation_queue.is_empty() or is_animating:
		return

	is_animating = true
	hp_animation_started.emit()

	var animation_data = animation_queue.pop_front()

	# フレーキー要素2: アニメーション時間のばらつき
	var animation_duration = randf() * 0.3 + 0.2  # 200-500ms

	# HP バーアニメーション実行
	if hp_bar:
		var tween = create_tween()
		tween.tween_property(hp_bar, "value", animation_data.new_hp, animation_duration)
		await tween.finished

	# フレーキー要素3: スプライト点滅との並列実行
	if sprite:
		flash_sprite()

	# フレーキー要素4: アニメーション完了通知のタイミング問題
	if randi() % 3 == 0:  # 33%の確率で1フレーム遅延
		await get_tree().process_frame

	is_animating = false
	hp_animation_finished.emit()

	# フレーキー要素5: 死亡チェックのタイミング問題
	if current_hp <= 0:
		handle_death()

	# フレーキー要素6: 次のアニメーション開始タイミングの不確定性
	if not animation_queue.is_empty():
		call_deferred("process_animation_queue")

func flash_sprite():
	"""スプライト点滅アニメーション (並列実行によるタイミング問題)"""
	if not sprite:
		return

	# 並列実行でタイミング問題を誘発
	var flash_tween = create_tween()
	flash_tween.set_parallel(true)

	flash_tween.tween_property(sprite, "modulate", Color.RED, 0.1)
	flash_tween.tween_property(sprite, "modulate", Color.WHITE, 0.1).set_delay(0.1)

func handle_death():
	"""死亡処理 (重複実行防止の不完全性)"""
	# フレーキー要素7: 死亡処理の重複実行防止が不完全
	if sprite and sprite.modulate.a > 0:
		var death_tween = create_tween()
		death_tween.tween_property(sprite, "modulate:a", 0.0, 0.5)
		await death_tween.finished
		death_animation_finished.emit()

func is_dead() -> bool:
	return current_hp <= 0

# テスト用: デバッグ状態の取得
func get_debug_state() -> Dictionary:
	return {
		"current_hp": current_hp,
		"is_animating": is_animating,
		"animation_queue_size": animation_queue.size(),
		"sprite_alpha": sprite.modulate.a if sprite else 1.0
	}
UI.gd (UI 管理)
extends CanvasLayer
class_name BattleUI

# UI コンポーネントへの参照
@export var attack_button: Button
@export var player_hp_bar: ProgressBar
@export var enemy_hp_bar: ProgressBar
@export var battle_log: RichTextLabel

# 戦闘システムへの参照
var battle_manager: Node
var enemy: Node2D
var log_entries: Array[String] = []

func _ready():
	# UI コンポーネントを直接取得
	attack_button = $VBoxContainer/AttackButton
	player_hp_bar = $VBoxContainer/PlayerHPContainer/PlayerHPBar
	enemy_hp_bar = $VBoxContainer/EnemyHPContainer/EnemyHPBar
	battle_log = $VBoxContainer/BattleLog

	# 攻撃ボタンのイベント接続
	if attack_button:
		attack_button.pressed.connect(_on_attack_button_pressed)

func setup(manager: Node, enemy_node: Node2D):
	"""戦闘システムとの連携設定"""
	battle_manager = manager
	enemy = enemy_node

	# 戦闘イベントのシグナル接続
	battle_manager.damage_dealt.connect(_on_damage_dealt)
	battle_manager.battle_ended.connect(_on_battle_ended)
	enemy.hp_animation_started.connect(_on_enemy_animation_started)
	enemy.hp_animation_finished.connect(_on_enemy_animation_finished)

func _on_attack_button_pressed():
	"""攻撃ボタン押下時の処理 (ボタン制御のフレーキー要素を含む)"""
	if battle_manager:
		if attack_button.disabled:
			return

		# ボタンを一時的に無効化
		attack_button.disabled = true
		battle_manager.attack_enemy()

		# フレーキー要素: ボタン再有効化のタイミング問題
		var enable_delay = randf() * 0.2 + 0.3  # 300-500ms のランダム遅延
		await get_tree().create_timer(enable_delay).timeout

		if attack_button and battle_manager.battle_active:
			attack_button.disabled = false

func _on_damage_dealt(target: String, damage: int):
	"""ダメージ発生時の UI 更新"""
	var log_message = "Dealt %d damage to %s" % [damage, target]
	add_log_entry(log_message)

	# フレーキー要素: UI 更新の不安定なタイミング
	if target == "enemy" and enemy_hp_bar:
		if randi() % 4 == 0:  # 25% の確率で1フレーム遅延
			await get_tree().process_frame

func _on_enemy_animation_started():
	"""敵アニメーション開始時のログ記録"""
	add_log_entry("Enemy animation started")

func _on_enemy_animation_finished():
	"""敵アニメーション完了時の同期チェック"""
	add_log_entry("Enemy animation finished")

	# HP 同期問題の検出
	if enemy and enemy_hp_bar:
		if abs(enemy_hp_bar.value - enemy.current_hp) > 1:
			add_log_entry("WARNING: HP desync detected!")

func _on_battle_ended(winner: String):
	"""戦闘終了時の処理"""
	add_log_entry("Battle ended! Winner: " + winner)
	if attack_button:
		attack_button.disabled = true

func add_log_entry(message: String):
	"""ログエントリの追加 (表示更新のフレーキー要素を含む)"""
	var timestamp = Time.get_time_string_from_system()
	var full_message = "[%s] %s" % [timestamp, message]
	log_entries.append(full_message)

	if battle_log:
		battle_log.text += full_message + "\n"

		# フレーキー要素: ログ表示更新のタイミング問題
		if randi() % 3 == 0:  # 33% の確率で1フレーム遅延
			await get_tree().process_frame

		battle_log.scroll_to_line(battle_log.get_line_count())

# テスト用: UI 状態の取得
func get_debug_state() -> Dictionary:
	return {
		"button_disabled": attack_button.disabled if attack_button else false,
		"player_hp_bar_value": player_hp_bar.value if player_hp_bar else 0,
		"enemy_hp_bar_value": enemy_hp_bar.value if enemy_hp_bar else 0,
		"log_entries_count": log_entries.size(),
		"last_log_entry": log_entries[-1] if not log_entries.is_empty() else ""
	}

func clear_logs():
	"""ログのクリア"""
	log_entries.clear()
	if battle_log:
		battle_log.text = ""
Main.tscn (メインシーンの構造定義)
[gd_scene load_steps=5 format=3 uid="uid://b2xvyqr8qwk2d"]

[ext_resource type="Script" path="res://Main.gd" id="1_0hdqr"]
[ext_resource type="Script" path="res://BattleManager.gd" id="2_1hdqr"]
[ext_resource type="Script" path="res://Enemy.gd" id="3_2hdqr"]
[ext_resource type="Script" path="res://UI.gd" id="4_ui"]
[ext_resource type="Texture2D" uid="uid://cschc7ek37cpm" path="res://icon.svg" id="5_icon"]

[node name="Main" type="Node2D"]
script = ExtResource("1_0hdqr")
battle_manager = NodePath("BattleManager")
enemy = NodePath("Enemy")
ui = NodePath("UI")

[node name="BattleManager" type="Node" parent="."]
script = ExtResource("2_1hdqr")

[node name="Enemy" type="Node2D" parent="."]
position = Vector2(400, 200)
script = ExtResource("3_2hdqr")
max_hp = 50

[node name="Sprite2D" type="Sprite2D" parent="Enemy"]
modulate = Color(1, 1, 1, 1) 
texture = ExtResource("5_icon")
scale = Vector2(0.5, 0.5)

[node name="UI" type="CanvasLayer" parent="."]
script = ExtResource("4_ui")
attack_button = NodePath("VBoxContainer/AttackButton")
player_hp_bar = NodePath("VBoxContainer/PlayerHPContainer/PlayerHPBar")
enemy_hp_bar = NodePath("VBoxContainer/EnemyHPContainer/EnemyHPBar")
battle_log = NodePath("VBoxContainer/BattleLog")

[node name="VBoxContainer" type="VBoxContainer" parent="UI"]
anchors_preset = 2
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = 20.0
offset_top = -300.0
offset_right = 400.0
offset_bottom = -20.0

[node name="PlayerHPContainer" type="HBoxContainer" parent="UI/VBoxContainer"]
layout_mode = 2

[node name="PlayerLabel" type="Label" parent="UI/VBoxContainer/PlayerHPContainer"]
layout_mode = 2
text = "Player HP:"
custom_minimum_size = Vector2(100, 0)

[node name="PlayerHPBar" type="ProgressBar" parent="UI/VBoxContainer/PlayerHPContainer"]
layout_mode = 2
size_flags_horizontal = 3
max_value = 100.0
value = 100.0

[node name="EnemyHPContainer" type="HBoxContainer" parent="UI/VBoxContainer"]
layout_mode = 2

[node name="EnemyLabel" type="Label" parent="UI/VBoxContainer/EnemyHPContainer"]
layout_mode = 2
text = "Enemy HP:"
custom_minimum_size = Vector2(100, 0)

[node name="EnemyHPBar" type="ProgressBar" parent="UI/VBoxContainer/EnemyHPContainer"]
layout_mode = 2
size_flags_horizontal = 3
max_value = 50.0
value = 50.0

[node name="AttackButton" type="Button" parent="UI/VBoxContainer"]
layout_mode = 2
text = "Attack!"

[node name="BattleLog" type="RichTextLabel" parent="UI/VBoxContainer"]
layout_mode = 2
size_flags_vertical = 3
bbcode_enabled = true
scroll_following = true

GUT の導入

GUT のインストール

エディタ上部の「AssetLib」で「GUT」を検索しダウンロード・インストールします。

AssetLib 検索結果

プロジェクト設定の更新

GUT を使用するため、プロジェクト設定 > プラグイン で GUT を ON にします。

プラグイン設定

Test ディレクトリの作成

テストファイルを配置するディレクトリを作成します。

mkdir -p test/unit

GUT 設定ファイルの作成

.gutconfig.json を作成し、テストディレクトリを指定します。

{
  "dirs": ["res://test/unit/"],
  "include_subdirs": true,
  "log_level": 1,
  "should_exit": true,
  "should_exit_on_success": false
}

エディタ下部「GUT」をクリックし、「読み込み」で作成した json をロードします。

読み込みボタン

フレーキーテストの実装

test/unit/test_battle_flaky.gd を作成し、フレーキーな挙動を検出するテストを実装します。このテストでは、15 回のイテレーションで連続攻撃を実行し、各攻撃後の状態を記録します。HP 同期問題、アニメーション競合、タイミング異常などのフレーキーな挙動を検出し、結果を LLM に送信して分析を依頼します。

test_battle_flaky.gd (フレーキーテストの実装)
extends GutTest

# テスト対象のコンポーネント
var main_scene: Node2D
var battle_manager: Node
var enemy: Node2D
var ui: CanvasLayer

func before_each():
	"""各テスト実行前のセットアップ"""
	# メインシーンを読み込み
	var main_scene_resource = preload("res://Main.tscn")
	main_scene = main_scene_resource.instantiate()
	add_child(main_scene)

	# セットアップ完了を待機
	await get_tree().process_frame

	# 各コンポーネントへの参照を取得
	battle_manager = main_scene.battle_manager
	enemy = main_scene.enemy
	ui = main_scene.ui

func after_each():
	"""各テスト実行後のクリーンアップ"""
	if main_scene:
		main_scene.queue_free()

func test_rapid_attack_timing_issues():
	"""連続攻撃によるフレーキーテスト - タイミング問題の検出"""
	var test_results = []
	var iterations = 15

	for i in range(iterations):
		# 各イテレーションで戦闘状態をリセット
		reset_battle_state()

		# 連続攻撃の実行とデータ収集
		var attacks_executed = 0
		while battle_manager.battle_active and attacks_executed < 4:
			# 攻撃前の状態を記録
			var before_state = capture_battle_state()

			# 攻撃実行
			battle_manager.attack_enemy()
			attacks_executed += 1

			# フレーキー要素を誘発するランダム待機
			var random_wait = randf() * 0.03  # 0-30ms
			await get_tree().create_timer(random_wait).timeout

			# 攻撃後の状態を記録
			var after_state = capture_battle_state()

			# 攻撃結果をデータとして保存
			var attack_result = create_attack_result_data(
				attacks_executed, before_state, after_state, random_wait
			)
			test_results.append(attack_result)

		# イテレーション終了時の最終状態を記録
		var final_result = create_final_result_data(i, attacks_executed)
		test_results.append(final_result)

		# 次のイテレーション前の待機
		await get_tree().create_timer(0.05).timeout

	# LLM による分析実行
	await analyze_with_llm(test_results, "rapid_attack_timing_issues")

	# 基本的なテスト成功条件
	assert_true(test_results.size() > 0, "Test results should be collected")

	# フレーキー問題の検出結果をログ出力
	log_anomaly_detection_results(test_results, iterations)

func reset_battle_state():
	"""戦闘状態の初期化"""
	battle_manager.player_hp = 100
	battle_manager.enemy_hp = 50
	battle_manager.battle_active = true
	battle_manager.is_processing_attack = false
	enemy.current_hp = 50
	enemy.is_animating = false
	enemy.animation_queue.clear()

	if ui.enemy_hp_bar:
		ui.enemy_hp_bar.value = 50

func capture_battle_state() -> Dictionary:
	"""現在の戦闘状態をキャプチャ"""
	return {
		"enemy_hp": enemy.current_hp,
		"hp_bar_value": ui.enemy_hp_bar.value if ui.enemy_hp_bar else -1,
		"enemy_animating": enemy.is_animating,
		"animation_queue_size": enemy.animation_queue.size(),
		"battle_manager_processing": battle_manager.is_processing_attack
	}

func create_attack_result_data(attack_num: int, before: Dictionary, after: Dictionary, wait_time: float) -> Dictionary:
	"""攻撃結果データの作成"""
	return {
		"attack_num": attack_num,
		"before_hp": before.enemy_hp,
		"after_hp": after.enemy_hp,
		"before_bar": before.hp_bar_value,
		"after_bar": after.hp_bar_value,
		"hp_bar_sync": abs(after.hp_bar_value - after.enemy_hp) < 2,
		"enemy_animating": after.enemy_animating,
		"animation_queue_size": after.animation_queue_size,
		"battle_manager_processing": after.battle_manager_processing,
		"random_wait_ms": wait_time * 1000
	}

func create_final_result_data(iteration: int, attacks_executed: int) -> Dictionary:
	"""イテレーション終了時の最終結果データ作成"""
	var final_result = {
		"iteration": iteration,
		"total_attacks": attacks_executed,
		"final_enemy_hp": enemy.current_hp,
		"final_hp_bar": ui.enemy_hp_bar.value if ui.enemy_hp_bar else -1,
		"final_battle_active": battle_manager.battle_active,
		"final_animation_queue": enemy.animation_queue.size(),
		"battle_ended_properly": not battle_manager.battle_active,
		"hp_desync_detected": false,
		"timing_anomalies": []
	}

	# 異常状態の検出
	detect_anomalies(final_result)
	return final_result

func detect_anomalies(result: Dictionary):
	"""異常状態の検出と記録"""
	# HP 同期問題の検出
	if ui.enemy_hp_bar:
		var hp_diff = abs(ui.enemy_hp_bar.value - enemy.current_hp)
		if hp_diff > 2:
			result.hp_desync_detected = true
			result.timing_anomalies.append("HP bar desync: " + str(hp_diff))

	# アニメーションキューの残存問題
	if enemy.animation_queue.size() > 0 and not battle_manager.battle_active:
		result.timing_anomalies.append("Animation queue not empty after battle end")

func log_anomaly_detection_results(test_results: Array, iterations: int):
	"""異常検出結果のログ出力"""
	var anomaly_count = 0
	for result in test_results:
		if result.has("timing_anomalies") and result.timing_anomalies.size() > 0:
			anomaly_count += 1
			print("Anomaly detected in iteration ", result.get("iteration", "unknown"), ": ", result.timing_anomalies)

	print("Total anomalies detected: ", anomaly_count, " out of ", iterations, " iterations")

func analyze_with_llm(results: Array, test_name: String):
	"""LLM によるテスト結果分析"""
	var http_request = HTTPRequest.new()
	add_child(http_request)

	# 分析リクエストのペイロード作成
	var payload = {
		"test_name": test_name,
		"results": results,
		"timestamp": Time.get_datetime_string_from_system(),
		"analysis_request": "このフレーキーテストの結果を分析して、HP同期問題、アニメーション競合、タイミング問題の根本原因を特定し、具体的な修正方法を提案してください。特に、HP値が負になる問題や、UIとゲームロジックの同期問題に注目してください。"
	}

	var json_string = JSON.stringify(payload)
	var headers = ["Content-Type: application/json"]

	# API エンドポイント (実際の URL に置き換えてください)
	var url = "https://your-endpoint-url"

	print("\n=== Sending test results to Claude for analysis ===")
	print("Data points: ", results.size())
	print("Payload size: ", json_string.length(), " characters")

	# HTTP リクエスト送信
	http_request.request_completed.connect(_on_llm_response_received)
	var error = http_request.request(url, headers, HTTPClient.METHOD_POST, json_string)

	if error != OK:
		print("❌ Failed to send request: ", error)
		http_request.queue_free()
		return

	print("📤 Request sent, waiting for Claude's analysis...")
	await http_request.request_completed
	http_request.queue_free()

func _on_llm_response_received(result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray):
	"""LLM からのレスポンス処理"""
	print("\n=== Claude Analysis Response ===")
	print("Response code: ", response_code)

	if response_code == 200:
		var json = JSON.new()
		var parse_result = json.parse(body.get_string_from_utf8())

		if parse_result == OK:
			var response_data = json.data
			print("\n🤖 Claude's Analysis:")
			print("================================================================================")
			print(response_data.get("analysis", "No analysis provided"))
			print("================================================================================")
			print("📊 Processed ", response_data.get("processed_results", 0), " data points")
			print("⏰ Analysis completed at: ", response_data.get("timestamp", "unknown"))
		else:
			print("❌ Failed to parse Claude's response")
	else:
		print("❌ Claude analysis request failed with code: ", response_code)
		print("Error response: ", body.get_string_from_utf8())

	print("=== End Claude Analysis ===\n")

エンドポイントの作成

テスト結果を分析するため、AWS Lambda と Amazon Bedrock を使用して LLM のエンドポイントを構築します。

Bedrock の設定

Amazon Bedrock の Model catalog で Claude Sonnet 4 のアクセス承認を取得しておきます。

Bedrock

AWS Lambda 関数の作成

AWS Lambda コンソールで新しい関数を作成し、以下のコードを実装します。

Lambda 関数
const { BedrockRuntimeClient, InvokeModelCommand } = require('@aws-sdk/client-bedrock-runtime');

exports.handler = async (event) => {
    console.log('=== GUT Test Results Analysis with Claude ===');

    try {
        // リクエストボディの解析
        let body;
        if (event.body) {
            body = typeof event.body === 'string' ? JSON.parse(event.body) : event.body;
        } else {
            body = event;
        }

        const testName = body.test_name || 'unknown_test';
        const results = body.results || [];
        const analysisRequest = body.analysis_request || '';

        // 入力検証
        if (results.length === 0) {
            return createErrorResponse(400, 'Test results are required');
        }

        console.log(`Analyzing test: "${testName}" with ${results.length} data points`);

        // Claude によるテスト結果分析
        const analysis = await analyzeTestResults(testName, results, analysisRequest);

        // 成功レスポンスの返却
        return createSuccessResponse({
            test_name: testName,
            analysis: analysis,
            processed_results: results.length,
            timestamp: new Date().toISOString()
        });

    } catch (error) {
        console.error('Error:', error);
        console.error('Error stack:', error.stack);
        return createErrorResponse(500, 'Analysis failed', error.message);
    }
};

/**
 * Claude を使用したテスト結果分析
 */
async function analyzeTestResults(testName, results, analysisRequest) {
    console.log('Creating Bedrock client...');

    const client = new BedrockRuntimeClient({
        region: process.env.AWS_REGION || 'your-region'
    });

    // テスト結果の統計情報を事前計算
    const stats = calculateTestStats(results);
    console.log('Calculated stats:', stats);

    // Claude への分析プロンプト構築
    const prompt = buildAnalysisPrompt(testName, results, stats);

    console.log('Sending request to Claude...');

    try {
        const command = new InvokeModelCommand({
            modelId: 'apac.anthropic.claude-sonnet-4-20250514-v1:0',
            body: JSON.stringify({
                messages: [{ role: "user", content: prompt }],
                max_tokens: 800,
                anthropic_version: "bedrock-2023-05-31"
            })
        });

        console.log('Invoking model...');
        const response = await client.send(command);
        console.log('Model response received');

        const result = JSON.parse(new TextDecoder().decode(response.body));
        console.log('Claude analysis completed successfully');

        return result.content[0].text;

    } catch (bedrockError) {
        console.error('Bedrock error:', bedrockError);
        throw bedrockError;
    }
}

/**
 * Claude への分析プロンプト構築
 */
function buildAnalysisPrompt(testName, results, stats) {
    return `Godot ゲームのフレーキーテスト分析:

テスト: ${testName}
データ数: ${results.length}

統計:
- HP同期エラー: ${stats.hpDesyncRate}%
- アニメーション問題: ${stats.animationIssueRate}%
- タイミング異常: ${stats.timingAnomalyRate}%
- 平均HP差分: ${stats.avgHpDiff}

代表的な問題例:
${JSON.stringify(results.slice(0, 3), null, 2)}

以下を簡潔に分析してください:
1. 主要な問題パターン
2. 根本原因の推定
3. 具体的な修正方法(GDScriptコード例含む)

400文字以内で回答してください。`;
}

/**
 * テスト結果の統計情報計算
 */
function calculateTestStats(results) {
    let hpDesyncCount = 0;
    let animationIssueCount = 0;
    let timingAnomalyCount = 0;
    let hpDiffs = [];

    // 各テスト結果を解析して統計情報を収集
    results.forEach(result => {
        if (result.hp_desync_detected) {
            hpDesyncCount++;
        }
        if (result.animation_queue_size > 1) {
            animationIssueCount++;
        }
        if (result.timing_anomalies && result.timing_anomalies.length > 0) {
            timingAnomalyCount++;

            // HP 差分値を抽出
            result.timing_anomalies.forEach(anomaly => {
                if (typeof anomaly === 'string' && anomaly.includes('HP bar desync:')) {
                    const match = anomaly.match(/HP bar desync: ([\d.]+)/);
                    if (match) {
                        hpDiffs.push(parseFloat(match[1]));
                    }
                }
            });
        }
    });

    // 統計値の計算
    const avgHpDiff = hpDiffs.length > 0 ? 
        (hpDiffs.reduce((a, b) => a + b, 0) / hpDiffs.length).toFixed(2) : 0;
    const maxHpDiff = hpDiffs.length > 0 ? Math.max(...hpDiffs).toFixed(2) : 0;

    return {
        hpDesyncRate: results.length > 0 ? ((hpDesyncCount / results.length) * 100).toFixed(1) : 0,
        animationIssueRate: results.length > 0 ? ((animationIssueCount / results.length) * 100).toFixed(1) : 0,
        timingAnomalyRate: results.length > 0 ? ((timingAnomalyCount / results.length) * 100).toFixed(1) : 0,
        avgHpDiff: avgHpDiff,
        maxHpDiff: maxHpDiff
    };
}

/**
 * 成功レスポンスの生成
 */
function createSuccessResponse(data) {
    return {
        statusCode: 200,
        headers: {
            'Content-Type': 'application/json',
            'Access-Control-Allow-Origin': '*'
        },
        body: JSON.stringify(data)
    };
}

/**
 * エラーレスポンスの生成
 */
function createErrorResponse(statusCode, error, message = null) {
    const errorBody = { error };
    if (message) {
        errorBody.message = message;
    }

    return {
        statusCode: statusCode,
        headers: {
            'Content-Type': 'application/json',
            'Access-Control-Allow-Origin': '*'
        },
        body: JSON.stringify(errorBody)
    };
}

IAM ロールの設定

Lambda 関数が Bedrock にアクセスできるよう、Lambda 実行ロールに IAM ポリシーを設定します。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "bedrock:InvokeModel",
                "bedrock:InvokeModelWithResponseStream"
            ],
            "Resource": [
                "arn:aws:bedrock:*:*:inference-profile/apac.anthropic.claude-sonnet-4-20250514-v1:0",
                "arn:aws:bedrock:*::foundation-model/anthropic.claude-sonnet-4-20250514-v1:0"
            ]
        }
    ]
}

Lambda 関数の設定調整

LLM 問合せ時の動作安定化のため、下記の設定とします。

  • タイムアウト: 30 秒
  • メモリ: 256 MB

API Gateway の設定

Lambda 関数を REST API で公開するよう、API Gateway を設定します。エンドポイントの CORS を有効化し、下記のように設定します。

  • Access-Control-Allow-Origin: *
  • Access-Control-Allow-Headers: Content-Type
  • Access-Control-Allow-Methods: POST,OPTIONS

エンドポイント URL の更新

API Gateway のデプロイが完了したら、取得したエンドポイント URL を test_battle_flaky.gd の該当箇所に設定します。

動作確認

GUT タブ > Run All でテストを実行し、 LLM による分析結果が表示されるのを確認します。

実行結果

今回の検証では次のような分析結果を得ることができました。

## 分析結果

### 1. 主要な問題パターン
- 連続攻撃時にHP同期エラー(23%)とタイミング異常(23%)が発生
- 3回目攻撃でHP値とHPバーの同期が破綻(50→32のHP変化でバーが未更新)

### 2. 根本原因
バトルマネージャーの処理状態とアニメーション状態の競合。連続攻撃時にHP更新とUIアニメーションの非同期処理が衝突。

### 3. 修正方法

\```gdscript
# BattleManager.gd
func apply_damage(damage: int):
    if is_processing_attack:
        await attack_finished

    is_processing_attack = true
    enemy_hp -= damage

    # HP更新を即座に反映
    hp_bar.value = enemy_hp

    # アニメーション完了を待機
    await hp_bar.animate_to(enemy_hp)
    is_processing_attack = false
    attack_finished.emit()
\```

キューシステムで攻撃処理を順次実行し、HP同期を保証する仕組みが必要。
実際の動作ログ
Godot Engine v4.4.1.stable.official.49a5bc7b6 - https://godotengine.org
OpenGL API 3.3.0 - Build 32.0.101.6790 - Compatibility - Using Device: Intel - Intel(R) Iris(R) Xe Graphics

BattleManager: _ready() called
BattleManager: HP initialized - Player: 100, Enemy: 50
Enemy: _ready() called
Enemy: HP set to 50
Enemy: Sprite found: Sprite2D:<Sprite2D#58418267799>
Enemy: No HP bar assigned yet
UI: _ready() called
UI: attack_button = AttackButton:<Button#58686703271>
UI: player_hp_bar = PlayerHPBar:<ProgressBar#58552485535>
UI: enemy_hp_bar = EnemyHPBar:<ProgressBar#58653148837>
UI: battle_log = BattleLog:<RichTextLabel#58737034922>
UI: Attack button connected
Main: _ready() called
BattleManager: BattleManager:<Node#58384713365>
Enemy: Enemy:<Node2D#58401490582>
UI: UI:<CanvasLayer#58435045016>
Main: setup_battle_system() called
Main: All components found, setting up...
BattleManager: Enemy reference set
UI: setup() called
UI: Connecting signals...
UI: Signals connected
Main: Setup completed successfully
BattleManager: Starting attack
BattleManager: Calculated damage: 18
BattleManager: Enemy HP now: 32
UI: Damage dealt - 18 to enemy
BattleManager: Starting attack
BattleManager: Calculated damage: 38
BattleManager: Enemy HP now: 0
UI: Damage dealt - 38 to enemy
BattleManager: Enemy defeated!
BattleManager: Starting attack
BattleManager: Calculated damage: 16
BattleManager: Enemy HP now: 34
UI: Damage dealt - 16 to enemy
BattleManager: Starting attack
BattleManager: Calculated damage: 21
BattleManager: Enemy HP now: 29
UI: Damage dealt - 21 to enemy
BattleManager: Starting attack
BattleManager: Calculated damage: 39
BattleManager: Enemy HP now: 0
UI: Damage dealt - 39 to enemy
BattleManager: Enemy defeated!
BattleManager: Starting attack
BattleManager: Calculated damage: 38
BattleManager: Enemy HP now: 12
UI: Damage dealt - 38 to enemy
BattleManager: Starting attack
BattleManager: Calculated damage: 18
BattleManager: Enemy HP now: 0
UI: Damage dealt - 18 to enemy
BattleManager: Enemy defeated!
BattleManager: Starting attack
BattleManager: Calculated damage: 37
BattleManager: Enemy HP now: 13
UI: Damage dealt - 37 to enemy
BattleManager: Starting attack
BattleManager: Calculated damage: 19
BattleManager: Enemy HP now: 0
UI: Damage dealt - 19 to enemy
BattleManager: Enemy defeated!
BattleManager: Starting attack
BattleManager: Calculated damage: 15
BattleManager: Enemy HP now: 35
UI: Damage dealt - 15 to enemy
BattleManager: Starting attack
BattleManager: Calculated damage: 39
BattleManager: Enemy HP now: 0
UI: Damage dealt - 39 to enemy
BattleManager: Enemy defeated!
BattleManager: Starting attack
BattleManager: Calculated damage: 37
BattleManager: Enemy HP now: 13
UI: Damage dealt - 37 to enemy
BattleManager: Starting attack
BattleManager: Calculated damage: 15
BattleManager: Enemy HP now: 0
UI: Damage dealt - 15 to enemy
BattleManager: Enemy defeated!
BattleManager: Starting attack
BattleManager: Calculated damage: 18
BattleManager: Enemy HP now: 32
UI: Damage dealt - 18 to enemy
BattleManager: Starting attack
BattleManager: Calculated damage: 16
BattleManager: Enemy HP now: 16
UI: Damage dealt - 16 to enemy
BattleManager: Starting attack
BattleManager: Calculated damage: 21
BattleManager: Enemy HP now: 0
UI: Damage dealt - 21 to enemy
BattleManager: Enemy defeated!
BattleManager: Starting attack
BattleManager: Calculated damage: 16
BattleManager: Enemy HP now: 34
UI: Damage dealt - 16 to enemy
BattleManager: Starting attack
BattleManager: Calculated damage: 16
BattleManager: Enemy HP now: 34
UI: Damage dealt - 16 to enemy
BattleManager: Starting attack
BattleManager: Calculated damage: 21
BattleManager: Enemy HP now: 13
UI: Damage dealt - 21 to enemy
BattleManager: Starting attack
BattleManager: Calculated damage: 39
BattleManager: Enemy HP now: 0
UI: Damage dealt - 39 to enemy
BattleManager: Enemy defeated!
BattleManager: Starting attack
BattleManager: Calculated damage: 39
BattleManager: Enemy HP now: 11
UI: Damage dealt - 39 to enemy
BattleManager: Starting attack
BattleManager: Calculated damage: 15
BattleManager: Enemy HP now: 35
UI: Damage dealt - 15 to enemy
BattleManager: Starting attack
BattleManager: Calculated damage: 19
BattleManager: Enemy HP now: 16
UI: Damage dealt - 19 to enemy
BattleManager: Starting attack
BattleManager: Calculated damage: 18
BattleManager: Enemy HP now: 0
UI: Damage dealt - 18 to enemy
BattleManager: Enemy defeated!
BattleManager: Starting attack
BattleManager: Calculated damage: 17
BattleManager: Enemy HP now: 33
UI: Damage dealt - 17 to enemy
BattleManager: Starting attack
BattleManager: Calculated damage: 19
BattleManager: Enemy HP now: 14
UI: Damage dealt - 19 to enemy
BattleManager: Starting attack
BattleManager: Calculated damage: 19
BattleManager: Enemy HP now: 31
UI: Damage dealt - 19 to enemy
BattleManager: Starting attack
BattleManager: Calculated damage: 20
BattleManager: Enemy HP now: 11
UI: Damage dealt - 20 to enemy
BattleManager: Starting attack
BattleManager: Calculated damage: 16
BattleManager: Enemy HP now: 0
UI: Damage dealt - 16 to enemy
BattleManager: Enemy defeated!
BattleManager: Starting attack
BattleManager: Calculated damage: 39
BattleManager: Enemy HP now: 11
UI: Damage dealt - 39 to enemy

=== Sending test results to Claude for analysis ===
Data points: 61
Payload size: 13715 characters
Endpoint: https://your-endpoint-url
📤 Request sent, waiting for Claude's analysis...

=== Claude Analysis Response ===
Response code: 200
Response size: 1088 bytes

🤖 Claude's Analysis:
================================================================================
## 分析結果

### 1. 主要な問題パターン
- 連続攻撃時にHP同期エラー(23%)とタイミング異常(23%)が発生
- 3回目攻撃でHP値とHPバーの同期が破綻(50→32のHP変化でバーが未更新)

### 2. 根本原因
バトルマネージャーの処理状態とアニメーション状態の競合。連続攻撃時にHP更新とUIアニメーションの非同期処理が衝突。

### 3. 修正方法

\```gdscript
# BattleManager.gd
func apply_damage(damage: int):
    if is_processing_attack:
        await attack_finished

    is_processing_attack = true
    enemy_hp -= damage

    # HP更新を即座に反映
    hp_bar.value = enemy_hp

    # アニメーション完了を待機
    await hp_bar.animate_to(enemy_hp)
    is_processing_attack = false
    attack_finished.emit()
\```

キューシステムで攻撃処理を順次実行し、HP同期を保証する仕組みが必要。
================================================================================
📊 Processed 61.0 data points
⏰ Analysis completed at: 2025-07-17T08:04:07.676Z
=== End Claude Analysis ===

Anomaly detected in iteration 0: ["HP bar desync: 17.3"]
Anomaly detected in iteration 1: ["HP bar desync: 4.17"]
Anomaly detected in iteration 2: ["HP bar desync: 40.42"]
Anomaly detected in iteration 3: ["HP bar desync: 31.25"]
Anomaly detected in iteration 4: ["HP bar desync: 18.65", "Animation queue not empty after battle end"]
Anomaly detected in iteration 5: ["HP bar desync: 17.44", "Animation queue not empty after battle end"]
Anomaly detected in iteration 6: ["HP bar desync: 14.94"]
Anomaly detected in iteration 7: ["HP bar desync: 4.53"]
Anomaly detected in iteration 8: ["HP bar desync: 22.34"]
Anomaly detected in iteration 9: ["HP bar desync: 7.76"]
Anomaly detected in iteration 10: ["HP bar desync: 7.54"]
Anomaly detected in iteration 12: ["HP bar desync: 8.27"]
Anomaly detected in iteration 13: ["HP bar desync: 13.11"]
Anomaly detected in iteration 14: ["HP bar desync: 12.48"]
Total anomalies detected: 14 out of 15 iterations
--- Debugging process stopped ---

まとめ

本記事では、Godot の GUT テストフレームワークと LLM を組み合わせて、フレーキーテストの根本原因を自動分析するシステムを構築しました。意図的に不安定な要素を含む戦闘システムを用意し、テスト結果を Claude に送信することで、原因特定を自動化し具体的な修正コード例まで提案してもらうことができました。この手法をより大規模なゲームプロジェクトに適用することで、複雑な状態管理やマルチプレイヤー同期などの高度なフレーキーテストの実施も期待できるでしょう。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.