# Testing

## For humans

**Read this if** you write Java units. **Skip the first two sections if** you only write JS or JSX.

### Do this

1. **Add the compile gate** to your repo as `scripts/check-units-compile.sh`. It compiles every
   Java unit against the packaged app before anything boots. Takes about two seconds.
2. **Run it in CI and as a pre-commit hook.** It is the cheapest check on this page.
3. **Assert what happened**, not that the request returned 200.

```bash
# not this
curl -s -o /dev/null -w '%{http_code}' "$HOST/app/counter/api"   # 200 proves nothing
```

```bash
# this
curl -s -X POST "$HOST/app/counter/api" -d '{"action":"add","by":1}'
curl -s "$HOST/app/counter/api" | grep -q '"count":1' && echo OK
```

### What will bite you

- **Your build output is not what production loads.** Compile against the packaged app or the
  mismatch shows up after deploy, to a visitor.
- **A 200 hides a wrong branch.** A unit can answer successfully while running the wrong code. That
  is exactly how a bug where every POST body was ignored survived.
- **The harness test does not replace the gate.** It runs in your ordinary test JVM, which has a
  different classpath. You need both.

### Then read

- [The two-second gate](#the-two-second-gate) for the script.
- [Assert effects, not status codes](#assert-effects-not-status-codes) for the pattern.

## For robots

Four patterns, each one here because it caught something real. Every rule below states the failure
it prevents, the literal error text that failure produces, a minimal example, and the command that
proves the rule holds in your own repo.

Versions: measured 2026-08-08 on Quarkus 3.15.1, JDK 21.0.2, `interlock-java-sdk` 0.1.0-SNAPSHOT,
against a packaged fast-jar. Every `javap` and `javac` command below was run, not reasoned about.
Re-run them on your own version before trusting the table in
[Every measurement needs a control arm](#every-measurement-needs-a-control-arm).

## The two-second gate

**The rule.** Compile EVERY Java unit against the PACKAGED application, with
`quarkus/transformed-bytecode.jar` first on the classpath, before anything boots. Make it the first
gate in your pre-flight, ahead of the test suite and ahead of the server.

**Why it exists.** A unit is compiled at RUNTIME, by the host JVM, against the classes that JVM
actually loaded. Quarkus builds every entity twice: `app/<app>.jar` holds the ORIGINAL bytecode and
`quarkus/transformed-bytecode.jar` holds the rewritten copy, and the JVM loads the transformed one.
A unit compiled against anything else (your repo's `build/classes`, a stale jar, a `quarkusDev`
session that writes no transformed jar at all) is compiled against a host API that is not the one it
will meet. The gate reproduces the server's classpath exactly, so the compile either succeeds for
the same reason the server will succeed, or fails now.

**The failure it prevents.** A whole unit tree broken for an entire session, hidden behind an
unrelated outage: nothing had ever compiled those units, so nothing had ever reported them. The two
error texts to search for are quoted from a real host application (Team Lakes), so `Presence` below
is that host's own entity, not a class you are expected to own:

```
error: status has protected access in Presence
```

at unit compile time (the entity has no hand-written accessor, so Quarkus privatized the field), and

```
java.lang.IllegalAccessError: tried to access protected field com.teamlakes.api.office.Presence.status
```

at runtime, on the first HTTP request after a deploy (the unit compiled against the ORIGINAL
bytecode, where the field is still public, and the JVM loaded the transformed copy). Without the
gate the second one is how you find out, in production, from a visitor.

Boot also warns once when the transformed jar is missing entirely, which is what `quarkusDev` looks
like:

```
WARN  java units: no transformed-bytecode jar found — if this is quarkusDev, units that touch host entities will not compile against their accessors; run the packaged jar for entity-touching units
```

**Minimal example.** The whole gate is one `javac` invocation. Transformed jar first, then the
original and the libraries, with `-sourcepath` pointing at the unit tree so sibling imports resolve
the way the runtime resolves them (directory is package):

```bash
javac -nowarn -d /tmp/units -cp "$APP/quarkus/transformed-bytecode.jar:$APP/app/*:$APP/lib/main/*:$APP/lib/boot/*" -sourcepath code/ $(find code/ -name '*.java')
```

**Verify it.** Save the script in the next section as `scripts/check-units-compile.sh`. That path is
the canonical one: every page in these docs invokes the gate by exactly that name, so a link, a CI
job, and a pre-commit hook all name the same file. Then:

```bash
./scripts/check-units-compile.sh
```

Green output is `==> all units compile`. Exit status is 1 on any compile error, so it fails a CI job
or a pre-flight script without further plumbing.

## The gate script

Copy this verbatim. It packages first (Gradle and Maven both no-op when nothing changed), finds the
`quarkus-app` directory, puts the transformed jar first, and compiles the whole unit tree. It runs
in about two seconds on a warm build: 13 units in 2.0s wall clock on the reference repo.

```bash
#!/usr/bin/env bash
# scripts/check-units-compile.sh — compile every Java unit the way the RUNNING SERVER will.
#
# Not the way this repo's own test classpath would: Quarkus builds every entity twice, and the
# packaged application loads the transformed copy. This puts that copy FIRST, exactly as the SDK's
# JavaRunner does, so a unit that only compiles against this repo's classes fails HERE, in about
# two seconds, instead of on the first HTTP request after a deploy.
#
# Packaging first is not optional: checking units against a stale jar reports the last build's host
# API, which is the same lie this script exists to catch. Gradle and Maven no-op when nothing
# changed. Pass --no-build only when you have just packaged by hand.
#
# Usage:  ./scripts/check-units-compile.sh [--no-build]   (the cd below makes the CWD irrelevant)
# Override: APP=<quarkus-app dir> UNITS=<unit tree> BUILD_CMD='<packaging command>'
set -euo pipefail

cd "$(git rev-parse --show-toplevel)"

UNITS="${UNITS:-code}"
APP="${APP:-}"

find_app() {
    [[ -n "$APP" ]] && return 0
    local c
    for c in */build/quarkus-app build/quarkus-app */target/quarkus-app target/quarkus-app; do
        if [[ -f "$c/quarkus/transformed-bytecode.jar" ]]; then
            APP="$c"
            return 0
        fi
    done
    return 0
}

if [[ "${1:-}" != "--no-build" ]]; then
    echo "==> packaging (needed for the transformed bytecode)"
    if [[ -n "${BUILD_CMD:-}" ]]; then
        eval "$BUILD_CMD"
    elif [[ -x ./gradlew ]]; then
        ./gradlew quarkusBuild -q
    elif [[ -x ./mvnw ]]; then
        ./mvnw -q package -DskipTests
    else
        echo "!! no ./gradlew or ./mvnw — set BUILD_CMD='...' or pass --no-build" >&2
        exit 1
    fi
fi

find_app
if [[ -z "$APP" ]]; then
    echo "!! no transformed bytecode found — package a fast-jar first (quarkusDev writes none)" >&2
    exit 1
fi

# Transformed FIRST. The JVM loads that copy, so javac must see it ahead of the original in app/.
CP="$APP/quarkus/transformed-bytecode.jar"
for j in "$APP"/app/*.jar "$APP"/lib/main/*.jar "$APP"/lib/boot/*.jar; do
    [[ -f "$j" ]] && CP="$CP:$j"
done

OUT="$(mktemp -d)"
trap 'rm -rf "$OUT"' EXIT

FILES="$(find "$UNITS" -name '*.java' -not -name '*.conflict-server' | sort)"
if [[ -z "$FILES" ]]; then
    echo "==> no Java units under $UNITS — nothing to check"
    exit 0
fi
echo "==> compiling $(echo "$FILES" | wc -l | tr -d ' ') unit(s) against $APP"

# -sourcepath is the sibling-import rule: directory is package, so javac resolves notes.NoteApi
# out of code/notes/NoteApi.java the same way JavaRunner's file manager serves it.
if javac -nowarn -d "$OUT" -cp "$CP" -sourcepath "$UNITS" $FILES; then
    echo "==> all units compile"
else
    echo "!! units do not compile against the packaged application" >&2
    exit 1
fi
```

Three details are load-bearing, and each of them was a bug before it was a line of script:

1. **`transformed-bytecode.jar` comes first.** Put the launcher jar or `app/` first and javac
   silently resolves the untransformed classes instead, because javac honours a jar manifest's
   `Class-Path` and `quarkus-run.jar` lists `app/`.
2. **`--no-build` is opt-in, not the default.** Checking units against a stale jar reports the last
   build's host API, which is exactly the lie the gate exists to catch.
3. **`-not -name '*.conflict-server'`** keeps a sync conflict file from failing the build with a
   phantom error. Conflicts are covered in [operating](/operating).

Both outcomes, verified on a real host application (Team Lakes), whose own paths and classes the
output below names:

```bash
./scripts/check-units-compile.sh --no-build
```

```
==> compiling 13 unit(s) against teamlake-api/build/quarkus-app
==> all units compile
```

and with one broken unit in the tree:

```
==> compiling 1 unit(s) against /path/to/quarkus-app
code/probe/Broken.java:6: error: status has protected access in Presence
    public static String statusOf(Presence p) { return p.status; }
                                                        ^
1 error
!! units do not compile against the packaged application
```

## The unit-harness test

**The rule.** Run your real units through the real pipeline inside the host's ordinary test suite:
`DirectoryCodeSource` pointed at the git checkout, `Engine.unitClass(id, env)` to compile a unit, and
reflection to exercise it. No server, no key, no sync, no DB.

**Why it exists.** `unitClass` compiles through the same path a request takes: sibling resolution,
package validation, the host compile anchors, the transformed classpath. So a harness test is two
things at once, a behaviour test for the unit's logic and a compile gate for the unit tree, and it
runs in a couple of seconds inside a suite people already run.

**The failure it prevents.** A broken unit that surfaces only as a boot WARN nobody reads in time,
or as a 500 for the first visitor. When a real host moved logic into units, the harness caught a
shipped bug on its first run: a unit called a package-private method on a sibling and could never
have compiled on the host, because until that moment nothing had ever compiled it. The errors the
harness turns into red tests are:

```
no code 'notes/NoteApi' in env 'dev'
```

(`CodeNotFoundException`: wrong id, wrong env, or the code root resolved to the wrong directory) and

```
unit 'counter/panel' is jsx, not java
```

(`EngineException` from `unitClass`, which only compiles Java units).

**Minimal example.**

```java
class NoteUnitsTest {

    private static Engine engine;
    private static Class<?> noteApi;

    @BeforeAll
    static void compileTheUnits() {
        // Gradle runs tests with the module directory as CWD; the unit tree is the repo's.
        Path code = Files.isDirectory(Path.of("../code")) ? Path.of("../code") : Path.of("code");
        // The same registration the host does at boot: javac needs the host classes on a real
        // -classpath, and an anchor's protection-domain code source is how the runner finds them.
        Engine.addCompileAnchor(MyApp.class);
        // null key: the key is the credential for the DEFAULT code source, and this test replaces
        // that source, so nothing ever reads it. No server, no network, no key in CI.
        engine = InterlockSDK.init(null)
                .codeSource(new DirectoryCodeSource(code))
                .warmUp(false)
                .build();
        noteApi = engine.unitClass("notes/NoteApi", "dev");
    }

    @Test
    void theUnitCompilesWithItsSiblingsAndCarriesItsPackage() {
        assertEquals("notes.NoteApi", noteApi.getName(),
                "directory is package — the id notes/NoteApi implies exactly this binary name");
    }

    @Test
    void everyHandlerInTheDirectoryCompilesThroughTheRealPipeline() {
        for (String id : new String[]{"notes/NoteList", "notes/NoteStore", "notes/NoteRenderer"}) {
            assertEquals("notes." + id.substring("notes/".length()),
                    engine.unitClass(id, "dev").getName());
        }
    }
}
```

`DirectoryCodeSource` reads text units straight off `code/<id>.<ext>` and marks everything
`authored`, so never point it at a directory where generated units land. Binary asset units are not
served by it: their bytes live behind the content-addressed blob store, and a test that needs them
needs a server.

### This test does NOT replace the packaged gate, and cannot

**The harness runs in your ordinary test suite. The gate runs against a packaged fast-jar. Those are
two different classpaths, and the difference is exactly the entity rule.**

Here is the mechanism, from `JavaRunner`. The host classes reach javac through
`addCodeSource(anchor)`, which resolves the anchor's protection domain to a file and then calls
`addTransformedBytecode` on it. That helper returns immediately unless the file is a `.jar`:

```java
private static void addTransformedBytecode(Set<String> entries, File entry) {
    if (!entry.getName().endsWith(".jar")) {
        return;
    }
```

In a Gradle or Maven test JVM the anchor's code source is `build/classes/java/main`, a **directory**.
Nothing inserts `quarkus/transformed-bytecode.jar`, because in a test run there is no such jar to
insert (`quarkusDev` and `@QuarkusTest` transform in memory and write none). So the harness compiles
every unit against the **original** bytecode, where an entity field Quarkus will later privatize is
still `public` and javac has no complaint. The packaged host then loads the transformed copy and the
same unit dies on the first request.

That is the green-test-red-production condition in full, and it is the precise thing the entity rule
exists to catch.

The classpaths differ a second way, in the opposite direction. `classpath()` copies the JVM's
`java.class.path` verbatim, and under `./gradlew test` that string carries `build/classes/java/test`
and every test-scope dependency. So the harness can also compile a unit **too** successfully, against
a jar the packaged server does not ship. Both differences push the same way: a green harness is not a
statement about the server. The full list is
[what is on a unit's compile classpath](/host#what-is-on-a-units-compile-classpath).

Which is why the split is:

| check | classpath it compiles against | catches | misses |
|---|---|---|---|
| the harness test | the host's test classpath: `build/classes/java/main`, untransformed, plus everything else the test JVM was launched with | wrong id or env, a sibling that does not resolve, a reserved package, a package-private call across units, ordinary type errors, and the unit's actual **behaviour** | every entity-privatization failure, because the field is still public in the classes it sees; and it can pass a unit that compiles only against a test-scope jar |
| [the two-second gate](#the-two-second-gate) | the packaged app, `transformed-bytecode.jar` first | exactly that failure: `status has protected access in Presence` | anything about behaviour; it compiles and asserts nothing |

Neither is a superset of the other. The harness is the only one that runs your unit's logic; the gate
is the only one that sees the bytecode the server will load. Run both, gate first, which is what
[the order to run them](#the-order-to-run-them) does.

One diagnostic to know: if the harness is the thing reporting a protected-access error, your test JVM
somehow does have a transformed jar on its classpath, and the boot line tells you which world you are
in. `no transformed-bytecode jar found` in the log means you are on the untransformed classpath and
the gate is doing work this test cannot.

**Verify it.**

```bash
./gradlew test --tests '*UnitsTest'
```

```bash
# and prove the two checks read different bytecode. The first path is a DIRECTORY, which is why the
# harness gets no transformed jar; the second is the jar only the packaged gate ever sees.
ls -d */build/classes/java/main
ls -l  */build/quarkus-app/quarkus/transformed-bytecode.jar
```

## Reflection is the boundary, not a workaround

**The rule.** Reach unit classes reflectively, and do not try to make them compile-visible to the
host. If reflection feels like a smell, that feeling is the boundary reporting itself correctly.

**Why it exists.** The unit is deliberately not on the host's test classpath. It is authored code
that an `interlock sync` can change without rebuilding the host, compiled at runtime into its own
per-importer classloader. A host that could `import notes.NoteApi` would be a host that has to
be rebuilt whenever a unit changes, which is the entire property the unit tree exists to provide.

**The failure it prevents.** Two, in opposite directions. Trying to import the unit type into a host
test fails at compile:

```
error: package notes does not exist
import notes.NoteApi;
```

Casting the `Class<?>` result to a host type of the same name fails at runtime with a
`ClassCastException` whose two class names print identically, because they are the same name in two
different loaders. The same trap exists across `il.call`, and the SDK warns about it once per class
rather than letting you meet it at the failure site:

```
WARN  il.call id:notes/NoteList returned unit-local class notes.NoteApi — unit classes are per-importer; types crossing units should be host classes
```

**Minimal example.** The unit under test is `code/lib/Counter.java`: it holds an `int[]` of
slots, and it declares a static `of(int[])` factory, an `add(...)` method and a `raw()` reader.
Nothing else about it matters. Reflective doors wrap those three methods, named so the test body
still reads like the behaviour it asserts:

```java
// the only handle a host test can have on a unit class: no import, no cast
private static Class<?> counter;   // = engine.unitClass("lib/Counter", "dev")

private static Object of(int[] slots) throws Exception {
    return counter.getMethod("of", int[].class).invoke(null, (Object) slots);
}

private static int[] raw(Object c) throws Exception {
    return (int[]) counter.getMethod("raw").invoke(c);
}

// Amount is a record declared in the HOST, so both sides mean the same class by that name.
private static void add(Object c, int slot, int by) throws Exception {
    counter.getMethod("add", Amount.class).invoke(c, new Amount(slot, by));
}

@Test
void addsAccumulateInTheSlotTheyName() throws Exception {
    Object c = of(new int[4]);
    add(c, 0, 2);
    add(c, 0, 3);
    assertEquals(5, raw(c)[0], "two adds to the same slot sum");
    assertEquals(0, raw(c)[3], "and no other slot moves");
}
```

Types that genuinely need to cross the boundary (arguments and return values, like
`Amount` above) are HOST classes, not unit classes. That is the same rule the `il.call`
warning states, and it is why the doors take `int[]` and host records rather than unit types.

**Verify it.** The negative proof matters more than the positive one: add the import to a host test
and confirm the build refuses it.

```bash
./gradlew compileTestJava
```

## Assert effects, not status codes

**The rule.** An end-to-end check asserts that the EFFECT happened, read back through the API. A
200, or a non-empty body, or a schema match, is not an assertion.

**Why it exists.** A unit can answer correctly-shaped 200s while running entirely the wrong branch.
Status is produced by the transport; the effect is produced by the logic under test.

**The failure it prevents.** The one that justifies the whole rule: `req.str(name, def)` used to read
query parameters only and ignore the JSON body, so EVERY unit API silently ran its default branch on
every POST. Well-formed request, 200 response, wrong code path, nothing anywhere reporting it. No
error text exists for this failure, which is precisely the point: it was found only because one e2e
check asked "did the mute request arrive?" instead of "did the POST return 200?". `req.str` reads
query params AND the JSON body today, query wins, but a check that asserts on status will hide the
next bug of this shape just as well as it hid this one.

Related: a unit that can only answer 200 turns every refusal into an apparent success. Throw
`Refusal` (default status 409) rather than returning a map with an `ok:false` in it, so a client that
ignores the body still fails visibly.

**Minimal example.** Two requests: one that causes the effect, one that reads it back.

```bash
curl -fsS -X POST http://localhost:8099/app/counter/api \
  -H 'content-type: application/json' \
  -d '{"action":"add","by":1}'
```

```bash
curl -fsS http://localhost:8099/app/counter/api \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["count"])'
```

The assertion is that the second command prints exactly one more than it printed before the POST.
Assert the negative too: read it again without posting and expect the SAME number, because a read
that increments is a bug the first assertion cannot see. And assert refusals by reason, not by
absence: an increment the unit is meant to reject must come back `409` with a `reason` token, not a
`200` carrying an `ok:false` in the body.

**Verify it.** Run your full-circle script and read the assertions, not the exit code:

```bash
./run-full-local-macos-test.sh
```

If any assertion in it can pass while the handler runs its default branch, it is a status-code
assertion wearing an effect's clothes.

## Every measurement needs a control arm

**The rule.** Any claim about how the platform behaves needs at least two arms: the case you suspect
and a control that differs in exactly the property under test. Print the artifact; do not reason
about it.

**Why it exists.** A one-armed measurement can only confirm the hypothesis you brought to it. The
entity rule is the worked case: measuring only an entity with public fields and no hand-written
accessors shows the fields turning `protected` and accessors appearing, which supports exactly the
wrong conclusion ("Quarkus privatizes entity fields, so units must use the generated accessors").
Generated accessors do not exist on the classes host tests compile against, so that conclusion fails
in the other direction, and days went into three wrong diagnoses because of it. The control entity,
which had hand-written accessors, is what showed the actual mechanism: **Quarkus privatizes a field
only when it GENERATES that field's accessor.**

**The failure it prevents.** Shipping the inverted rule, whose two error texts are exactly the ones
the [two-second gate](#the-two-second-gate) catches: `y has protected access in X` at compile, and
`tried to access protected field X.y` at runtime. Also the quieter version: an entity with public
fields whose accessor someone later deletes. Quarkus regenerates it, privatizes the field, every
unit reading that field breaks on the next deploy, and every host test stays green. Private fields
make that same deletion fail the host build immediately, which is the only reason to prefer them.

**Minimal example.** The measurement, both arms, run against a packaged real host application
(Team Lakes). The class names below are that host's own, not names you own. Test arm, an
entity with public fields and no hand-written accessors:

```bash
unzip -o -q "$APP/quarkus/transformed-bytecode.jar" 'com/teamlakes/api/office/Presence.class' -d /tmp/t && javap -p /tmp/t/com/teamlakes/api/office/Presence.class | grep -E ' status;|getStatus| id;'
```

```
  public java.lang.String id;
  protected java.lang.String status;
  public java.lang.String getStatus();
```

Control arm, an entity with private fields and hand-written accessors, same jar, same command shape:

```bash
unzip -o -q "$APP/quarkus/transformed-bytecode.jar" 'com/teamlakes/api/world/OfficeWorld.class' -d /tmp/c && javap -p /tmp/c/com/teamlakes/api/world/OfficeWorld.class | grep -E 'officeId|getOfficeId'
```

```
  java.lang.String officeId;
  public java.lang.String getOfficeId();
```

The control arm is what settles it. Nothing was generated, and `getOfficeId()` is byte-identical to
the one in `app/<app>.jar`, which is why ONE unit source can compile against the host's ordinary test
classpath AND against the packaged application. Note also that `@Id` stays `public` in both arms,
which is why `entity.id` works from a unit regardless and why this bug hides for so long.

| entity as declared | transformed bytecode | usable from a unit? |
|---|---|---|
| public fields, **no** hand-written accessors | fields become `protected`, accessors generated | no |
| public fields, **with** hand-written accessors | fields stay **public**, nothing generated | yes, either style |
| private fields, with hand-written accessors | accessors untouched, field access relaxed to package-private | yes, accessors only |

**Verify it.** Re-run both arms on your own Quarkus version and your own entities before relying on
the table, and diff the two copies of the same class:

```bash
javap -p /tmp/t/com/teamlakes/api/office/Presence.class > /tmp/transformed.txt && unzip -o -q "$APP"/app/*.jar 'com/teamlakes/api/office/Presence.class' -d /tmp/o && javap -p /tmp/o/com/teamlakes/api/office/Presence.class > /tmp/original.txt && diff /tmp/original.txt /tmp/transformed.txt
```

## The order to run them

Cheapest and most specific first, so the slow gates only ever run on code that already passed the
fast ones.

The first two are not interchangeable and neither one is optional. The gate is the only check that
compiles against the bytecode the packaged server loads; the harness is the only check that runs
what a unit actually does. See
[this test does not replace the packaged gate](#this-test-does-not-replace-the-packaged-gate-and-cannot).

```bash
./scripts/check-units-compile.sh          # packaged classpath, transformed bytecode: the entity rule
```

```bash
./gradlew test --tests '*UnitsTest'       # test classpath, untransformed: ids, siblings, behaviour
```

```bash
./gradlew test
```

```bash
./run-full-local-macos-test.sh
```
