Testing

Run JavaScript, TypeScript and JVM tests with elide test, and configure reports and coverage

elide test runs every test in your project and reports them as one run: a status line per test, a single tally across every language, and an exit code that follows the results.

elide test

JavaScript and TypeScript tests, written against node:test, need no configuration — Elide finds them by convention. JVM tests run through JUnit, from the test source sets your manifest declares.

Narrow a run by naming files, directories, or globs:

elide test src/api
elide test src/api/client.test.ts
elide test 'src/**/*.spec.ts'

A glob is matched against project-relative paths, so quoting it — which stops the shell from expanding it first — works the same as letting the shell do the expansion.

What counts as a test

A JavaScript or TypeScript file is a test if its name says so:

PatternExample
.test.client.test.ts
.spec.parse.spec.js
_test.client_test.mts
_spec.parse_spec.jsx

…or if it sits in a directory that exists to hold tests — test/, tests/, __tests__/ — in which case every guest source beneath it is a test regardless of name. Recognized extensions are js, mjs, cjs, jsx, ts, mts, cts, and tsx.

Dependency and build-output directories are never searched: node_modules, .git, .dev, dist, build, target, out, and coverage. A file you name explicitly on the command line always runs, whether or not its name carries a marker.

Note

JVM tests still require a test source set declared in elide.pkl; there is no convention-based discovery for them yet. A project with no manifest reports only its JavaScript and TypeScript tests, and Elide says so rather than reporting that no tests exist. Python test discovery is not implemented.

Which directory is the project

Without -p, Elide resolves the project the way every other command does: it walks up from the working directory to the nearest manifest. In a repository whose root carries an elide.pkl, running elide test from a subdirectory therefore tests the whole project, and a path argument is interpreted relative to that root rather than to where you are standing. Name the project explicitly to avoid the ambiguity:

elide test -p packages/api

A polyglot project

One command covers every language in the project. Given guest tests under src/js and JVM tests in a test source set, elide test compiles what needs compiling, runs both engines, and closes with a single tally:

src/js/api.test.js
✓ guest js runs in the same session as the jvm tests [1ms]
✓ guest suite > reports under its own file

src/js/typed.spec.ts
✓ guest typescript runs too [1ms]

polyglot.GreeterTest
✓ greets by name() [17ms]

polyglot.JavaGreeterTest
✓ greetingIsNotBlank() [4ms]

 5 pass
 0 skip
 0 fail
Ran 5 tests across 4 files. (0.91s)

Guest files are headed by their path, JVM suites by their qualified class name. The tally spans both, and so does the exit code — a failing guest test fails the run exactly as a failing JUnit test does.

Reading the output

Per-test rows, suite headings, and failure diagnostics are progress output, so they go to stderr. The closing tally goes to stdout. That split lets a pipeline keep a run’s summary without capturing the whole feed:

elide test > summary.txt        # the tally
elide test 2> feed.txt          # the per-test rows

The exit code follows the accumulated results rather than any single runner’s status: if any test failed, elide test exits non-zero even when every runner exited cleanly.

Build tasks with nothing to do stay quiet. A test run’s output is the tests, so compilation that was already up to date says nothing rather than printing a line each ahead of the results; --verbose reports them as elide build does. Test runners themselves add no “passed” line either — the tally covers every engine, and a per-engine line would only sit between the last test and it.

Options

FlagDescription
-t, —test-name-pattern=REGEXRun only tests whose name, or an enclosing suite’s name, matches
—bail[=N]Stop after N failures; a bare flag means one
—test-timeout=MSDefault per-test timeout
—coverageCollect coverage for every language in the run
—onlyRun only tests marked .only
—concurrency=NRun N test files at once
—reporter=junitWrite JUnit-compatible XML
—reporter-outfile=DIRDirectory to write reports into

Several of these have edges worth knowing:

  • --bail takes its count with = (--bail=3). Written bare it means one, and it will not swallow a following path argument. The JavaScript runner stops between tests and honors any count exactly; the JUnit console launcher can only stop after its first failure, so a higher count behaves as --bail=1 for JVM tests.
  • --test-timeout is per test. The global --timeout bounds the whole invocation and is a separate budget; the two can be combined.
  • --concurrency interleaves output. Rows from several files arrive mixed together; the default of one file at a time keeps each file’s results grouped under its heading.

Filtering by name

-t keeps a test when its own name matches, or when any suite enclosing it matches — so naming a describe runs everything inside it. Everything else reports as skipped.

For JVM tests the pattern is matched against the method name, which is what the console launcher can filter on. A test carrying a custom @DisplayName is therefore selected by its method, not by the name printed in the output — the one place -t does not mean quite the same thing in both engines.

A suite none of whose descendants match is skipped as a unit: its children never run, and so never report. That is why a filtered run’s total is usually smaller than an unfiltered one, rather than the same total with more skips.

Reports

Machine-readable reports use one document shape for every language, so a CI system consuming them does not need to know which runner produced a given suite. Request them with --reporter=junit, or in the manifest:

amends "elide:project.pkl"
import "elide:Testing.pkl" as Testing

testing {
  reports {
    Testing.xmlReport()
  }
}

Reports land under .dev/reports/tests/<language>/<source-set>/, one document per guest file or JVM class. A project declaring more than one JVM test source set gets a directory each, since a task runs per source set:

.dev/reports/tests/js/test/TEST-src_js_api.test.js.xml
.dev/reports/tests/js/test/TEST-src_js_typed.spec.ts.xml
.dev/reports/tests/jvm/test/TEST-polyglot.GreeterTest.xml
.dev/reports/tests/jvm/test/TEST-polyglot.JavaGreeterTest.xml
Note

A JVM suite's classname is recovered by matching the runner's reported display name against the compiled classes. A custom @DisplayName, or a simple name owned by two packages, cannot be resolved that way and falls back to the unqualified name.

Coverage

Pass --coverage to collect coverage for every language in the run, or configure it in the top-level testing { } block of your manifest. The flag covers both engines; the manifest blocks below additionally decide which report files are written.

One table is printed once, after the tests finish, covering every language in the run. It opens with the run as a whole, then rolls up per language, listing files beneath:

╭─ Coverage Report ──────────────────────────────────────╮
│ Total · 43 files                                       │
│   Statements ━━━━━━━━━━━━━━━━──── 833/1627  51%        │
│        Lines ━━━━━━━━━━━━━━──────  426/1096 38%        │
│    Functions ━━━━━━──────────────    44/128  34%       │
│                                                        │
│ JavaScript · 40 files                                  │
│   Statements ━━━━━━━━━━────────── 820/1600  51%        │
│        Lines ━━━━━━━━━━━━━━──────  420/1080 38%        │
│    Functions ━━━━━━──────────────   40/120  33%        │
│   src/js/module1.test.js 1/40 1/12 1/3                 │
│   src/js/module2.test.js 2/40 2/12 1/3                 │
│   +38 more files                                       │
│                                                        │
│ Kotlin · 2 files                                       │
│   Statements ━━━━━━━━━━━━━━━━────    12/15  80%        │
│        Lines ━━━━━━━━━━━━━━──────      5/7  71%        │
│    Functions ━━━━━━━━━━━━────────      3/5  60%        │
│   src/main/kotlin/polyglot/Greeter.kt      3/3 1/1 1/1 │
│   src/test/kotlin/polyglot/GreeterTest.kt 9/12 4/6 2/4 │
╰────────────────────────────────────────────────────────╯

A file’s three counts read in the same order as the rows above them — statements, lines, functions. Percentages are floored, so a source one statement short of complete never reads as 100%. Grouping follows the language, taken from each source’s extension, rather than the engine that measured it: one engine covers both JavaScript and TypeScript, and another both Kotlin and Java.

The rolled-up counters always account for every file. The listing beneath them does not: past a handful of files a language shows only its least-covered ones and says how many it left out, so the report stays readable in a codebase with thousands of sources. A language that contributed a single file is named by that file and has no listing at all. The Total block appears only when more than one language is present — with one, the language block already is the total.

It prints after the run rather than as each language finishes, because the build display owns the terminal while tasks are running — a table emitted mid-run lands inside a live progress region.

JVM coverage

Backed by JaCoCo:

amends "elide:project.pkl"
import "elide:Jvm.pkl" as Jvm

testing {
  coverage {
    jvm {
      enabled = true
      reports {
        Jvm.htmlReport()
        Jvm.xmlReport()
      }
    }
  }
}
FieldTypeDefaultDescription
enabledBooleantrueWhether JVM coverage collection is active
reportsListing<JvmCoverageReport>{}Coverage reports to generate
ReportHelperDescription
htmlJvm.htmlReport()Browsable HTML coverage report
xmlJvm.xmlReport()XML coverage report (for CI tooling)
csvJvm.csvReport()CSV coverage report

Each report can also be constructed directly, for example new Jvm.HtmlCoverageReport {}.

Guest JavaScript and TypeScript coverage

Collected by the runtime itself and written as LCOV, which is what the JavaScript ecosystem’s tooling reads:

amends "elide:project.pkl"

testing {
  coverage {
    js {
      enabled = true
    }
  }
}
FieldTypeDefaultDescription
enabledBooleantrueWhether guest coverage collection is active
pathsListing<String>{}Source roots to report coverage for; empty reports everything under the project root

A summary prints after the run, and the full report goes to .dev/reports/coverage/js/lcov.info.

Guest coverage comes from an engine-wide instrument, which sees every source the runtime touched — the runtime’s own internals included. Reports are therefore scoped to files under the project root, and paths narrows them further.

For JVM sources, the shared table reports JaCoCo’s counters: instructions under Statements (the closest bytecode analogue, and what JaCoCo’s own reports lead with), methods under Functions, and lines as lines. Line-by-line JVM detail lives in the JaCoCo HTML and XML reports rather than the console summary.

Not yet supported

These are known gaps rather than design decisions:

AreaStatus
Snapshots (t.assert.snapshot, fileSnapshot)Not implemented
mock.moduleNot implemented
Programmatic run() and node:test/reportersNot implemented
Watch modeNot implemented
Python test discoveryNot implemented
JVM discovery without a manifestRequires a test source set

The rest of the node:test surface is implemented: test/it/describe/suite with .skip/.todo/.only, the before/after/beforeEach/afterEach hooks, subtests, t.assert, t.plan, per-test timeouts, and the mock and fake-timer APIs.