Menu

Aussom-Server Unit Testing - Usage Guide

Aussom-Server ships an in-process unit-test runner. You write tests in Aussom, run them with aussom-server -t <file>.aus, and your app code is exercised directly - no port binding, no Undertow, no Node WebSocket driver. The runner reuses Aussom's standard aunit library so @Test, @Before, @After, expect* helpers, tag filters, mocks, and spies all behave exactly the way they do in the bare Aussom CLI.

This guide walks through everything you need to author a test suite: how to invoke the runner, how to load an app under test, the MockHttpReq and MockWsConn objects you feed your handlers, and a handful of recipes for the patterns that come up most often.


How it fits next to the existing test layers

Aussom-Server has three places tests can live. They are complementary, not redundant:

Layer What it tests How to run
aussom-tests/*-api-tests.aus Wire format, auth filter, Undertow routing. aussom -t aussom-tests/admin-api-tests.aus
aussom-tests/*-websocket-tests.aus Handshake, framing, close codes via Node WS. aussom -t aussom-tests/admin-websocket-tests.aus
In-process unit tests (this doc) App logic, no server. aussom-server -t apps/<app>/test/<app>Test.aus

The first two stand up the real server in another process and drive it over the wire. They are slow, but they cover everything below the Aussom layer (TLS, routing, handshake). The unit-test layer skips the server and runs your Aussom code in a fresh engine in the same process. It is fast, deterministic, and lets you assert against the state your handler wrote (response body, headers, captured frames) without parsing wire output.

Use the in-process layer for app logic. Reach for the wire-level layers when you are testing wire-level concerns.


Quick start

A minimal test that loads apps/helloworld/helloworld.aus and asserts its test endpoint returns Hello world! plus the right content type:

include aunitserver;

@Test(name = "helloworld unit tests")
class helloworldTest : AppTest {
    private app = null;

    @Before
    public setUp() {
        this.app = this.loadApp("helloworld", "apps/helloworld");
    }

    @Test(name = "GET /helloworld/test returns plain text")
    public testEndpointReturnsPlainText() {
        req = this.newReq();
        req.setMethod("GET").setPath("/helloworld/test");

        this.app.test(req);

        this.expect(req.getCapturedStatus(), 200);
        this.expect(req.getCapturedHeaders()["content-type"], "text/plain");
        return this.expect(req.getCapturedBody(), "Hello world!");
    }
}

Run it:

aussom-server -t apps/helloworld/test/helloworldTest.aus

Expected output:

[info] Running Test [ helloworldTest : helloworld unit tests ]
[info] *** testEndpointReturnsPlainText : GET /helloworld/test returns plain text ... PASSED
[info] PASSED: 1 SKIPPED: 0 FAILED: 0 TOTAL: 1

The CLI

The same install that gives you start-server.sh also lays down a generic aussom-server shell wrapper next to it. Both are produced by -i (install). The wrapper is just a thin shim that forwards every argument to java -cp <fat-jar>:./lib/* com.lehman.aussomserver.Main, so any flag the JAR understands works.

Flag What it does
-t <file> Run tests in <file>. Loads only the test classes declared in that file.
-ta <file> Run all @Test classes reachable from <file>, including those pulled in via include.
--tags A,B Only run @Test methods whose tags set intersects this list. Untagged tests are skipped under an include filter.
--exclude-tags A,B Skip @Test methods whose tags set intersects this list. Exclude wins over include when both match.
-cf <file> Optional. Path to config.yaml. Pass when the test calls props.load, api.unpack, or anything else that needs server config.
-ad <dir> Optional. Path to the apps/ directory. Same reason as -cf.

Examples:

aussom-server -t apps/helloworld/test/helloworldTest.aus
aussom-server -t apps/helloworld/test/helloworldTest.aus --tags fast
aussom-server -ta apps/helloworld/test/helloworldTest.aus
aussom-server -t apps/myapp/test/myappTest.aus -cf config.yaml -ad apps

Exit codes:

Code Meaning
0 All tests passed.
1 At least one test failed.
2 Usage error (missing file, parse error, ...).

These match aussom -t, so any CI script that already gates on the Aussom CLI runner will work unchanged against the server runner.


What you get from include aunitserver;

aunitserver.aus is an Aussom-Server library bundled inside the JAR. It pulls in aunit and aussomserver, then adds three things:

  1. The MockHttpReq extern class (a stand-in for HttpReq).
  2. The MockWsConn extern class (a stand-in for WsConn).
  3. The AppTest base class, which extends aunit's test and adds helpers for loading an app under test and constructing fresh mock objects.

Every test class should include aunitserver; and extend AppTest.

include aunitserver;

@Test(name = "...")
class myTests : AppTest {
    // tests go here
}

AppTest inherits the full expect* family from aunit (expect, expectNotNull, expectMap, expectList, expectBool, expectInt, expectClose, expectThrows, expectContains, expectKey, expectSize, expectMatches, fail, ...). See aunit.aus for the full list; everything the Aussom CLI gives you is here verbatim.


AppTest helpers

Method Purpose
this.loadApp(name, dir) Parses dir/name.aus into the test engine and returns the instantiated app object.
this.newReq() Returns a fresh MockHttpReq with sane defaults.
this.newWs() Returns a fresh MockWsConn with sane defaults.

Behavior notes:

  • loadApp is idempotent: subsequent calls reuse the parsed class rather than re-parsing. Most suites call it once in @Before and cache the instance on a private field.
  • Loading honors the same security fence the live server uses (AussomServerSecurityManager), and adds the app dir to the include path while excluding <dir>/app_data exactly the way AussomServerApp.initEngine does.
  • The app file you load can include any of the standard server libraries (http, file, xml, yaml, markdown, sendgrid, lucene, jdbc, firebase, cache, etc.). The runner registers all of them up front so resolution matches production.

MockHttpReq - driving HTTP endpoints

MockHttpReq exposes the same Aussom externs as the real HttpReq, so handler code does not care which it received. On top of that it adds test-side setters (to shape the incoming request) and capture methods (to read what the handler wrote).

Constructing and shaping a request

this.newReq() returns an empty request: GET /, no headers, no body, status 200. Configure further with chainable setters:

req = this.newReq();
req
    .setMethod("POST")
    .setPath("/myapp/save")
    .setHeader("content-type", "application/json")
    .setHeader("x-trace-id", "abc-123")
    .addQueryParam("debug", "1")
    .setBody('{"name":"jane","age":30}');

Available setters:

Setter Notes
setMethod(string) GET, POST, PUT, DELETE, ... (case preserved).
setPath(string) Updates getReqURI() and getReqURL() to match.
setScheme(string) http / https.
setHost(string) Updates getReqURL() to match.
setSrcAddress(string) What the handler reads via getSrcAddress().
setHeader(name, value) Multi-valued headers: call again with the same name.
setQueryString(string) Raw override; prefer addQueryParam so map and string stay in sync.
addQueryParam(name, value) Updates both the parsed map and the raw query string.
addPathParam(name, value) Positional; appends to the list getPathParams() returns.
setBody(string) Mutually exclusive with setFormField.
setFormField(name, value) First call stamps Content-Type: application/x-www-form-urlencoded so formSubmitted() reports true.

All setters return the MockHttpReq so chains compose.

Calling the handler

The handler is just a public method on the loaded app. Pass the mock where the real server would pass an HttpReq:

this.app.save(req);

That call runs synchronously. When it returns, the response state is sitting in req's capture slots.

Reading the captured response

Method Returns
getCapturedStatus() The status code the handler set (or 200 if it never called setStatusCode).
getCapturedHeaders() Map of lower-case header name to value, populated by putHeader calls.
getCapturedBody() Full response body. send, sendBytes, and sendChunk all append here.
getCapturedChunks() List of chunks the handler wrote via sendChunk in order. Useful for streaming tests.
getCapturedCookies() List of maps with name / value / expires / secure / path for every setCookie call.

Assertion idiom:

this.expect(req.getCapturedStatus(), 201);
this.expect(req.getCapturedHeaders()["content-type"], "application/json");
body = json.parse(req.getCapturedBody());
this.expect(body.id, "user-7");

Full surface mirror

The mock exposes the entire HttpReq extern surface so handler code that reads the request runs unchanged:

getReqPath, getReqMethod, getReqScheme, getReqURI, getReqURL,
getReqContentLength, getReqStartTime, getReqHeaders, getReqCookies,
getHost, getAddress, getPort, getSrcHost, getSrcAddress, getSrcPort,
getProtocol, getQueryString, getQueryParams, getPathParams, getReq,
getBody, formSubmitted, getFormData,
getRespBytesSent, getRespContentLength, getRespCharset,
getRespHeaders, getRespCookies, getResp,
putHeader, setStatusCode, send, sendBytes, sendChunk, setCookie,
addPathParam, addQueryParam

A Java interface (HttpReqLike) is implemented by both HttpReqObj and MockHttpReqObj, so adding a method to the production class breaks the build until the mock implements it too. Drift between the two is impossible to ship.


MockWsConn - driving WebSocket handlers

MockWsConn is the WebSocket counterpart to MockHttpReq. It looks like a real WsConn to the handler, plus test-side methods to fire events and read what the handler sent.

Setting up a connection

ws = this.newWs();
ws.setReqPath("/myapp/chat")
  .setQueryString("room=lounge")
  .setReqHeader("user-agent", "test/1.0")
  .setSrcAddress("203.0.113.7");
Setter Notes
setReqPath(string) Path the handler reads via getReqPath().
setQueryString(string) What getQueryString() returns.
setSrcAddress(string) What getSrcAddress() returns.
setReqHeader(name, value) Multi-valued: call more than once with the same name.

Wiring the handler

Call your @Websocket-annotated function with the mock; the handler will register its onMessage / onBinary / onClose / onError callbacks on it, the same way it would on a real WsConn:

this.app.chat(ws);

Driving frames

After the handler has wired callbacks, drive frames through:

Method Effect
fireMessage(text) Invokes onMessage(text) synchronously.
fireBinary(buffer) Invokes onBinary(buffer) synchronously.
fireClose(code, reason) Invokes onClose(code, reason) synchronously.
fireError(message) Invokes onError(message) synchronously.

Frame delivery is synchronous on the calling thread. The production server runs callbacks on Undertow's worker pool through a per-connection serial queue; the mock just calls the registered callback inline. This eliminates the timing flake the wire-level WS tests have to defend against (closeAfterMs deadlines, sleep loops, etc.) and keeps the tests deterministic.

Reading what the handler did

Method Returns
getSentTexts() List of text frames the handler sent via send(text).
getSentBinary() List of Buffer objects the handler sent via sendBytes(buf).
wasClosed() True if the handler called close(), or if a close was fired in.
getCloseCode() Close code (0 if not closed).
getCloseReason() Close reason (empty if not closed).

Full surface mirror

The mock exposes the entire WsConn extern surface (send, sendBytes, onMessage, onBinary, onClose, onError, close, getReqPath, getQueryString, getSrcAddress, getReqHeaders). A Java WsConnLike interface keeps the two impls in lockstep.

Worked example

For an echo handler that replies "echo: <text>" to every text frame:

@Test(name = "echo replies once per text frame")
public echoesEachFrame() {
    ws = this.newWs();
    ws.setReqPath("/wstest/echo");

    // Handler registers its onMessage callback here.
    this.app.echo(ws);

    ws.fireMessage("hello");
    ws.fireMessage("world");

    sent = ws.getSentTexts();
    this.expect(#sent, 2);
    this.expect(sent[0], "echo: hello");
    return this.expect(sent[1], "echo: world");
}

For a tally handler that sends "ready" on attach, "count:N" per message, and "final:N" on close:

@Test(name = "tally lifecycle")
public tallyLifecycle() {
    ws = this.newWs();
    this.app.tally(ws);

    this.expect(ws.getSentTexts()[0], "ready");

    ws.fireMessage("a");
    ws.fireMessage("b");
    ws.fireClose(1000, "bye");

    sent = ws.getSentTexts();
    this.expect(sent[1], "count:1");
    this.expect(sent[2], "count:2");
    return this.expect(sent[3], "final:2");
}

Test class structure

Tests are normal Aussom classes that extend AppTest and carry @Test annotations. The shape mirrors aunit exactly:

include aunitserver;

@Test(name = "Suite name")
class mySuite : AppTest {
    private app = null;

    @Before                            // optional, runs once before any @Test
    public setUp() {
        this.app = this.loadApp("myapp", "apps/myapp");
    }

    @After                             // optional, runs once after all @Test
    public tearDown() {
        // cleanup that should not happen between tests
    }

    @BeforeEach                        // optional, runs before each @Test
    public freshFixtures() { /* ... */ }

    @AfterEach                         // optional, runs after each @Test
    public cleanupBetweenTests() { /* ... */ }

    @OnTestFail                        // optional, runs when a @Test fails
    public dumpDiagnostics() { /* ... */ }

    @Test(name = "human-readable description", tags = "fast,smoke")
    public publicTestMethod() {
        return this.expect(1 + 1, 2);
    }

    @Test(name = "an explicitly skipped test", skip = true)
    public skippedTest() { /* ... */ }
}

Notes:

  • @Before / @After run once per test class. They are the place to load the app and any expensive shared state.
  • @BeforeEach / @AfterEach run once per @Test method. Use them for fixtures that must be pristine for each test.
  • @OnTestFail runs after a failing test, before @AfterEach. Best for diagnostic dumps.
  • @Test(tags = "...") takes a comma-separated string. The --tags / --exclude-tags CLI flags filter on this set.
  • @Test(skip = true) flags the test as skipped without removing it.
  • A test passes when it returns a truthy value (or any of the expect* helpers, which throw on failure and return true on success). A test fails when it throws or returns a falsy value.

Recipes

Spy on outbound HTTP

When the handler under test calls an external service, do not let it hit the network. aunit ships a spy helper that intercepts method calls on a class:

@Test(name = "library endpoint surfaces upstream items")
public libraryUsesUpstream() {
    spy = new spy(Http);
    spy.when("get").thenReturn({
        info: { isSuccessful: true, responseCode: 200 },
        body: '{"items":[{"title":"X","note":["..."],"start_year":1900,"end_year":1923}]}'
    });

    req = this.newReq();
    req.setMethod("GET").setPath("/helloworld/library").addQueryParam("terms", "ohio");

    this.app.library(req);

    body = json.parse(req.getCapturedBody());
    this.expect(#body.items, 1);
    return this.expect(body.requestTerms, "ohio");
}

Multipart / form submissions

req = this.newReq();
req.setMethod("POST").setPath("/upload");
req.setFormField("title", "my-doc");
req.setFormField("author", "jane");

this.app.upload(req);

// In the handler:  data = req.getFormData(); data.title.value -> "my-doc"

The first setFormField call also stamps Content-Type: application/x-www-form-urlencoded so the handler's formSubmitted() reports true. File uploads are not supported in v1 - tests that need binary upload should fall through to the wire-level black-box layer.

Cookies

req = this.newReq();
req.setHeader("cookie", "session=abc123; theme=dark");
this.app.dashboard(req);

// Response cookies the handler set:
cookies = req.getCapturedCookies();
this.expect(cookies[0].name, "csrf");
this.expect(cookies[0].secure, true);

MockHttpReq does not parse the request Cookie header into the structured map getReqCookies() returns; tests that need the parsed shape should read the raw header instead, or use the wire-level layer.

Asserting against streamed responses

If the handler calls req.sendChunk(...) repeatedly, each chunk is captured separately:

this.app.streamReport(req);
chunks = req.getCapturedChunks();
this.expect(#chunks, 4);
this.expect(chunks[0], "first-chunk");
// getCapturedBody() also works - it concatenates chunks.

Testing private functions

Aussom's unit-test runner does not enforce access modifiers on dispatch. A test on AppTest can call any method on the loaded app instance, including helpers that are not endpoint functions:

this.expect(this.app.computeTotal(items), 47);

This is the answer to "I want to test a function that is not a public endpoint" - just call it directly.

Driving WebSocket close from the server side

The handler may close the channel itself by calling ws.close(code, reason). The mock records the close locally and also fires the registered onClose callback so cleanup paths run:

@Test(name = "server-initiated close fires onClose")
public serverClose() {
    ws = this.newWs();
    this.app.chat(ws);
    this.app.kickConnection(ws);            // calls ws.close(1008, "bye")
    this.expect(ws.wasClosed(), true);
    this.expect(ws.getCloseCode(), 1008);
    return this.expect(ws.getCloseReason(), "bye");
}

Tag-based test slicing

Tag your fast in-process tests so CI can run them on every commit and defer the slow ones to a nightly job:

@Test(name = "fast unit", tags = "fast")
public quickCheck() { /* ... */ }

@Test(name = "talks to a real DB", tags = "integration,slow")
public dbCheck() { /* ... */ }
aussom-server -t apps/myapp/test/myappTest.aus --tags fast
aussom-server -t apps/myapp/test/myappTest.aus --exclude-tags slow

File layout convention

We recommend keeping tests under each app's directory tree:

apps/
  helloworld/
    helloworld.aus
    test/
      helloworldTest.aus
  myapp/
    myapp.aus
    test/
      myappTest.aus
      featuresTest.aus

The server only loads <appname>.aus at startup, so anything under apps/<appname>/test/ is ignored at runtime - it exists purely for the test runner. This keeps tests next to the code they exercise without polluting the app's request-routing namespace.

If you prefer a flat top-level test directory (e.g. aussom-tests/), that works equally well; the runner only cares about the file path you pass to -t.


When tests need server config

Most unit tests can skip -cf and -ad because the runner registers all the standard libraries unconditionally. Pass them when:

  • The handler calls props.encrypt / props.decrypt. These need the encryption key from config.yaml.
  • The handler calls props.load(file) to read a YAML properties file. The runner needs to know the app dir to resolve the file.
  • The handler calls api.unpack(...) against an OpenAPI definition generated for an app. The runner needs the app dir to find the generated spec.

Example:

aussom-server -t apps/myapp/test/myappTest.aus -cf config.yaml -ad apps

Differences from aussom -t

The Aussom-Server runner is built on top of the same UnitTestRunner base the Aussom CLI uses, so 95 percent of behavior is identical. The deltas:

Aspect aussom -t aussom-server -t
Security manager DefaultSecurityManagerImpl AussomServerSecurityManager
Standard library Bare Aussom (lang, util, math, ...) Bare Aussom + server libs (http, file, xml, yaml, markdown, sendgrid, lucene, firebase, jdbc, cache, props, api)
aunitserver library not available bundled
MockHttpReq / MockWsConn not available bundled
loadApp(name, dir) not available bundled
Per-test timeouts enforced (@Test(timeoutMs = ...)) not enforced in v1; the annotation parses but the runner does not apply a watchdog

If you write tests that do not need any of the server-only features (no app loading, no mocks, no server libraries), you can run them under either CLI. Tests that touch the app directly need the server runner.


Limitations in v1

  • No per-test timeouts. The CLI runner has them; the server runner inherits the base UnitTestRunner directly and does not override runClass. A test that hangs will hang the suite. If this becomes a problem we will lift the watchdog helper from CliUnitTestRunner into the shared base.
  • No file uploads in MockHttpReq. setFormField covers urlencoded fields; multipart with file content is wire-level only for now.
  • No live KPI counters. The runner does not increment the request / doc / api counters that AussomServer keeps. If a test needs to assert against KPI state, drive the wire-level layer instead.
  • No reload-on-file-change. Disabled by design - the server's file watcher is a runtime concern.
  • Cookie request parsing is shallow. The mock does not split a raw Cookie: header into structured cookies for getReqCookies(). Tests that need that should fall through to the wire-level layer.

These are documented intentionally. The whole point of the layer is to be small and fast; complexity that does not pay for itself in tests-per-second is held back.


Troubleshooting

Object 'cnull' has no overload of '...' matching call signature 's'

You are chaining setters off something that returned null. The most common cause is calling a method on the loaded app that returns null and then calling .something(...) on the result. Check the chain: the only methods that return the request/connection for chaining are the explicit setters (setMethod, setHeader, ...) and the response writers (putHeader, setStatusCode, send, ...).

appLoader.load: app file not found at '...'

loadApp(name, dir) looks for dir/name.aus. Double-check the relative path - the runner runs in the directory you invoked it from, not the test file's directory.

Engine.runTest(): No classes found for that script file

The file parsed but contained no @Test classes. Verify the class extends AppTest (or test) and carries a class-level @Test annotation, and that at least one method also has @Test.

Test passes locally but fails in CI

The runner does not start the listener, but tests can still call Http.get(...) or os.exec(...) and reach the network. Use a spy on Http instead of relying on outbound calls succeeding from the build machine.


Further reading

  • design/aussom-server-unit-testing-design-doc.md - architecture notes for the runner and the mock objects.
  • design/lessons-learned.md - includes the env.getClassInstance() vs env.getCurObj() rule that matters for any future extern that should chain.
  • aussom-tests/server-smoke-test.aus, aussom-tests/server-mockreq-test.aus, aussom-tests/server-mockws-test.aus, aussom-tests/server-loadapp-test.aus - working test suites that ship in the repo. Read them as live examples of the shapes documented above.