Hello. In the previous lesson, you defined EngineEvent as the engine’s typed, presentation-neutral output vocabulary. That gives the project a small but real public protocol: callers submit EngineCommand values, and the engine eventually reports EngineEvent values.
This final lesson in the architecture module adds two deliberately small tests around that structure:
- a pure Rust smoke test that uses
vn_engineexactly as an external crate would, with no Godot process involved; - a GDExtension loading smoke test that starts Godot 4.7.1 headlessly, loads a Rust-defined native class, and confirms its lifecycle callback ran—without any GDScript.
These tests do not prove the visual novel works. They prove that the two foundational boundaries are alive and independently diagnosable before the narrative runtime becomes more complex.
Smoke tests should answer narrow operational questions
A smoke test is a fast check for a catastrophic integration failure: “Can the essential thing start at all?”
For this workspace, keep the two checks separate.
| Check | Question it answers | Intentionally does not test |
|---|---|---|
vn_engine smoke test | Can another Rust crate compile against and use the public engine protocol? | Godot loading, scene wiring, dialogue behavior |
| Extension smoke test | Can the pinned Godot executable load the compiled Rust extension and instantiate a Rust GodotClass? | UI rendering, signals, game logic, save data |
That separation is useful when a build breaks:
- If
vn_enginefails, the cause is within the pure Rust engine API or its dependencies. - If the engine test passes but the extension smoke test fails, investigate the dynamic library,
.gdextensionconfiguration, Godot executable, native target, or class registration. - If both pass, later failures are more likely to be in runtime behavior or presentation wiring—not basic process startup.
Keep the engine test independent: it must not build vn_godot, invoke Godot, or inspect Godot project files. Likewise, the extension test should use a tiny native probe rather than starting a full narrative scenario.
Test Organization - The Rust Programming Language
Read the relevant integration-test guidance from The Rust Programming Language. It explains why tests in a crate's tests directory exercise only the public API, which is exactly the perspective needed for the pure-engine boundary.
In the “Integration Tests” section, especially the “The tests Directory” subsection, read from the integration-test rationale. Then read “Submodules in Integration Tests” through its explanation of tests/common/mod.rs; it will become useful once engine fixtures need shared setup. Focus on the distinction between an in-source unit test and an external consumer of a library crate.
1. Smoke-test the pure Rust public boundary
At this point in the course, the engine protocol is intentionally small. The full deterministic GameState and command handler arrive in Module 2, so do not invent an artificial runtime merely to make this test look more substantial.
Instead, add an integration test that confirms the public command-and-event protocol can be imported, constructed, and pattern-matched from outside vn_engine.
Create:
crates/vn_engine/tests/protocol_smoke.rs
use vn_engine::{EngineCommand, EngineEvent};
#[test]
fn public_engine_protocol_is_usable_without_the_godot_adapter() {
let command = EngineCommand::StartNewGame;
let event = EngineEvent::GameStarted;
assert!(matches!(command, EngineCommand::StartNewGame));
assert!(matches!(event, EngineEvent::GameStarted));
}
This is intentionally a public API smoke test, not a replacement for the more focused event_api.rs test from the previous lesson. Together, they establish two useful properties:
event_api.rsverifies that a detailed rejection event preserves typed context.protocol_smoke.rsverifies that the basic engine protocol remains usable from a separate test crate.
The location matters. A test inside src/ can access private implementation details. A file under crates/vn_engine/tests/ is compiled as a separate crate, so it can only use types re-exported by vn_engine::lib.rs.
Run it independently:
cargo test -p vn_engine --test protocol_smoke --no-default-features --locked
The --no-default-features flag expresses an architectural intention: the core engine should remain buildable as a lean Rust library, without quietly acquiring adapter or platform behavior through a default feature.
This test does not, by itself, prove that nobody added a godot dependency to vn_engine. The dependency direction established earlier remains the primary guard:
vn_content depends on vn_engine
vn_godot depends on vn_engine and vn_content
Godot project loads vn_godot
A review of crates/vn_engine/Cargo.toml should continue to show no godot dependency. The test complements that rule by making the engine’s public consumer boundary explicit and continuously executable.
2. Make the GDExtension boundary observable
A headless Godot launch that merely exits successfully is not sufficient evidence. Godot can sometimes report a loading or scene error while the process itself still exits normally.
The extension test therefore needs an unambiguous observation:
Godot loaded the extension, recognized a Rust native class in a
.tscnscene, instantiated that class, and called its_readylifecycle method.
Use a tiny class whose sole job is to emit a recognizable marker. Keep it separate from EngineHost.
Testing through EngineHost now would couple the loading test to future requirements such as acquired Control nodes, localization initialization, or a loaded game state. A failure would become ambiguous. The probe tests exactly one boundary.
First, add a feature in crates/vn_godot/Cargo.toml:
[features]
smoke-test = []
Then add the class at:
crates/vn_godot/src/extension_smoke_probe.rs
use godot::prelude::*;
#[derive(GodotClass)]
#[class(base = Node)]
pub struct ExtensionSmokeProbe {
#[base]
#[allow(dead_code)]
base: Base<Node>,
}
#[godot_api]
impl INode for ExtensionSmokeProbe {
fn ready(&mut self) {
godot_print!("VN_EXTENSION_SMOKE_READY");
}
}
Ensure the module is compiled only for the smoke-test build:
// crates/vn_godot/src/lib.rs
#[cfg(feature = "smoke-test")]
mod extension_smoke_probe;
Your existing #[gdextension] entry-point implementation remains unchanged. The important point is that, with smoke-test enabled, ExtensionSmokeProbe is linked into the extension and registered alongside its other Rust Godot classes.
Why use a compile-time feature?
The probe is test infrastructure, not a presentation feature. Compiling it only under smoke-test means:
- the normal extension remains focused on the shipped engine bridge;
- a test build gets a stable, minimal class to instantiate;
- the test does not require a hidden GDScript test adapter;
- web-specific builds do not accidentally include a desktop test seam.
3. Add a script-free Godot test scene
Create the scene file:
godot/tests/extension_smoke.tscn
[gd_scene format=3]
[node name="ExtensionSmokeProbe" type="ExtensionSmokeProbe"]
There is deliberately no Script resource, no attached .gd file, and no scene hierarchy beyond the Rust-defined root node.
When Godot opens this scene, it must perform several actions successfully:
- Read the project’s
.gdextensionfile. - Select the native dynamic library for the current operating system and architecture.
- Find the Rust GDExtension entry symbol.
- Register the
ExtensionSmokeProbeclass. - Parse the scene’s custom root-node type.
- Instantiate that type.
- Invoke
ready, producingVN_EXTENSION_SMOKE_READY.
If any earlier stage fails, the marker will not appear.
Your existing .gdextension file should continue to map the appropriate debug library to a path within the Godot project, such as:
res://bin/libvn_godot.so
The precise filename differs by platform:
| Platform | Typical debug artifact |
|---|---|
| Linux | libvn_godot.so |
| macOS | libvn_godot.dylib |
| Windows | vn_godot.dll |
The filename must match both the Cargo library name and the platform entry in the .gdextension configuration. Do not manually change the extension configuration for this test; build and copy the artifact to the location it already declares.
4. Run Godot headlessly and require the marker
For a Linux-based development machine or CI runner, add:
scripts/run_extension_smoke.sh
#!/usr/bin/env bash
set -euo pipefail
: "${GODOT_BIN:?Set GODOT_BIN to the pinned Godot 4.7.1 executable}"
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
project_dir="$repo_root/godot"
artifact="$repo_root/target/debug/libvn_godot.so"
destination="$project_dir/bin/libvn_godot.so"
cargo build \
--manifest-path "$repo_root/Cargo.toml" \
-p vn_godot \
--features smoke-test \
--locked
mkdir -p "$(dirname "$destination")"
cp "$artifact" "$destination"
log_file="$(mktemp)"
trap 'rm -f "$log_file"' EXIT
"$GODOT_BIN" \
--headless \
--path "$project_dir" \
res://tests/extension_smoke.tscn \
--quit-after 10 \
2>&1 | tee "$log_file"
grep -F "VN_EXTENSION_SMOKE_READY" "$log_file" >/dev/null
Make the runner executable:
chmod +x scripts/run_extension_smoke.sh
Run it with the Godot executable selected by your pinned tool configuration:
GODOT_BIN=/absolute/path/to/godot-4.7.1 scripts/run_extension_smoke.sh
A valid run has two required conditions:
- the Godot process exits successfully;
- output contains
VN_EXTENSION_SMOKE_READY.
set -o pipefail is important here. Without it, tee could succeed even if Godot itself failed, masking the actual error.
For Windows CI or local development, implement the same sequence in a PowerShell runner rather than attempting to run this Bash script through an ad hoc compatibility layer:
- build
vn_godotwith--features smoke-test; - copy
vn_godot.dllinto the path declared by the Windows.gdextensionlibrary entry; - invoke the pinned Godot executable with
--headless,--path, the smoke scene, and--quit-after 10; - fail unless process success and the marker are both present.
The behavioral contract is portable even though native filenames and copy commands are platform-specific.
Read failures as boundary diagnostics
Use the narrow scope of the test to shorten debugging.
| Observed result | Most likely area to inspect |
|---|---|
cargo test -p vn_engine fails | Public engine API, engine dependencies, Rust compilation |
cargo build -p vn_godot --features smoke-test fails | gdext API usage, Rust extension crate configuration, feature-gated module |
| Godot cannot find the library | .gdextension library path, copied filename, target architecture |
| Godot reports an entry-symbol error | GDExtension entry-point macro or configured symbol |
Scene reports unknown type ExtensionSmokeProbe | Probe module was not compiled, feature was omitted, or class registration failed |
| Godot exits but marker is absent | The scene did not instantiate the probe or _ready did not execute |
| Marker appears | The extension loading boundary is healthy; investigate later UI or game failures elsewhere |
Do not turn this into an all-purpose integration test. In particular, it should not:
- load authored dialogue content;
- initialize saves;
- render a choice button;
- emit a Godot UI signal;
- rely on
EngineHostnode lookup; - test browser persistence.
Those concerns will receive scenario and Godot integration tests once the systems they depend upon exist. A smoke test earns its value by staying fast and unambiguous.
Key takeaways
You now have two independently runnable checks around the project’s most important early boundary:
crates/vn_engine/tests/protocol_smoke.rstreatsvn_engineas an external pure Rust library and confirms its public protocol is available without the Godot adapter.- The feature-gated
ExtensionSmokeProbeand script-free.tscnscene prove that Godot 4.7.1 can load the compiled Rust GDExtension, register a native class, instantiate it, and execute Rust lifecycle code. - The extension test requires both a successful Godot process and a known output marker, avoiding false confidence from an exit code alone.
- Keeping the tests separate makes failures actionable rather than merely detectable.
Next, the course moves into the deterministic narrative runtime. You will begin by defining stable typed identifiers for dialogue nodes, choices, characters, locations, quests, and items—the references that will replace temporary indices and raw strings throughout the engine.
Can't find a good explanation? Sign up and we'll make it for you
Sign up