Skip to content

Commit 865d6ab

Browse files
committed
Migrate test runner from GDScript to Rust
More uniform codebase, static typing, faster test execution.
1 parent 92b1e23 commit 865d6ab

File tree

7 files changed

+268
-241
lines changed

7 files changed

+268
-241
lines changed

godot-macros/src/itest.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ pub fn transform(input: TokenStream) -> Result<TokenStream, Error> {
5252
#body
5353
}
5454

55-
::godot::sys::plugin_add!(__GODOT_ITEST in crate; crate::TestCase {
55+
::godot::sys::plugin_add!(__GODOT_ITEST in crate; crate::RustTestCase {
5656
name: #test_name_str,
5757
skipped: false,
5858
file: std::file!(),

itest/godot/TestRunner.gd

Lines changed: 17 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -5,74 +5,43 @@
55
extends Node
66

77
func _ready():
8-
var test_suites: Array = [
9-
IntegrationTests.new(),
8+
var rust_runner = IntegrationTests.new()
9+
10+
var gdscript_suites: Array = [
1011
preload("res://ManualFfiTests.gd").new(),
1112
preload("res://gen/GenFfiTests.gd").new(),
1213
]
1314

14-
var tests: Array[_Test] = []
15-
for suite in test_suites:
15+
var gdscript_tests: Array = []
16+
for suite in gdscript_suites:
1617
for method in suite.get_method_list():
1718
var method_name: String = method.name
1819
if method_name.begins_with("test_"):
19-
tests.push_back(_Test.new(suite, method_name))
20-
21-
print()
22-
print_rich(" [b][color=green]Running[/color][/b] test project %s" % [
23-
ProjectSettings.get_setting("application/config/name", ""),
24-
])
25-
print()
26-
27-
var stats: TestStats = TestStats.new()
28-
stats.start_stopwatch()
29-
for test in tests:
30-
printraw(" -- %s ... " % [test.test_name])
31-
var ok: bool = test.run()
32-
print_rich("[color=green]ok[/color]" if ok else "[color=red]FAILED[/color]")
33-
stats.add(ok)
34-
stats.stop_stopwatch()
35-
36-
print()
37-
print_rich("test result: %s. %d passed; %d failed; finished in %.2fs" % [
38-
"[color=green]ok[/color]" if stats.all_passed() else "[color=red]FAILED[/color]",
39-
stats.num_ok,
40-
stats.num_failed,
41-
stats.runtime_seconds(),
42-
])
43-
print()
44-
45-
for suite in test_suites:
46-
suite.free()
47-
48-
var exit_code: int = 0 if stats.all_passed() else 1
20+
gdscript_tests.push_back(GDScriptTestCase.new(suite, method_name))
21+
22+
var success: bool = rust_runner.run_all_tests(gdscript_tests, gdscript_suites.size())
23+
24+
var exit_code: int = 0 if success else 1
4925
get_tree().quit(exit_code)
5026

51-
class _Test:
27+
28+
class GDScriptTestCase:
5229
var suite: Object
5330
var method_name: String
54-
var test_name: String
31+
var suite_name: String
5532

5633
func _init(suite: Object, method_name: String):
5734
self.suite = suite
5835
self.method_name = method_name
59-
self.test_name = "%s::%s" % [_suite_name(suite), method_name]
60-
36+
self.suite_name = _suite_name(suite)
37+
6138
func run():
6239
# This is a no-op if the suite doesn't have this property.
6340
suite.set("_assertion_failed", false)
6441
var result = suite.call(method_name)
65-
var ok: bool = (
66-
(result == true || result == null)
67-
&& !suite.get("_assertion_failed")
68-
)
42+
var ok: bool = (result == true || result == null) && !suite.get("_assertion_failed")
6943
return ok
7044

7145
static func _suite_name(suite: Object) -> String:
7246
var script: GDScript = suite.get_script()
73-
if script:
74-
# Test suite written in GDScript.
75-
return script.resource_path.get_file().get_basename()
76-
else:
77-
# Test suite written in Rust.
78-
return suite.get_class()
47+
return str(script.resource_path.get_file().get_basename(), ".gd")

itest/godot/TestStats.gd

Lines changed: 0 additions & 33 deletions
This file was deleted.

itest/godot/TestSuite.gd

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,30 @@
33
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
44

55
class_name TestSuite
6-
extends Node
6+
extends RefCounted
77

88
var _assertion_failed: bool = false
99

1010
## Asserts that `what` is `true`, but does not abort the test. Returns `what` so you can return
1111
## early from the test function if the assertion failed.
1212
func assert_that(what: bool, message: String = "") -> bool:
13-
if !what:
14-
_assertion_failed = true
15-
if message:
16-
print("assertion failed: %s" % message)
17-
else:
18-
print("assertion failed")
19-
return what
13+
if what:
14+
return true
15+
16+
_assertion_failed = true
17+
if message:
18+
print("assertion failed: %s" % message)
19+
else:
20+
print("assertion failed")
21+
return false
2022

2123
func assert_eq(left, right, message: String = "") -> bool:
22-
if left != right:
23-
_assertion_failed = true
24-
if message:
25-
print("assertion failed: %s\n left: %s\n right: %s" % [message, left, right])
26-
else:
27-
print("assertion failed: `(left == right)`\n left: %s\n right: %s" % [left, right])
28-
return false
29-
return true
24+
if left == right:
25+
return true
26+
27+
_assertion_failed = true
28+
if message:
29+
print("assertion failed: %s\n left: %s\n right: %s" % [message, left, right])
30+
else:
31+
print("assertion failed: `(left == right)`\n left: %s\n right: %s" % [left, right])
32+
return false

itest/rust/src/lib.rs

Lines changed: 32 additions & 136 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,8 @@
44
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
55
*/
66

7-
use godot::bind::{godot_api, GodotClass};
87
use godot::init::{gdextension, ExtensionLibrary};
98
use godot::sys;
10-
use godot::test::itest;
11-
use std::collections::HashSet;
12-
use std::panic;
139

1410
mod array_test;
1511
mod base_test;
@@ -30,134 +26,16 @@ mod utilities_test;
3026
mod variant_test;
3127
mod virtual_methods_test;
3228

33-
// ----------------------------------------------------------------------------------------------------------------------------------------------
34-
// Entry point + main runner class
35-
36-
#[gdextension(entry_point=itest_init)]
37-
unsafe impl ExtensionLibrary for IntegrationTests {}
38-
39-
#[derive(GodotClass, Debug)]
40-
#[class(base=Node, init)]
41-
struct IntegrationTests {
42-
tests_run: i64,
43-
tests_passed: i64,
44-
tests_skipped: i64,
45-
}
46-
47-
#[godot_api]
48-
impl IntegrationTests {
49-
// TODO could return a Stats object with properties in the future
50-
#[func]
51-
fn test_all(&mut self) {
52-
println!("Run Godot integration tests...");
53-
self.run_tests();
54-
}
55-
56-
#[func]
57-
fn num_run(&self) -> i64 {
58-
self.tests_run
59-
}
60-
61-
#[func]
62-
fn num_passed(&self) -> i64 {
63-
self.tests_passed
64-
}
65-
66-
#[func]
67-
fn num_skipped(&self) -> i64 {
68-
self.tests_skipped
69-
}
70-
71-
fn run_tests(&mut self) {
72-
let mut tests: Vec<TestCase> = vec![];
73-
74-
let mut all_files = HashSet::new();
75-
sys::plugin_foreach!(__GODOT_ITEST; |test: &TestCase| {
76-
all_files.insert(test.file);
77-
tests.push(*test);
78-
});
79-
80-
println!(
81-
"Rust: found {} tests in {} files.",
82-
tests.len(),
83-
all_files.len()
84-
);
85-
86-
let mut last_file = None;
87-
for test in tests {
88-
let outcome = run_test(&test);
89-
90-
self.tests_run += 1;
91-
match outcome {
92-
TestOutcome::Passed => self.tests_passed += 1,
93-
TestOutcome::Failed => {}
94-
TestOutcome::Skipped => self.tests_skipped += 1,
95-
}
96-
97-
print_test(&test, outcome, &mut last_file);
98-
}
99-
}
100-
}
29+
mod runner;
10130

10231
// ----------------------------------------------------------------------------------------------------------------------------------------------
103-
// Implementation
104-
105-
// Registers all the tests
106-
sys::plugin_registry!(__GODOT_ITEST: TestCase);
107-
108-
// For more colors, see https://stackoverflow.com/a/54062826
109-
// To experiment with colors, add `rand` dependency and add following code above.
110-
// use rand::seq::SliceRandom;
111-
// let outcome = [TestOutcome::Passed, TestOutcome::Failed, TestOutcome::Skipped];
112-
// let outcome = outcome.choose(&mut rand::thread_rng()).unwrap();
113-
const FMT_GREEN: &str = "\x1b[32m";
114-
const FMT_YELLOW: &str = "\x1b[33m";
115-
const FMT_RED: &str = "\x1b[31m";
116-
const FMT_END: &str = "\x1b[0m";
117-
118-
fn run_test(test: &TestCase) -> TestOutcome {
119-
if test.skipped {
120-
return TestOutcome::Skipped;
121-
}
122-
123-
// Explicit type to prevent tests from returning a value
124-
let success: Option<()> =
125-
godot::private::handle_panic(|| format!(" !! Test {} failed", test.name), test.function);
126-
127-
if success.is_some() {
128-
TestOutcome::Passed
129-
} else {
130-
TestOutcome::Failed
131-
}
132-
}
32+
// API for test cases
13333

134-
/// Prints a test name and its outcome.
135-
///
136-
/// Note that this is run after a test run, so stdout/stderr output during the test will be printed before.
137-
fn print_test(test: &TestCase, outcome: TestOutcome, last_file: &mut Option<&'static str>) {
138-
// Check if we need to open a new category for a file
139-
let print_file = last_file.map_or(true, |last_file| last_file != test.file);
140-
if print_file {
141-
let sep_pos = test.file.rfind(&['/', '\\']).unwrap_or(0);
142-
println!("\n {}:", &test.file[sep_pos + 1..]);
143-
}
144-
145-
// Do not use print_rich() from Godot, because it's very slow and significantly delays test execution.
146-
let test_name = test.name;
147-
let end = FMT_END;
148-
let (col, outcome) = match outcome {
149-
TestOutcome::Passed => (FMT_GREEN, "ok"),
150-
TestOutcome::Failed => (FMT_RED, "FAILED"),
151-
TestOutcome::Skipped => (FMT_YELLOW, "ignored"),
152-
};
153-
154-
println!(" -- {test_name} ... {col}{outcome}{end}");
155-
156-
// State update for file-category-print
157-
*last_file = Some(test.file);
158-
}
34+
use godot::test::itest;
35+
36+
pub(crate) fn expect_panic(context: &str, code: impl FnOnce() + std::panic::UnwindSafe) {
37+
use std::panic;
15938

160-
pub(crate) fn expect_panic(context: &str, code: impl FnOnce() + panic::UnwindSafe) {
16139
// Exchange panic hook, to disable printing during expected panics
16240
let prev_hook = panic::take_hook();
16341
panic::set_hook(Box::new(|_panic_info| {}));
@@ -172,19 +50,37 @@ pub(crate) fn expect_panic(context: &str, code: impl FnOnce() + panic::UnwindSaf
17250
);
17351
}
17452

53+
// ----------------------------------------------------------------------------------------------------------------------------------------------
54+
// Entry point + #[itest] test registration
55+
56+
#[gdextension(entry_point=itest_init)]
57+
unsafe impl ExtensionLibrary for runner::IntegrationTests {}
58+
59+
// Registers all the `#[itest]` tests.
60+
sys::plugin_registry!(__GODOT_ITEST: RustTestCase);
61+
62+
/// Finds all `#[itest]` tests.
63+
fn collect_rust_tests() -> (Vec<RustTestCase>, usize) {
64+
let mut all_files = std::collections::HashSet::new();
65+
let mut tests: Vec<RustTestCase> = vec![];
66+
67+
sys::plugin_foreach!(__GODOT_ITEST; |test: &RustTestCase| {
68+
all_files.insert(test.file);
69+
tests.push(*test);
70+
});
71+
72+
// Sort alphabetically for deterministic run order
73+
tests.sort_by_key(|test| test.file);
74+
75+
(tests, all_files.len())
76+
}
77+
17578
#[derive(Copy, Clone)]
176-
struct TestCase {
79+
struct RustTestCase {
17780
name: &'static str,
17881
file: &'static str,
17982
skipped: bool,
18083
#[allow(dead_code)]
18184
line: u32,
18285
function: fn(),
18386
}
184-
185-
#[must_use]
186-
enum TestOutcome {
187-
Passed,
188-
Failed,
189-
Skipped,
190-
}

itest/rust/src/node_test.rs

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,8 @@
77
use crate::itest;
88
use godot::builtin::NodePath;
99
use godot::engine::{node, Node, Node3D, NodeExt};
10-
use godot::log::godot_print;
1110
use godot::obj::Share;
1211

13-
// TODO move to other test
14-
#[itest]
15-
fn node_print() {
16-
godot_print!("Test print, bool={} and int={}", true, 32);
17-
}
18-
1912
#[itest]
2013
fn node_get_node() {
2114
let mut child = Node3D::new_alloc();

0 commit comments

Comments
 (0)