Skip to content

Commit 24f51ca

Browse files
authored
GD-1293: Opt-in ProjectSettings auto save/restore with explicit API (#1297)
## Why GdUnit automatically snapshots and restores `ProjectSettings` around every suite and test case. Most tests never touch the settings, so backing them up only makes sense when a test actually changes one — the direction agreed in #1293. ## What - Adds a **Save and restore your project settings around each test execution** setting (default **on**, non-breaking) that gates the automatic per-suite and per-test snapshot. - Adds `save_project_settings()` / `restore_project_settings()` so a suite can snapshot on demand in `before()`/`before_test()` and `after()`/`after_test()` when the automatic behaviour is turned off. - Documents the setting and the two helpers. The framework keeps auto save enabled by default, so existing projects are unaffected. Closes #1293
1 parent b566f8d commit 24f51ca

8 files changed

Lines changed: 161 additions & 4 deletions

File tree

addons/gdUnit4/src/GdUnitTestSuite.gd

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,28 @@ func auto_free(obj: Variant) -> Variant:
109109
return execution_context.register_auto_free(obj)
110110

111111

112+
## Saves the current project settings into the active execution context.[br]
113+
## Useful when the "Auto Save Project Settings" setting is disabled and a test needs settings isolation on demand.[br]
114+
## [br]
115+
## Call this in [code]before()[/code]/[code]before_test()[/code] to snapshot the settings a test
116+
## is about to change, to restore see [method restore_project_settings].
117+
func save_project_settings() -> void:
118+
var execution_context := GdUnitThreadManager.get_current_context().get_execution_context()
119+
if execution_context == null:
120+
return
121+
execution_context.save_project_settings()
122+
123+
124+
## Restores the project settings previously captured by [method save_project_settings].[br]
125+
## [br]
126+
## Call this in [code]after()[/code]/[code]after_test()[/code] to undo any settings a test changed.
127+
func restore_project_settings() -> void:
128+
var execution_context := GdUnitThreadManager.get_current_context().get_execution_context()
129+
if execution_context == null:
130+
return
131+
execution_context.restore_project_settings()
132+
133+
112134
@warning_ignore("native_method_override")
113135
func add_child(node: Node, force_readable_name := false, internal := Node.INTERNAL_MODE_DISABLED) -> void:
114136
super.add_child(node, force_readable_name, internal)

addons/gdUnit4/src/core/GdUnitSettings.gd

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const TEST_DISCOVER_ENABLED = GROUP_TEST + "/test_discovery"
2222
const TEST_FLAKY_CHECK = GROUP_TEST + "/flaky_check_enable"
2323
const TEST_FLAKY_MAX_RETRIES = GROUP_TEST + "/flaky_max_retries"
2424
const TEST_RERUN_UNTIL_FAILURE_RETRIES = GROUP_TEST + "/rerun_until_failure_retries"
25+
const TEST_PROJECT_SETTINGS_AUTO_SAVE = GROUP_TEST + "/project_settings_auto_save"
2526

2627

2728
# Report Setiings
@@ -131,6 +132,7 @@ static func setup() -> void:
131132
create_property_if_need(TEST_FLAKY_CHECK, false, "Rerun tests on failure and mark them as FLAKY")
132133
create_property_if_need(TEST_FLAKY_MAX_RETRIES, 3, "Sets the number of retries for rerunning a flaky test")
133134
create_property_if_need(TEST_RERUN_UNTIL_FAILURE_RETRIES, 10, "The number of reruns until the test fails.")
135+
create_property_if_need(TEST_PROJECT_SETTINGS_AUTO_SAVE, true, "Save and restore your project settings around each test execution")
134136
# report settings
135137
create_property_if_need(REPORT_PUSH_ERRORS, false, "Report push_error() as failure")
136138
create_property_if_need(REPORT_SCRIPT_ERRORS, true, "Report script errors as failure")
@@ -320,6 +322,13 @@ static func is_test_flaky_check_enabled() -> bool:
320322
return get_setting(TEST_FLAKY_CHECK, false)
321323

322324

325+
## Returns whether the framework automatically saves and restores the project settings
326+
## around each test execution. When disabled, use [method GdUnitTestSuite.save_project_settings]
327+
## and [method GdUnitTestSuite.restore_project_settings] to snapshot settings on demand.
328+
static func is_project_settings_auto_save() -> bool:
329+
return get_setting(TEST_PROJECT_SETTINGS_AUTO_SAVE, true)
330+
331+
323332
static func is_feature_enabled(feature: String) -> bool:
324333
return get_setting(feature, false)
325334

addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseExecutionStage.gd

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ var _stage_fuzzer_test: IGdUnitExecutionStage = GdUnitTestCaseFuzzedExecutionSta
1616
func _execute(context :GdUnitExecutionContext) -> void:
1717
var test_case := context.test_case
1818

19-
context.save_project_settings()
19+
if GdUnitSettings.is_project_settings_auto_save():
20+
context.save_project_settings()
2021
context.error_monitor_start()
2122

2223
if test_case.is_fuzzed():
@@ -26,7 +27,8 @@ func _execute(context :GdUnitExecutionContext) -> void:
2627

2728
await context.gc()
2829
context.error_monitor_stop()
29-
context.restore_project_settings()
30+
if GdUnitSettings.is_project_settings_auto_save():
31+
context.restore_project_settings()
3032

3133
# finally free the test instance
3234
if is_instance_valid(context.test_case):

addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteExecutionStage.gd

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ func _execute(context :GdUnitExecutionContext) -> void:
2323
await (Engine.get_main_loop() as SceneTree).process_frame
2424
@warning_ignore("return_value_discarded")
2525
GdUnitMemoryObserver.guard_instance(context.test_suite.__awaiter)
26-
context.save_project_settings()
26+
if GdUnitSettings.is_project_settings_auto_save():
27+
context.save_project_settings()
2728
await _stage_before.execute(context)
2829
for test_case_index in context.test_suite.get_child_count():
2930
# iterate only over test cases
@@ -42,7 +43,8 @@ func _execute(context :GdUnitExecutionContext) -> void:
4243
# and replace it by a clone without function state
4344
context.test_suite = await clone_test_suite(context.test_suite)
4445
await _stage_after.execute(context)
45-
context.restore_project_settings()
46+
if GdUnitSettings.is_project_settings_auto_save():
47+
context.restore_project_settings()
4648
GdUnitMemoryObserver.unguard_instance(context.test_suite.__awaiter)
4749

4850
await (Engine.get_main_loop() as SceneTree).process_frame
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# GdUnit generated TestSuite
2+
class_name GdUnitProjectSettingsAutoSaveTest
3+
extends GdUnitTestSuite
4+
5+
6+
const __source = "res://addons/gdUnit4/src/GdUnitTestSuite.gd"
7+
8+
const IGNORE := GdUnitSettings.GdScriptWarningMode.IGNORE
9+
const ERROR := GdUnitSettings.GdScriptWarningMode.ERROR
10+
11+
const MUTATION_SUITE := "res://addons/gdUnit4/test/core/resources/testsuites/TestSuiteProjectSettingsMutation.gd"
12+
const PROBE_KEY := "application/config/name"
13+
14+
15+
#region auto save flag
16+
17+
func test_is_project_settings_auto_save_defaults_true() -> void:
18+
assert_bool(GdUnitSettings.is_project_settings_auto_save()).is_true()
19+
20+
21+
func test_is_project_settings_auto_save_reflects_disabled_flag() -> void:
22+
var original: Variant = ProjectSettings.get_setting(GdUnitSettings.TEST_PROJECT_SETTINGS_AUTO_SAVE, true)
23+
ProjectSettings.set_setting(GdUnitSettings.TEST_PROJECT_SETTINGS_AUTO_SAVE, false)
24+
assert_bool(GdUnitSettings.is_project_settings_auto_save()).is_false()
25+
# restore the flag so the change never leaves this test
26+
ProjectSettings.set_setting(GdUnitSettings.TEST_PROJECT_SETTINGS_AUTO_SAVE, original)
27+
28+
#endregion
29+
30+
#region explicit save / restore
31+
32+
func test_save_restore_isolates_setting_change() -> void:
33+
var key := GdUnitSettings.GDSCRIPT_WARNINGS_INFERRED_DECLARATION
34+
var original: Variant = ProjectSettings.get_setting(key, IGNORE)
35+
# snapshot, mutate, restore — the mutation must not survive restore
36+
save_project_settings()
37+
ProjectSettings.set_setting(key, ERROR)
38+
restore_project_settings()
39+
assert_that(ProjectSettings.get_setting(key)).is_equal(original)
40+
41+
#endregion
42+
43+
#region execution stage gating
44+
45+
# Runs a suite through the real executor in an isolated thread context with a chosen
46+
# auto-save flag, so the execution stages decide whether to snapshot the settings.
47+
func run_isolated(tests: Array[GdUnitTestCase], auto_save: bool) -> void:
48+
await GdUnitThreadManager.run("auto_save_probe_%d" % randi(), func() -> void:
49+
var previous: Variant = ProjectSettings.get_setting(GdUnitSettings.TEST_PROJECT_SETTINGS_AUTO_SAVE, true)
50+
ProjectSettings.set_setting(GdUnitSettings.TEST_PROJECT_SETTINGS_AUTO_SAVE, auto_save)
51+
var executor := GdUnitTestSuiteExecutor.new(true)
52+
await get_tree().process_frame
53+
await executor.run_and_wait(tests)
54+
ProjectSettings.set_setting(GdUnitSettings.TEST_PROJECT_SETTINGS_AUTO_SAVE, previous)
55+
)
56+
57+
58+
func test_execution_stage_snapshot_is_gated_by_flag() -> void:
59+
var loaded := GdUnitTestResourceLoader.load_tests(MUTATION_SUITE)
60+
var tests: Array[GdUnitTestCase] = Array(loaded.values(), TYPE_OBJECT, "RefCounted", GdUnitTestCase)
61+
var original: Variant = ProjectSettings.get_setting(PROBE_KEY)
62+
63+
# auto save on: the stage snapshots around the test, so the mutation is rolled back
64+
await run_isolated(tests, true)
65+
assert_str(str(ProjectSettings.get_setting(PROBE_KEY))).is_equal(str(original))
66+
67+
# auto save off: the stage skips the snapshot, so the mutation leaks past the run
68+
await run_isolated(tests, false)
69+
assert_str(str(ProjectSettings.get_setting(PROBE_KEY))).is_equal("__auto_save_probe__")
70+
71+
# clean up the mutation that leaked from the off run
72+
ProjectSettings.set_setting(PROBE_KEY, original)
73+
74+
#endregion
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Inner suite for GdUnitProjectSettingsAutoSaveTest.
2+
# A single test mutates an existing project setting. Whether the change survives the run
3+
# tells us whether the execution stages snapshotted around it:
4+
# auto save on -> the stage restores the setting, the mutation is gone after the run
5+
# auto save off -> the stage skips the snapshot, the mutation leaks past the run
6+
extends GdUnitTestSuite
7+
8+
func test_mutates_project_setting() -> void:
9+
ProjectSettings.set_setting("application/config/name", "__auto_save_probe__")
10+
assert_str(str(ProjectSettings.get_setting("application/config/name"))).is_equal("__auto_save_probe__")

documentation/doc/_advanced_testing/tools.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,38 @@ func print_obj_usage(name :String) :
134134
| UNPARENTED | [Deleted Object] | [Deleted Object] | [Deleted Object] |
135135
| PREDELETE | [Deleted Object] | [Deleted Object] | [Deleted Object] |
136136

137+
## save_project_settings() / restore_project_settings()
138+
139+
Helpers to snapshot and restore the in-memory [`ProjectSettings`](https://docs.godotengine.org/en/stable/classes/class_projectsettings.html)
140+
around a test that changes them, so the change does not leak into later tests.
141+
142+
By default GdUnit automatically saves and restores your project settings around every test execution
143+
(the **Auto Save Project Settings** setting). If you turn that off — for example to save time in a suite
144+
where almost no test touches the settings — you can snapshot on demand in just the tests that need it:
145+
call **save_project_settings()** in `before()`/`before_test()` and **restore_project_settings()** in
146+
`after()`/`after_test()`.
147+
148+
```gd
149+
func save_project_settings() -> void:
150+
func restore_project_settings() -> void:
151+
```
152+
153+
Here's a small example:
154+
155+
```gd
156+
func before_test() -> void:
157+
save_project_settings()
158+
159+
func after_test() -> void:
160+
restore_project_settings()
161+
162+
func test_changes_a_setting() -> void:
163+
ProjectSettings.set_setting("application/config/custom", true)
164+
# the change is rolled back in after_test(), so later tests see the original value
165+
```
166+
167+
---
168+
137169
## collect_orphan_node_details()
138170

139171
A helper to itemize the orphan nodes detected in the current test, reporting each one's class, instance ID, and the exact

documentation/doc/_first_steps/settings.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ To access these settings, simply press the 'tools' button located in the GdUnit
3636
This setting configures the maximum number of times a test is rerun when using **Run Tests Until Fail** from the Inspector Context Menu.
3737
Once the limit is reached or a failure occurs, the run stops. The default value is **10**.
3838

39+
* **Auto Save Project Settings**<br>
40+
When enabled (the default), GdUnit automatically saves your project settings before each test execution and restores them afterwards,
41+
so a test that changes a setting cannot leak that change into later tests. Disable it to skip this snapshot when your suites rarely
42+
touch the project settings, and instead snapshot on demand with `save_project_settings()` / `restore_project_settings()`
43+
(see [Tools and Helpers]({{site.baseurl}}/advanced_testing/tools/)) in only the tests that need it.
44+
3945
* **Test Discovery**<br>
4046
This setting configures the auto-discovery of tests. If enabled, it will scan the configured Test Root Folder for available tests at startup.
4147
Directories containing a `.gdignore` file are excluded from test discovery,

0 commit comments

Comments
 (0)