Create your own
Lesson illustration

Smoke Testing the Rust Engine and Godot Extension Boundary

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:

  1. a pure Rust smoke test that uses vn_engine exactly as an external crate would, with no Godot process involved;
  2. 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.

CheckQuestion it answersIntentionally does not test
vn_engine smoke testCan another Rust crate compile against and use the public engine protocol?Godot loading, scene wiring, dialogue behavior
Extension smoke testCan 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_engine fails, 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, .gdextension configuration, 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.rs verifies that a detailed rejection event preserves typed context.
  • protocol_smoke.rs verifies 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 .tscn scene, instantiated that class, and called its _ready lifecycle 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:

  1. Read the project’s .gdextension file.
  2. Select the native dynamic library for the current operating system and architecture.
  3. Find the Rust GDExtension entry symbol.
  4. Register the ExtensionSmokeProbe class.
  5. Parse the scene’s custom root-node type.
  6. Instantiate that type.
  7. Invoke ready, producing VN_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:

PlatformTypical debug artifact
Linuxlibvn_godot.so
macOSlibvn_godot.dylib
Windowsvn_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:

  1. build vn_godot with --features smoke-test;
  2. copy vn_godot.dll into the path declared by the Windows .gdextension library entry;
  3. invoke the pinned Godot executable with --headless, --path, the smoke scene, and --quit-after 10;
  4. 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 resultMost likely area to inspect
cargo test -p vn_engine failsPublic engine API, engine dependencies, Rust compilation
cargo build -p vn_godot --features smoke-test failsgdext 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 errorGDExtension entry-point macro or configured symbol
Scene reports unknown type ExtensionSmokeProbeProbe module was not compiled, feature was omitted, or class registration failed
Godot exits but marker is absentThe scene did not instantiate the probe or _ready did not execute
Marker appearsThe 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 EngineHost node 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.rs treats vn_engine as an external pure Rust library and confirms its public protocol is available without the Godot adapter.
  • The feature-gated ExtensionSmokeProbe and script-free .tscn scene 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