Skip to main content
HRTK is a runtime testing framework purpose-built for Hytale server plugins. Unlike traditional test frameworks that run before deployment, HRTK executes tests inside a live server where the ECS, worlds, and plugin systems are fully operational. This page explains the key architectural decisions that make that possible.

Two-Artifact Design

HRTK ships as two separate JARs with distinct roles:

hrtk-api

Compile-only dependency. Contains annotations (@HytaleTest, @HytaleSuite, etc.), assertion classes (HytaleAssert, EcsAssert, WorldAssert), context interfaces (TestContext, EcsTestContext), and mock types. Your mod compiles against this JAR, but it is never loaded at runtime as a standalone plugin.

hrtk-server

Server plugin. Loaded by the Hytale server as a regular JavaPlugin. Contains the discovery engine, test runner, context implementations, isolation managers, the /hrtk command tree, and the result exporter. This is the only JAR that needs to be in your server’s plugins/ folder.
This separation means your mod’s production JAR carries zero runtime overhead from HRTK. The API classes are erased at compile time; the annotations survive in the bytecode for the server plugin to discover.

Discovery via JAR Scanning

When the server starts — or when you run /hrtk scan — the TestDiscoveryEngine iterates every loaded JavaPlugin, opens its JAR file, and scans every .class entry for HRTK annotations.
TestDiscoveryEngine.scanAll()
  -> PluginManager.get().getPlugins()
    -> For each JavaPlugin:
       -> Open JAR via plugin.getFile()
       -> Walk .class entries (skip inner classes)
       -> Load class via plugin's ClassLoader
       -> Check for @HytaleTest, @EcsTest, @WorldTest, @Benchmark, etc.
       -> Build TestClassInfo + TestMethodInfo
       -> Register in PluginTestRegistry
Inner classes (entries containing $) are skipped during scanning. If you need helper classes for your tests, use top-level or static nested classes annotated with @HytaleSuite.
The registry groups discovered tests by plugin name, then by suite. Each TestClassInfo captures lifecycle hooks (@BeforeAll, @AfterAll, @BeforeEach, @AfterEach), isolation strategy, class-level tags, and the ordered list of test methods.

Command-Triggered Execution

Tests never run automatically. They are triggered through the /hrtk command tree:
CommandPurpose
/hrtk runRun all tests (or filter by plugin/suite/tag)
/hrtk benchRun benchmarks only
/hrtk scanRe-discover tests from loaded plugins
/hrtk listList all discovered tests
/hrtk resultsShow results from the last run
/hrtk exportExport results to JSON
/hrtk watchAuto-rerun tests when a plugin reloads
See Commands for the full reference.

Threading Model

HRTK uses two execution paths depending on the test type:
Tests that do not require a world context (pure logic, codec, basic assertions) are submitted to CompletableFuture.runAsync() and awaited with the configured timeout. They execute on the common ForkJoinPool, keeping the server’s main threads free.
Tests that interact with the ECS store or world state are scheduled onto the world thread via world.execute(). This ensures all ECS operations happen on the correct thread, respecting Hytale’s single-threaded-per-world model. The test runner awaits completion via a CompletableFuture with the configured timeout.
Because world-bound tests run on the world thread, a test that blocks indefinitely would stall that world’s tick loop. The timeout mechanism (default 30 seconds) prevents this — if a test does not complete in time, it is marked TIMED_OUT and the runner moves on.

Crash Protection

A core design goal of HRTK is that a test must never crash the server. The test executor wraps every invocation in a catch (Throwable) block — not just Exception, but Throwable. This catches:
  • AssertionFailedException — test failures, reported as FAILED
  • RuntimeException, NullPointerException, etc. — unexpected errors, reported as ERRORED
  • StackOverflowError, OutOfMemoryError — JVM-level errors, reported as ERRORED
try {
    methodInfo.getMethod().invoke(suiteInstance, params);
} catch (Throwable t) {
    // A test must NEVER crash the server.
    Throwable cause = unwrapCause(t);
    if (cause instanceof AssertionFailedException) {
        return TestResult(... TestStatus.FAILED ...);
    } else {
        return TestResult(... TestStatus.ERRORED ...);
    }
}
Even if your test accidentally triggers an infinite recursion or a native memory error, the server keeps running. The test is simply marked as errored and the runner continues to the next test.

What Happens at a Glance

1

Discovery

On server start, TestDiscoveryEngine scans all plugin JARs for HRTK annotations and builds the PluginTestRegistry.
2

Trigger

An operator runs /hrtk run (with optional filters for plugin, suite, tag, or method).
3

Execution

The TestRunner iterates matching suites. For each suite, SuiteExecutor instantiates the test class, applies isolation, runs lifecycle hooks, and delegates to TestExecutor for each method.
4

Results

Results are collected by ResultCollector, formatted by ResultFormatter for console output, and optionally exported to JSON via ResultExporter.
Next, learn how to write your first test.