Skip to content
Developer Tools

Unit Testing

3forge Enterprise can run AmiScript unit tests against a Center in an isolated, throwaway clone: no Docker, no JDBC client, no second JVM, and never against the live component. A run clones the target with a lean test profile (test mode on, listeners off, persist / HDB / managed-schema redirected away), runs the tests against the clone(s), and tears them down afterward. Because the run is isolated, no live data is touched and no feed, timer, or replication can interleave and flake a test.

Note

Isolation is structural. Each test runs against a clone whose data is reset to a whole-clone baseline between tests: a test's mutations, whether direct, trigger-induced, procedure-induced, or cascaded, all land in the clone and are rolled back together at reset.

The runner is component-agnostic; Center supports it today.

Prerequisites

The runner is dev-gated. Enable it in the component's local.properties:

1
2
3
ami.db.tests.enabled=true
# Coverage runs additionally need the debugger's per-statement hook:
ami.db.debug.enabled=true

Restart AmiCore to pick up the properties. ami_execute(op=runComponentTests) only works against a component that supports the testable capability (Center does).

How It Runs

Tests are invoked with runComponentTests, available from the admin console or through MCP tooling as ami_execute(op=runComponentTests):

ami_execute(op=runComponentTests, componentName, testFile, coverage, outputDir?)
  • Clones the component, never the live instance. Each clone boots fresh from the config schema with the test-mode overrides applied (test mode on, JDBC/console ports off, persist / HDB / managed-schema paths redirected to empty).
  • coverage=false: the suite is discovered on one clone, partitioned across a pool of worker clones (ami.test.parallelism, default 4), run in parallel, and the results merged. Returns a Test / Status / Detail table.
  • coverage=true: runs on a single clone with the per-statement coverage hook on (needs ami.db.debug.enabled=true). Returns the per-target coverage report, or, with outputDir, writes report files and returns their paths.
  • Per-test isolation is a whole-clone baseline reset. The lifecycle is: BEFORE ALL -> snapshot -> for each test [BEFORE EACH -> body -> AFTER EACH -> restore] -> AFTER ALL.

The clone has config-schema objects only

Because the clone's managed-schema file is redirected to empty, any object created at runtime (a table / procedure / trigger made ad hoc) is invisible to the test, and you get Unknown table: .... Either put the object in the component's config schema, or (simplest) define it in the test file itself. This is the single biggest gotcha, and the reason the sample below is self-contained.

Writing a Test File

A .test.amisql file may contain ordinary top-level DDL (CREATE PUBLIC TABLE, CREATE PROCEDURE, CREATE TRIGGER, …) followed by CREATE TEST blocks. The runner executes the top-level DDL against the clone when it loads the file, so the tests' fixtures are created fresh in each clone with no dependency on live or runtime schema. CREATE TEST (and CREATE BEFORE|AFTER ALL|EACH) are brace-delimited peers of CREATE METHOD.

CREATE PUBLIC TABLE tt_x(id Integer);
CREATE PUBLIC TABLE tt_y(id Integer);

CREATE PROCEDURE tt_maybe_clear OFTYPE AMISCRIPT USE arguments="" script="""int c = SELECT count(*) FROM tt_x;
if (c > 10) {
  DELETE FROM tt_y;
}""";

CREATE TEST proc_leaves_y_when_x_small {
  INSERT INTO tt_y VALUES(1);
  INSERT INTO tt_y VALUES(2);
  INSERT INTO tt_x VALUES(1);
  CALL tt_maybe_clear();
  int n = SELECT count(*) FROM tt_y;
  assertEquals(2, n);
};

CREATE TEST proc_clears_y_when_x_large {
  INSERT INTO tt_y VALUES(1);
  int i = 1;
  while (i <= 11) {
    INSERT INTO tt_x VALUES(i);
    i = i + 1;
  }
  CALL tt_maybe_clear();
  int n = SELECT count(*) FROM tt_y;
  assertEquals(0, n);
};

Assertion Vocabulary

Builtin Meaning
assertEquals(expected, actual) Soft-assert equality, recording a failure and continuing.
advanceClock(ms) Advance the run's frozen test clock and fire any now-due timers.

A test passes if no exception escaped its body and it recorded no assertEquals failure. To check a row count, assign the scalar subquery to a variable and assertEquals it, as above.

Authoring Rules

  • Assign a scalar SELECT with no surrounding parentheses: int n = SELECT count(*) FROM t;.
  • Strings are double-quoted (AmiScript): assertEquals("INSERT", op);.
  • Terminate every CREATE TEST / CREATE BEFORE|AFTER block with ; after its closing } (like CREATE METHOD { ... };). A bare } leaves the next statement as trailing text.
  • No /* ... */ block comments: the file is loaded through the SQL executor, which does not strip them.
  • CREATE PUBLIC TABLE, not CREATE TABLE: a plain CREATE TABLE is session-private and will not be visible where the test expects it.

Running the Suite

1
2
3
4
5
ami_execute(op=runComponentTests, componentName="center",
                                  testFile="<path>/sample.test.amisql",
                                  coverage=false)
=> [{"Test":"proc_leaves_y_when_x_small", "Status":"PASS","Detail":""},
    {"Test":"proc_clears_y_when_x_large", "Status":"PASS","Detail":""}]

Because every clone is torn down afterward, nothing leaks to the live component. Verify with SELECT TableName FROM __TABLE (no tt_* present).

Fire-and-forget async EXECUTE in a test

A fire-and-forget USE DS="AMI" EXECUTE ... (from a trigger or timer) cannot run under test isolation, because its continuation would need the partition write lock the run holds for its whole duration. In test mode it is treated as a no-op and surfaced as a WARN in the result Detail (the pass/fail is unchanged). The awaited form (a procedure that consumes the EXECUTE result) runs normally.

Coverage

Run the same file with coverage=true (needs ami.db.debug.enabled=true) to measure line and branch coverage of the production targets the tests exercise, instead of the pass/fail table:

1
2
3
4
ami_execute(op=runComponentTests, componentName="center", testFile="<path>/sample.test.amisql", coverage=true)
=> [{"TargetId":"center:procedure:tt_maybe_clear:script",
     "Covered":3,"Total":3,"Pct":"100.0%","CoveredLines":"1,2,3","MissedLines":"",
     "BranchesCovered":2,"BranchesTotal":2,"BranchPct":"100.0%","PartialBranchLines":""}]

Here tt_maybe_clear is at 100% on both counts because both arms of if (c > 10) were exercised (the true arm via the x-large test, the false arm via the x-small test). With only one of those tests, line coverage would still read 100% while branch coverage would report 1/2, the gap branch coverage exists to catch. Add an outputDir to write cobertura.xml, coverage.html, and coverage.info for CI.

See Code Coverage for the full report columns, branch semantics, file formats, where the files land, and recording coverage on a live Center.

CI Mode

For a pipeline, Enterprise can run a component's tests on startup and then exit with a status code, instead of booting as a server. Set ami.test.runonstartup=true and point it at the component(s) and file(s):

1
2
3
4
5
ami.test.runonstartup = true
ami.test.components    = center
ami.test.center.file   = ./config/sample.test.amisql
# optional per-component: ami.test.center.coverage=true, ami.test.center.output.dir=./coverage
ami.test.junit.file    = test-results/junit.xml

Enterprise runs the tests in the same isolated clones, writes a merged JUnit report (one <testsuite> per component), prints a summary, and exits:

Exit code Meaning
0 every test passed
1 at least one test FAILed
2 an infra error: component missing / not testable, no file configured, a configured file that discovered no tests, a clone error, a run threw, or the JUnit report could not be written

When coverage is enabled for a CI component, pass/fail comes from a normal run and coverage is generated as a separate artifact pass, so both are produced without interfering. See Code Coverage.

Gotchas and Limitations

  • Dev-gated. ami_execute(op=runComponentTests) refuses to run unless ami.db.tests.enabled=true; coverage additionally needs ami.db.debug.enabled=true.
  • Clone sees config schema only. Runtime-created objects are invisible, so define fixtures in the test file.
  • coverage=true returns the coverage report, not the pass/fail table: the per-test status and any WARN only appear in the coverage=false run (in CI, the runner does both).
  • Fire-and-forget async EXECUTE is a no-op in tests (WARN-and-continue).