chore(client): add gdUnit4 test framework addon

Vendor gdUnit4 for Godot 4 client testing (D-030).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 16:52:00 +01:00
co-authored by Claude Opus 4.6
parent 3a2fe4bd71
commit 61e0762a84
979 changed files with 55221 additions and 0 deletions
@@ -0,0 +1,274 @@
## The execution context
## It contains all the necessary information about the executed stage, such as memory observers, reports, orphan monitor
class_name GdUnitExecutionContext
enum GC_ORPHANS_CHECK {
NONE,
SUITE_HOOK_AFTER,
TEST_HOOK_AFTER,
TEST_CASE
}
var _parent_context: GdUnitExecutionContext
var _sub_context: Array[GdUnitExecutionContext] = []
var _orphan_monitor: GdUnitOrphanNodesMonitor
var _memory_observer: GdUnitMemoryObserver
var _report_collector: GdUnitTestReportCollector
var _timer: LocalTime
var _test_case_name: StringName
var _test_case_parameter_set: Array
var _name: String
var _test_execution_iteration: int = 0
var _flaky_test_check := GdUnitSettings.is_test_flaky_check_enabled()
var _flaky_test_retries := GdUnitSettings.get_flaky_max_retries()
var error_monitor: GodotGdErrorMonitor = null:
get:
if _parent_context != null:
return _parent_context.error_monitor
if error_monitor == null:
error_monitor = GodotGdErrorMonitor.new()
return error_monitor
var test_suite: GdUnitTestSuite = null:
get:
if _parent_context != null:
return _parent_context.test_suite
return test_suite
var test_case: _TestCase = null:
get:
if test_case == null and _parent_context != null:
return _parent_context.test_case
return test_case
func _init(name: StringName, parent_context: GdUnitExecutionContext = null) -> void:
_name = name
_parent_context = parent_context
_timer = LocalTime.now()
_orphan_monitor = GdUnitOrphanNodesMonitor.new(name)
if parent_context != null:
parent_context._orphan_monitor.add_child_monitor(_orphan_monitor)
orphan_monitor_start()
_memory_observer = GdUnitMemoryObserver.new()
_report_collector = GdUnitTestReportCollector.new()
if parent_context != null:
parent_context._sub_context.append(self)
func dispose() -> void:
if test_suite != null:
test_suite.free()
_timer = null
_orphan_monitor = null
_report_collector = null
_memory_observer = null
_parent_context = null
test_suite = null
test_case = null
dispose_sub_contexts()
func dispose_sub_contexts() -> void:
for context in _sub_context:
context.dispose()
_sub_context.clear()
func terminate() -> void:
if test_case:
test_case.do_terminate()
static func of(pe: GdUnitExecutionContext) -> GdUnitExecutionContext:
var context := GdUnitExecutionContext.new(pe._test_case_name, pe)
context._test_case_name = pe._test_case_name
context._test_execution_iteration = pe._test_execution_iteration
return context
static func of_test_case(pe: GdUnitExecutionContext, p_test_case: _TestCase) -> GdUnitExecutionContext:
assert(p_test_case, "test_case is null")
var context := GdUnitExecutionContext.new(p_test_case.test_name(), pe)
context.test_case = p_test_case
return context
static func of_parameterized_test(pe: GdUnitExecutionContext, test_case_name: String, test_case_parameter_set: Array) -> GdUnitExecutionContext:
var context := GdUnitExecutionContext.new(test_case_name, pe)
context._test_case_name = test_case_name
context._test_case_parameter_set = test_case_parameter_set
return context
func get_test_suite_path() -> String:
return test_suite.get_script().resource_path
func get_test_suite_name() -> StringName:
return test_suite.get_name()
func get_test_case_name() -> StringName:
if _test_case_name.is_empty():
return test_case._test_case.display_name
return _test_case_name
func error_monitor_start() -> void:
error_monitor.start()
func error_monitor_stop() -> void:
error_monitor.stop()
for error_report in error_monitor.to_reports():
if error_report.is_error():
_report_collector.push_back(error_report)
func orphan_monitor_start() -> void:
_orphan_monitor.start()
func orphan_monitor_collect() -> void:
_orphan_monitor.collect()
func orphan_monitor_stop() -> void:
_orphan_monitor.stop()
func add_report(report: GdUnitReport) -> GdUnitReport:
_report_collector.push_back(report)
return report
func reports() -> Array[GdUnitReport]:
return _report_collector.reports()
func collect_reports(recursive: bool) -> Array[GdUnitReport]:
if not recursive:
return reports()
# we combine the reports of test_before(), test_after() and test() to be reported by `fire_test_ended`
# we strictly need to copy the reports before adding sub context reports to avoid manipulation of the current context
var current_reports := reports().duplicate()
for sub_context in _sub_context:
current_reports.append_array(sub_context.collect_reports(true))
return current_reports
func calculate_statistics(reports_: Array[GdUnitReport]) -> Dictionary:
var failed_count := GdUnitTestReportCollector.count_failures(reports_)
var error_count := GdUnitTestReportCollector.count_errors(reports_)
var warn_count := GdUnitTestReportCollector.count_warnings(reports_)
var skip_count := GdUnitTestReportCollector.count_skipped(reports_)
var orphan_count := GdUnitTestReportCollector.count_orphans(reports_)
var is_failed := !is_success()
var elapsed_time := _timer.elapsed_since_ms()
var retries := 1 if _parent_context == null else _sub_context.size()
# Mark as flaky if it is successful, but errors were counted
var is_flaky := retries > 1 and not is_failed
# In the case of a flakiness test, we do not report an error counter, as an unreliable test is considered successful
# after a certain number of repetitions.
if is_flaky:
failed_count = 0
return {
GdUnitEvent.RETRY_COUNT: retries,
GdUnitEvent.ELAPSED_TIME: elapsed_time,
GdUnitEvent.FAILED: is_failed,
GdUnitEvent.ERRORS: error_count > 0,
GdUnitEvent.WARNINGS: warn_count > 0,
GdUnitEvent.FLAKY: is_flaky,
GdUnitEvent.SKIPPED: skip_count > 0,
GdUnitEvent.FAILED_COUNT: failed_count,
GdUnitEvent.ERROR_COUNT: error_count,
GdUnitEvent.SKIPPED_COUNT: skip_count,
GdUnitEvent.ORPHAN_NODES: orphan_count,
}
func is_success() -> bool:
if _sub_context.is_empty():
return not _report_collector.has_failures()
# we on test suite level?
if _parent_context == null:
return not _report_collector.has_failures()
return _sub_context[-1].is_success() and not _report_collector.has_failures()
func is_skipped() -> bool:
return (
_sub_context.any(func(c :GdUnitExecutionContext) -> bool:
return c.is_skipped())
or test_case.is_skipped() if test_case != null else false
)
func is_interupted() -> bool:
return false if test_case == null else test_case.is_interupted()
func sum(accum: int, number: int) -> int:
return accum + number
func retry_execution() -> bool:
var retry := _test_execution_iteration < 1 if not _flaky_test_check else _test_execution_iteration < _flaky_test_retries
if retry:
_test_execution_iteration += 1
return retry
func register_auto_free(obj: Variant) -> Variant:
return _memory_observer.register_auto_free(obj)
## Runs the gdunit garbage collector to free registered object and handle orphan node reporting
func gc(gc_orphan_check: GC_ORPHANS_CHECK = GC_ORPHANS_CHECK.NONE) -> void:
# unreference last used assert form the test to prevent memory leaks
GdUnitThreadManager.get_current_context().clear_assert()
await _memory_observer.gc()
orphan_monitor_stop()
match(gc_orphan_check):
GC_ORPHANS_CHECK.SUITE_HOOK_AFTER:
_orphan_monitor.collect()
var orphan_infos := _orphan_monitor.detected_orphans()
if orphan_infos.is_empty():
return
reports().push_front(GdUnitReport.new() \
.create(GdUnitReport.ORPHAN, 1, GdAssertMessages.orphan_detected_on_suite_setup(orphan_infos))
.with_current_value(orphan_infos.size()))
GC_ORPHANS_CHECK.TEST_HOOK_AFTER:
_orphan_monitor.collect()
var orphans := _orphan_monitor.detected_orphans()
if not orphans.is_empty():
reports().push_front(GdUnitReport.new()\
.create(GdUnitReport.ORPHAN, 1, GdAssertMessages.orphan_detected_on_test_setup(orphans))
.with_current_value(orphans.size()))
GC_ORPHANS_CHECK.TEST_CASE:
var orphans := _orphan_monitor.detected_orphans()
if orphans.is_empty():
var orphans_count := _orphan_monitor.orphans_count()
if orphans_count > 0:
reports().push_front(GdUnitReport.new() \
.create(GdUnitReport.ORPHAN, test_case.line_number(), GdAssertMessages.orphan_warning(orphans_count))
.with_current_value(orphans_count))
else:
reports().push_front(GdUnitReport.new()\
.create(GdUnitReport.ORPHAN, test_case.line_number(), GdAssertMessages.orphan_detected_on_test(orphans))
.with_current_value(orphans.size()))
@@ -0,0 +1 @@
uid://b6tbrqev7vxuk
@@ -0,0 +1,135 @@
## The memory watcher for objects that have been registered and are released when 'gc' is called.
class_name GdUnitMemoryObserver
extends RefCounted
const TAG_OBSERVE_INSTANCE := "GdUnit4_observe_instance_"
const TAG_AUTO_FREE = "GdUnit4_marked_auto_free"
const GdUnitTools = preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
var _store :Array[Variant] = []
# enable for debugging purposes
var _is_stdout_verbose := false
const _show_debug := false
## Registration of an instance to be released when an execution phase is completed
func register_auto_free(obj :Variant) -> Variant:
if not is_instance_valid(obj):
return obj
# do not register on GDScriptNativeClass
@warning_ignore("unsafe_cast")
if typeof(obj) == TYPE_OBJECT and (obj as Object).is_class("GDScriptNativeClass") :
return obj
#if obj is GDScript or obj is ScriptExtension:
# return obj
if obj is MainLoop:
push_error("GdUnit4: Avoid to add mainloop to auto_free queue %s" % obj)
return
if _is_stdout_verbose:
print_verbose("GdUnit4:gc():register auto_free(%s)" % obj)
# only register pure objects
if obj is GdUnitSceneRunner:
_store.push_back(obj)
else:
_store.append(obj)
_tag_object(obj)
return obj
# to disable instance guard when run into issues.
static func _is_instance_guard_enabled() -> bool:
return false
static func debug_observe(name :String, obj :Object, indent :int = 0) -> void:
if not _show_debug:
return
var script :GDScript= obj if obj is GDScript else obj.get_script()
if script:
var base_script :GDScript = script.get_base_script()
@warning_ignore("unsafe_method_access")
prints("".lpad(indent, " "), name, obj, obj.get_class(), "reference_count:", obj.get_reference_count() if obj is RefCounted else 0, "script:", script, script.resource_path)
if base_script:
debug_observe("+", base_script, indent+1)
else:
@warning_ignore("unsafe_method_access")
prints(name, obj, obj.get_class(), obj.get_name())
static func guard_instance(obj :Object) -> void:
if not _is_instance_guard_enabled():
return
var tag := TAG_OBSERVE_INSTANCE + str(abs(obj.get_instance_id()))
if Engine.has_meta(tag):
return
debug_observe("Gard on instance", obj)
Engine.set_meta(tag, obj)
static func unguard_instance(obj :Object, verbose := true) -> void:
if not _is_instance_guard_enabled():
return
var tag := TAG_OBSERVE_INSTANCE + str(abs(obj.get_instance_id()))
if verbose:
debug_observe("unguard instance", obj)
if Engine.has_meta(tag):
Engine.remove_meta(tag)
static func gc_guarded_instance(name :String, instance :Object) -> void:
if not _is_instance_guard_enabled():
return
await (Engine.get_main_loop() as SceneTree).process_frame
unguard_instance(instance, false)
if is_instance_valid(instance) and instance is RefCounted:
# finally do this very hacky stuff
# we need to manually unreferece to avoid leaked scripts
# but still leaked GDScriptFunctionState exists
#var script :GDScript = instance.get_script()
#if script:
# var base_script :GDScript = script.get_base_script()
# if base_script:
# base_script.unreference()
debug_observe(name, instance)
(instance as RefCounted).unreference()
await (Engine.get_main_loop() as SceneTree).process_frame
static func gc_on_guarded_instances() -> void:
if not _is_instance_guard_enabled():
return
for tag in Engine.get_meta_list():
if tag.begins_with(TAG_OBSERVE_INSTANCE):
var instance :Object = Engine.get_meta(tag)
await gc_guarded_instance("Leaked instance detected:", instance)
await GdUnitTools.free_instance(instance, false)
# store the object into global store aswell to be verified by 'is_marked_auto_free'
func _tag_object(obj :Variant) -> void:
var tagged_object: Array = Engine.get_meta(TAG_AUTO_FREE, [])
tagged_object.append(obj)
Engine.set_meta(TAG_AUTO_FREE, tagged_object)
## Runs over all registered objects and releases them
func gc() -> void:
if _store.is_empty():
return
# give engine time to free objects to process objects marked by queue_free()
await (Engine.get_main_loop() as SceneTree).process_frame
if _is_stdout_verbose:
print_verbose("GdUnit4:gc():running", " freeing %d objects .." % _store.size())
var tagged_objects: Array = Engine.get_meta(TAG_AUTO_FREE, [])
while not _store.is_empty():
var value :Variant = _store.pop_front()
tagged_objects.erase(value)
await GdUnitTools.free_instance(value, _is_stdout_verbose)
assert(_store.is_empty(), "The memory observer has still entries in the store!")
## Checks whether the specified object is registered for automatic release
static func is_marked_auto_free(obj: Variant) -> bool:
var tagged_objects: Array = Engine.get_meta(TAG_AUTO_FREE, [])
return tagged_objects.has(obj)
@@ -0,0 +1 @@
uid://beik6mbe7oahv
@@ -0,0 +1,77 @@
# Collects all reports seperated as warnings, failures and errors
class_name GdUnitTestReportCollector
extends RefCounted
var _reports :Array[GdUnitReport] = []
static func __filter_is_error(report :GdUnitReport) -> bool:
return report.is_error()
static func __filter_is_failure(report :GdUnitReport) -> bool:
return report.is_failure()
static func __filter_is_warning(report :GdUnitReport) -> bool:
return report.is_warning()
static func __filter_is_skipped(report :GdUnitReport) -> bool:
return report.is_skipped()
static func __filter_is_orphan(report: GdUnitReport) -> bool:
return report.is_orphan()
static func count_failures(reports_: Array[GdUnitReport]) -> int:
return reports_.filter(__filter_is_failure).size()
static func count_errors(reports_: Array[GdUnitReport]) -> int:
return reports_.filter(__filter_is_error).size()
static func count_warnings(reports_: Array[GdUnitReport]) -> int:
return reports_.filter(__filter_is_warning).size()
static func count_skipped(reports_: Array[GdUnitReport]) -> int:
return reports_.filter(__filter_is_skipped).size()
static func count_orphans(reports_: Array[GdUnitReport]) -> int:
var orphan_reports := reports_.filter(__filter_is_orphan)
if orphan_reports.is_empty():
return 0
## Collect orphan count from the reports
var orphans := 0
for report: GdUnitReport in orphan_reports:
orphans += report._current_value
return orphans
func has_failures() -> bool:
return _reports.any(__filter_is_failure)
func has_errors() -> bool:
return _reports.any(__filter_is_error)
func has_warnings() -> bool:
return _reports.any(__filter_is_warning)
func has_skipped() -> bool:
return _reports.any(__filter_is_skipped)
func reports() -> Array[GdUnitReport]:
return _reports
func push_back(report :GdUnitReport) -> void:
_reports.push_back(report)
@@ -0,0 +1 @@
uid://dwi34h40wy72u
@@ -0,0 +1,56 @@
## The executor to run a test-suite
class_name GdUnitTestSuiteExecutor
# preload all asserts here
@warning_ignore("unused_private_class_variable")
var _assertions := GdUnitAssertions.new()
var _executeStage := GdUnitTestSuiteExecutionStage.new()
var _debug_mode : bool
var _terminated := false
func _init(debug_mode: bool = false) -> void:
_executeStage.set_debug_mode(debug_mode)
_debug_mode = debug_mode
GdUnitSignals.instance().gdunit_test_session_terminate.connect(_on_testsession_terminated)
func _on_testsession_terminated() -> void:
_terminated = true
GdUnitThreadManager.interrupt()
func run_and_wait(tests: Array[GdUnitTestCase]) -> void:
if !_debug_mode:
GdUnitSignals.instance().gdunit_event.emit(GdUnitInit.new())
var orphan_detection_enabled := GdUnitSettings.is_verbose_orphans()
if not orphan_detection_enabled:
prints("!!! Reporting orphan nodes is disabled. Please check GdUnit settings.")
# first we group all tests by resource path
var grouped_by_suites := GdArrayTools.group_by(tests, func(test: GdUnitTestCase) -> String:
return test.suite_resource_path
)
var scanner := GdUnitTestSuiteScanner.new()
for suite_path: String in grouped_by_suites.keys():
if _terminated:
break
@warning_ignore("unsafe_call_argument")
var suite_tests: Array[GdUnitTestCase] = Array(grouped_by_suites[suite_path], TYPE_OBJECT, "RefCounted", GdUnitTestCase)
var script := GdUnitTestSuiteScanner.load_with_disabled_warnings(suite_path)
if script.get_class() == "GDScript":
var context := GdUnitExecutionContext.new(suite_path)
var test_suite := scanner.load_suite(script as GDScript, suite_tests)
context.test_suite = test_suite
(Engine.get_main_loop() as SceneTree).root.add_child(test_suite)
await _executeStage.execute(context)
context.dispose()
else:
await GdUnit4CSharpApiLoader.execute(suite_tests)
if !_debug_mode:
GdUnitSignals.instance().gdunit_event.emit(GdUnitStop.new())
func fail_fast(enabled :bool) -> void:
_executeStage.fail_fast(enabled)
@@ -0,0 +1 @@
uid://cxinlamg7ka6s
@@ -0,0 +1,22 @@
## The test case shutdown hook implementation.[br]
## It executes the 'test_after()' block from the test-suite.
class_name GdUnitTestCaseAfterStage
extends IGdUnitExecutionStage
var _call_stage: bool
func _init(call_stage := true) -> void:
_call_stage = call_stage
func _execute(context: GdUnitExecutionContext) -> void:
var test_suite := context.test_suite
if _call_stage:
@warning_ignore("redundant_await")
await test_suite.after_test()
await context.gc(GdUnitExecutionContext.GC_ORPHANS_CHECK.TEST_HOOK_AFTER)
context.error_monitor_stop()
@@ -0,0 +1 @@
uid://b0i7ud0nko07o
@@ -0,0 +1,20 @@
## The test case startup hook implementation.[br]
## It executes the 'test_before()' block from the test-suite.
class_name GdUnitTestCaseBeforeStage
extends IGdUnitExecutionStage
var _call_stage :bool
func _init(call_stage := true) -> void:
_call_stage = call_stage
func _execute(context: GdUnitExecutionContext) -> void:
var test_suite := context.test_suite
if _call_stage:
@warning_ignore("redundant_await")
await test_suite.before_test()
context.error_monitor_start()
@@ -0,0 +1 @@
uid://cx8mwagqugp5y
@@ -0,0 +1,37 @@
## The test case execution stage.[br]
class_name GdUnitTestCaseExecutionStage
extends IGdUnitExecutionStage
var _stage_single_test: IGdUnitExecutionStage = GdUnitTestCaseSingleExecutionStage.new()
var _stage_fuzzer_test: IGdUnitExecutionStage = GdUnitTestCaseFuzzedExecutionStage.new()
## Executes the test case 'test_<name>()'.[br]
## It executes synchronized following stages[br]
## -> test_before() [br]
## -> test_case() [br]
## -> test_after() [br]
@warning_ignore("redundant_await")
func _execute(context :GdUnitExecutionContext) -> void:
var test_case := context.test_case
context.error_monitor_start()
if test_case.is_fuzzed():
await _stage_fuzzer_test.execute(context)
else:
await _stage_single_test.execute(context)
await context.gc()
context.error_monitor_stop()
# finally free the test instance
if is_instance_valid(context.test_case):
context.test_case.dispose()
func set_debug_mode(debug_mode :bool = false) -> void:
super.set_debug_mode(debug_mode)
_stage_single_test.set_debug_mode(debug_mode)
_stage_fuzzer_test.set_debug_mode(debug_mode)
@@ -0,0 +1 @@
uid://ci15c4tn1gld4
@@ -0,0 +1,29 @@
## The test suite shutdown hook implementation.[br]
## It executes the 'after()' block from the test-suite.
class_name GdUnitTestSuiteAfterStage
extends IGdUnitExecutionStage
const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
func _execute(context :GdUnitExecutionContext) -> void:
var test_suite := context.test_suite
@warning_ignore("redundant_await")
await test_suite.after()
await context.gc(GdUnitExecutionContext.GC_ORPHANS_CHECK.SUITE_HOOK_AFTER)
var reports := context.collect_reports(false)
var statistics := context.calculate_statistics(reports)
fire_event(GdUnitEvent.new()\
.suite_after(context.get_test_suite_path(),\
test_suite.get_name(),
statistics,
reports))
GdUnitFileAccess.clear_tmp()
# Guard that checks if all doubled (spy/mock) objects are released
await GdUnitClassDoubler.check_leaked_instances()
# we hide the scene/main window after runner is finished
if not Engine.is_embedded_in_editor():
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_MINIMIZED)
@@ -0,0 +1 @@
uid://bcj0n64icx10i
@@ -0,0 +1,14 @@
## The test suite startup hook implementation.[br]
## It executes the 'before()' block from the test-suite.
class_name GdUnitTestSuiteBeforeStage
extends IGdUnitExecutionStage
func _execute(context: GdUnitExecutionContext) -> void:
var test_suite := context.test_suite
fire_event(GdUnitEvent.new()\
.suite_before(context.get_test_suite_path(), test_suite.get_name(), test_suite.get_child_count()))
@warning_ignore("redundant_await")
await test_suite.before()
@@ -0,0 +1 @@
uid://cjudpgdgtetuf
@@ -0,0 +1,147 @@
## The test suite main execution stage.[br]
class_name GdUnitTestSuiteExecutionStage
extends IGdUnitExecutionStage
const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
var _stage_before :IGdUnitExecutionStage = GdUnitTestSuiteBeforeStage.new()
var _stage_after :IGdUnitExecutionStage = GdUnitTestSuiteAfterStage.new()
var _stage_test :IGdUnitExecutionStage = GdUnitTestCaseExecutionStage.new()
var _fail_fast := false
## Executes all tests of an test suite.[br]
## It executes synchronized following stages[br]
## -> before() [br]
## -> run all test cases [br]
## -> after() [br]
func _execute(context :GdUnitExecutionContext) -> void:
if context.test_suite.__is_skipped:
await fire_test_suite_skipped(context)
else:
@warning_ignore("return_value_discarded")
GdUnitMemoryObserver.guard_instance(context.test_suite.__awaiter)
await _stage_before.execute(context)
for test_case_index in context.test_suite.get_child_count():
# iterate only over test cases
var test_case := context.test_suite.get_child(test_case_index) as _TestCase
if not is_instance_valid(test_case):
continue
context.test_suite.set_active_test_case(test_case.test_name())
var test_case_context := GdUnitExecutionContext.of_test_case(context, test_case)
await _stage_test.execute(test_case_context)
# stop on first error or if fail fast is enabled
if test_case.is_terminated() or (_fail_fast and not test_case_context.is_success()):
break
if test_case.is_interupted():
# it needs to go this hard way to kill the outstanding awaits of a test case when the test timed out
# we delete the current test suite where is execute the current test case to kill the function state
# and replace it by a clone without function state
context.test_suite = await clone_test_suite(context.test_suite)
await _stage_after.execute(context)
GdUnitMemoryObserver.unguard_instance(context.test_suite.__awaiter)
await (Engine.get_main_loop() as SceneTree).process_frame
# clones a test suite and moves the test cases to new instance
func clone_test_suite(test_suite :GdUnitTestSuite) -> GdUnitTestSuite:
await (Engine.get_main_loop() as SceneTree).process_frame
dispose_timers(test_suite)
await GdUnitMemoryObserver.gc_guarded_instance("Manually free on awaiter", test_suite.__awaiter)
var parent := test_suite.get_parent()
var _test_suite := GdUnitTestSuite.new()
parent.remove_child(test_suite)
copy_properties(test_suite, _test_suite)
for child in test_suite.get_children():
test_suite.remove_child(child)
_test_suite.add_child(child)
parent.add_child(_test_suite)
@warning_ignore("return_value_discarded")
GdUnitMemoryObserver.guard_instance(_test_suite.__awaiter)
# finally free current test suite instance
test_suite.free()
await (Engine.get_main_loop() as SceneTree).process_frame
return _test_suite
func dispose_timers(test_suite :GdUnitTestSuite) -> void:
GdUnitTools.release_timers()
for child in test_suite.get_children():
if child is Timer:
(child as Timer).stop()
test_suite.remove_child(child)
child.free()
func copy_properties(source :Object, target :Object) -> void:
if not source is _TestCase and not source is GdUnitTestSuite:
return
for property in source.get_property_list():
var property_name :String = property["name"]
if property_name == "__awaiter":
continue
target.set(property_name, source.get(property_name))
func fire_test_suite_skipped(context :GdUnitExecutionContext) -> void:
var test_suite := context.test_suite
var skip_count := test_suite.get_child_count()
fire_event(GdUnitEvent.new()\
.suite_before(context.get_test_suite_path(), test_suite.get_name(), skip_count))
for test_case_index in context.test_suite.get_child_count():
# iterate only over test cases
var test_case := context.test_suite.get_child(test_case_index) as _TestCase
if not is_instance_valid(test_case):
continue
var test_case_context := GdUnitExecutionContext.of_test_case(context, test_case)
fire_event(GdUnitEvent.new().test_before(test_case.id()))
# use skip count 0 because we counted it over the complete test suite
fire_test_skipped(test_case_context, 0)
var statistics := {
GdUnitEvent.ORPHAN_NODES: 0,
GdUnitEvent.ELAPSED_TIME: 0,
GdUnitEvent.WARNINGS: false,
GdUnitEvent.ERRORS: false,
GdUnitEvent.ERROR_COUNT: 0,
GdUnitEvent.FAILED: false,
GdUnitEvent.FAILED_COUNT: 0,
GdUnitEvent.SKIPPED_COUNT: skip_count,
GdUnitEvent.SKIPPED: true
}
var report := GdUnitReport.new().create(GdUnitReport.SKIPPED, -1, GdAssertMessages.test_suite_skipped(test_suite.__skip_reason, skip_count))
fire_event(GdUnitEvent.new().suite_after(context.get_test_suite_path(), test_suite.get_name(), statistics, [report]))
await (Engine.get_main_loop() as SceneTree).process_frame
func fire_test_skipped(context: GdUnitExecutionContext, skip_count := 1) -> void:
var test_case := context.test_case
var statistics := {
GdUnitEvent.ORPHAN_NODES: 0,
GdUnitEvent.ELAPSED_TIME: 0,
GdUnitEvent.WARNINGS: false,
GdUnitEvent.ERRORS: false,
GdUnitEvent.ERROR_COUNT: 0,
GdUnitEvent.FAILED: false,
GdUnitEvent.FAILED_COUNT: 0,
GdUnitEvent.SKIPPED: true,
GdUnitEvent.SKIPPED_COUNT: skip_count,
}
var report := GdUnitReport.new() \
.create(GdUnitReport.SKIPPED, test_case.line_number(), GdAssertMessages.test_skipped("Skipped from the entire test suite"))
fire_event(GdUnitEvent.new().test_after(test_case.id(), test_case.test_name(), statistics, [report]))
func set_debug_mode(debug_mode :bool = false) -> void:
super.set_debug_mode(debug_mode)
_stage_before.set_debug_mode(debug_mode)
_stage_after.set_debug_mode(debug_mode)
_stage_test.set_debug_mode(debug_mode)
func fail_fast(enabled :bool) -> void:
_fail_fast = enabled
@@ -0,0 +1 @@
uid://btrvyvk62pxxd
@@ -0,0 +1,37 @@
## The interface of execution stage.[br]
## An execution stage is defined as an encapsulated task that can execute 1-n substages covered by its own execution context.[br]
## Execution stage are always called synchronously.
@abstract class_name IGdUnitExecutionStage
extends RefCounted
var _debug_mode := false
## Executes synchronized the implemented stage in its own execution context.[br]
## example:[br]
## [codeblock]
## # waits for 100ms
## await MyExecutionStage.new().execute(<GdUnitExecutionContext>)
## [/codeblock][br]
func execute(context: GdUnitExecutionContext) -> void:
GdUnitThreadManager.get_current_context().set_execution_context(context)
@warning_ignore("redundant_await")
await _execute(context)
## Sends the event to registered listeners
func fire_event(event: GdUnitEvent) -> void:
if _debug_mode:
GdUnitSignals.instance().gdunit_event_debug.emit(event)
else:
GdUnitSignals.instance().gdunit_event.emit(event)
## Internal testing stuff.[br]
## Sets the executor into debug mode to emit `GdUnitEvent` via signal `gdunit_event_debug`
func set_debug_mode(debug_mode: bool) -> void:
_debug_mode = debug_mode
## The execution phase to be carried out.
@abstract func _execute(context: GdUnitExecutionContext) -> void
@@ -0,0 +1 @@
uid://cvtdudovarffs
@@ -0,0 +1,52 @@
## The test case execution stage.[br]
class_name GdUnitTestCaseFuzzedExecutionStage
extends IGdUnitExecutionStage
var _stage_before :IGdUnitExecutionStage = GdUnitTestCaseBeforeStage.new(false)
var _stage_after :IGdUnitExecutionStage = GdUnitTestCaseAfterStage.new(false)
var _stage_test :IGdUnitExecutionStage = GdUnitTestCaseFuzzedTestStage.new()
func _execute(context :GdUnitExecutionContext) -> void:
fire_event(GdUnitEvent.new().test_before(context.test_case.id()))
while context.retry_execution():
var test_context := GdUnitExecutionContext.of(context)
await _stage_before.execute(test_context)
if not context.test_case.is_skipped():
await _stage_test.execute(GdUnitExecutionContext.of(test_context))
await _stage_after.execute(test_context)
if test_context.is_success() or test_context.is_skipped() or test_context.is_interupted():
break
context.gc()
if context.is_skipped():
fire_test_skipped(context)
else:
var reports: = context.collect_reports(true)
var statistics := context.calculate_statistics(reports)
fire_event(GdUnitEvent.new().test_after(context.test_case.id(), context.test_case.test_name(), statistics, reports))
func set_debug_mode(debug_mode :bool = false) -> void:
super.set_debug_mode(debug_mode)
_stage_before.set_debug_mode(debug_mode)
_stage_after.set_debug_mode(debug_mode)
_stage_test.set_debug_mode(debug_mode)
func fire_test_skipped(context: GdUnitExecutionContext) -> void:
var test_case := context.test_case
var statistics := {
GdUnitEvent.ORPHAN_NODES: 0,
GdUnitEvent.ELAPSED_TIME: 0,
GdUnitEvent.WARNINGS: false,
GdUnitEvent.ERRORS: false,
GdUnitEvent.ERROR_COUNT: 0,
GdUnitEvent.FAILED: false,
GdUnitEvent.FAILED_COUNT: 0,
GdUnitEvent.SKIPPED: true,
GdUnitEvent.SKIPPED_COUNT: 1,
}
var report := GdUnitReport.new() \
.create(GdUnitReport.SKIPPED, test_case.line_number(), GdAssertMessages.test_skipped(test_case.skip_info()))
fire_event(GdUnitEvent.new().test_after(test_case.id(), test_case.test_name(), statistics, [report]))
@@ -0,0 +1,55 @@
## The fuzzed test case execution stage.[br]
class_name GdUnitTestCaseFuzzedTestStage
extends IGdUnitExecutionStage
var _expression_runner := GdUnitExpressionRunner.new()
## Executes a test case with given fuzzers 'test_<name>(<fuzzer>)' iterative.[br]
## It executes synchronized following stages[br]
## -> test_case() [br]
func _execute(context :GdUnitExecutionContext) -> void:
var test_suite := context.test_suite
var test_case := context.test_case
var fuzzers := create_fuzzers(test_suite, test_case)
# guard on fuzzers
for fuzzer in fuzzers:
@warning_ignore("return_value_discarded")
GdUnitMemoryObserver.guard_instance(fuzzer)
for iteration in test_case.iterations():
@warning_ignore("redundant_await")
await test_suite.before_test()
await test_case.execute(fuzzers, iteration)
@warning_ignore("redundant_await")
await test_suite.after_test()
if test_case.is_interupted():
break
# interrupt at first failure
var reports := context.reports()
if not reports.is_empty():
var report :GdUnitReport = reports.pop_front()
reports.append(GdUnitReport.new() \
.create(GdUnitReport.FAILURE, report.line_number(), GdAssertMessages.fuzzer_interuped(iteration, report.message())))
break
await context.gc(GdUnitExecutionContext.GC_ORPHANS_CHECK.TEST_CASE)
# unguard on fuzzers
if not test_case.is_interupted():
for fuzzer in fuzzers:
GdUnitMemoryObserver.unguard_instance(fuzzer)
func create_fuzzers(test_suite :GdUnitTestSuite, test_case :_TestCase) -> Array[Fuzzer]:
if not test_case.is_fuzzed():
return Array()
test_case.generate_seed()
var fuzzers :Array[Fuzzer] = []
for fuzzer_arg in test_case.fuzzer_arguments():
@warning_ignore("unsafe_cast")
var fuzzer := _expression_runner.to_fuzzer(test_suite.get_script() as GDScript, fuzzer_arg.plain_value() as String)
fuzzer._iteration_index = 0
fuzzer._iteration_limit = test_case.iterations()
fuzzers.append(fuzzer)
return fuzzers
@@ -0,0 +1 @@
uid://cuh0b47yf68tg
@@ -0,0 +1,53 @@
## The test case execution stage.[br]
class_name GdUnitTestCaseSingleExecutionStage
extends IGdUnitExecutionStage
var _stage_before :IGdUnitExecutionStage = GdUnitTestCaseBeforeStage.new()
var _stage_after :IGdUnitExecutionStage = GdUnitTestCaseAfterStage.new()
var _stage_test :IGdUnitExecutionStage = GdUnitTestCaseSingleTestStage.new()
func _execute(context :GdUnitExecutionContext) -> void:
fire_event(GdUnitEvent.new().test_before(context.test_case.id()))
while context.retry_execution():
var test_context := GdUnitExecutionContext.of(context)
await _stage_before.execute(test_context)
if not test_context.is_skipped():
await _stage_test.execute(GdUnitExecutionContext.of(test_context))
await _stage_after.execute(test_context)
if test_context.is_success() or test_context.is_skipped() or test_context.is_interupted():
break
context.gc()
if context.is_skipped():
fire_test_skipped(context)
else:
var reports: = context.collect_reports(true)
var statistics := context.calculate_statistics(reports)
fire_event(GdUnitEvent.new().test_after(context.test_case.id(), context.test_case.test_name(), statistics, reports))
func set_debug_mode(debug_mode :bool = false) -> void:
super.set_debug_mode(debug_mode)
_stage_before.set_debug_mode(debug_mode)
_stage_after.set_debug_mode(debug_mode)
_stage_test.set_debug_mode(debug_mode)
func fire_test_skipped(context: GdUnitExecutionContext) -> void:
var test_case := context.test_case
var statistics := {
GdUnitEvent.ORPHAN_NODES: 0,
GdUnitEvent.ELAPSED_TIME: 0,
GdUnitEvent.WARNINGS: false,
GdUnitEvent.ERRORS: false,
GdUnitEvent.ERROR_COUNT: 0,
GdUnitEvent.FAILED: false,
GdUnitEvent.FAILED_COUNT: 0,
GdUnitEvent.SKIPPED: true,
GdUnitEvent.SKIPPED_COUNT: 1,
}
var report := GdUnitReport.new() \
.create(GdUnitReport.SKIPPED, test_case.line_number(), GdAssertMessages.test_skipped(test_case.skip_info()))
fire_event(GdUnitEvent.new().test_after(test_case.id(), test_case.test_name(), statistics, [report]))
@@ -0,0 +1,11 @@
## The single test case execution stage.[br]
class_name GdUnitTestCaseSingleTestStage
extends IGdUnitExecutionStage
## Executes a single test case 'test_<name>()'.[br]
## It executes synchronized following stages[br]
## -> test_case() [br]
func _execute(context: GdUnitExecutionContext) -> void:
await context.test_case.execute()
await context.gc(GdUnitExecutionContext.GC_ORPHANS_CHECK.TEST_CASE)
@@ -0,0 +1 @@
uid://cjyply33fa4pj