Testing
Every change to njMule comes with tests, and every test reads as a short scenario. This page shows the style the project requires, the tools the tests are written with, the groups of tests that do not run by default, and what the CI runs on every push.
Tools
| Tool | Used for |
|---|---|
| JUnit (Jupiter) | every Java test, including parameterized tests |
| AssertJ | assertions |
| jqwik | property-based tests of codecs and parsers |
| Jimfs | in-memory file systems for simulations whose many nodes would otherwise wait on the disk |
| openapi-parser | checking that the API's document is a valid OpenAPI 3.2 description |
| Vitest with happy-dom | the interface's tests |
cargo test | the desktop window's tests, in Rust |
New behaviour needs tests: unit tests next to the code in each module's src/test/java, flows
across modules in the test kit (see The test kit), and the interface's logic in its
Vitest tests (see Interface tests).
The given/when/then style
Every test follows the given/when/then form of Gherkin, inside plain JUnit classes (no Cucumber):
- The test method's name states the expected behaviour (
shouldSliceInsideTheSource), and its body only calls step methods: thegiven...()steps, if any, then onewhen...(), then one or morethen...(). - A scenario has one action. Independent cases become separate tests, or one parameterized test;
the earlier stages of a longer flow become
given...()steps. - Steps are private methods of the test class, named as sentences in lowerCamelCase, taking parameters when a step is reused with other values. The state they share lives in fields.
- A
when...()step that can fail keeps the exception in a field, andthenExceptionOccurred(...)orthenNoExceptionOccurred()checks it.
A real example, from njmule-core's BytesTest (shortened):
class BytesTest {
private byte[] source;
private Bytes bytes;
private Throwable exception;
@Test
void shouldSliceInsideTheSource() {
givenTheSource(1, 2, 3, 4);
whenASliceIsTaken(1, 3);
thenTheBytesAre(2, 3, 4);
}
@ParameterizedTest
@CsvSource({"2, 3", "5, 0", "-1, 2", "0, -1"})
void shouldRefuseASliceOutsideTheSource(int offset, int length) {
givenTheSource(1, 2, 3, 4);
whenASliceIsTaken(offset, length);
thenExceptionOccurred(IndexOutOfBoundsException.class);
}
private void givenTheSource(int... values) {
this.source = toBytes(values);
}
private void whenASliceIsTaken(int offset, int length) {
this.exception = catchThrowable(() -> this.bytes = Bytes.of(this.source, offset, length));
}
private void thenTheBytesAre(int... expected) {
assertThat(this.exception).isNull();
assertThat(this.bytes.toByteArray()).containsExactly(expected);
}
private void thenExceptionOccurred(Class<? extends Throwable> type) {
assertThat(this.exception).isInstanceOf(type);
}
}
The fields are qualified with this. and the steps are called without it, as everywhere in the code
(Code style). The interface's Vitest tests follow the same
form, with given..., when... and then... functions inside each describe block.
Running tests
./gradlew build runs every test that is not excluded by a tag. To run one module's tests, or one
class:
./gradlew :njmule-core:test
./gradlew :njmule-core:test --tests '*TagCodecTest'
The options that choose and spread the tests (test.tags, test.excludedTags, test.forks,
test.maxHeap, test.parallelModules, -Pnjmule.<name>) are listed in
Building. On PowerShell, quote every -P option that holds a dot.
Each module's test classes are spread over a few JVMs, each running many classes one after the other: what a class leaves in static state, the next one finds, so tests and the engine take back what they set. In every test JVM the build also sets four system properties, so that no test reaches outside your machine by accident:
| Property | Effect |
|---|---|
njmule.upnp.disabled=true | UPnP stays off: a test never changes your router's port mappings |
njmule.defaultlists.disabled=true | a new profile starts without the shipped server list and Kad contacts, so no test reaches real servers or nodes |
njmule.update.disabled=true | njMule never looks for new versions on GitHub |
njmule.countries.disabled=true | the country database a profile asks for by default is never downloaded |
Tags
Three groups of tests are left out of the default run (test.excludedTags):
| Tag | What the tests do | How to run them |
|---|---|---|
interop | talk to a real eMule on your computer: KadInteropTest sends Kad bootstrap, hello, lookup and ping messages, and stores and searches a keyword in its index; the UDP port comes from -Pnjmule.interop.kadPort (default 14897) | '-Ptest.tags=interop' '-Ptest.excludedTags=private-fixtures,perf' |
private-fixtures | read a real eMule profile, read-only, from the folder in NJMULE_EMULE_CONFIG (downloads in progress from NJMULE_EMULE_TEMP); they print only counts, never names or hashes | '-Ptest.excludedTags=interop,perf' '-Ptest.tags=private-fixtures' |
perf | measure transfers on the loopback interface (Throughput tests) | '-Ptest.tags=perf' '-Ptest.excludedTags=interop' |
An excluded tag wins over an included one, which is why each command also narrows
test.excludedTags.
Copies of a real eMule profile, captures and screenshots of your own eMule belong in the git-ignored
fixtures-private/ folder, never in a commit.
The test kit
njmule-testkit holds test doubles that speak the real protocol, and the end-to-end tests that use
them:
| Class | What it is |
|---|---|
FakeServer | an in-process eD2K server: logs clients in with a chosen ID, greets them, answers searches with fixed results over TCP and, on the TCP port + 4, over UDP, knows the sources of the files it is given, and records what it receives |
FakePeer | an in-process client that shares one file: answers hellos and file requests, queues downloaders or gives them a slot (also by calling them back), sends blocks plain or compressed, and can end sessions early, send corrupt parts, go silent or drop its downloaders |
FreePorts | ports free for a test to bind, below the ranges systems hand out for port 0, so that another program cannot take them between the choice and the bind |
The end-to-end tests in njmule-testkit/src/test drive whole flows against these doubles: server
log-in, callbacks and searches, downloads with their limits and recovery, source exchange, the
command line, the web interface's calls and the remote-control API. Their coverage is aggregated
into one JaCoCo report with that of the launcher and the web interface.
The engine flows also run against an njmule executable instead of an engine in their JVM: with
-Pnjmule.engine=<path> each one starts that executable on its profile, as the desktop window does
(NJMULE_CONTROL_ENDPOINT=1), and drives it only through the remote-control API (ApiEngine). Its
output lands in engine.log beside the profile. The few steps the API cannot show (two calls that
must reach the engine in order, the history of cancelled files) are skipped in that mode:
./gradlew :njmule-app:nativeCompile :njmule-testkit:test "-Pnjmule.engine=$PWD/njmule-app/build/native/nativeCompile/njmule"
njmule-app/build/install/njmule/bin/njmule of :njmule-app:installDist (njmule.bat on Windows)
serves as well, without the native build.
Property-based and fuzz tests
Codecs and parsers have jqwik properties (@Property) over generated input: tags, packets, hashes,
links, search expressions, data files and more. MutatedMessagesTest fuzzes the decoders: it
takes messages of the network built right, changes them 5,000 times as a broken or hostile peer or
server could, and checks that the decoders refuse them only with a DecodeException, never with
another exception.
The eD2K and AICH hashes and the protocol obfuscation are also checked against golden vectors made
by an independent Python implementation (tools/golden/), stored in
njmule-core/src/test/resources/golden/.
A corpus in the same folder keeps a decoder's inputs with the answers the engine gives them, so that
another engine can be checked against them. tag-codec.txt holds tags, tag lists and Kad tag lists,
written right and changed by Mutations, with the bytes each read takes and the tags written again,
or the refusal. TagCodecCorpusTest checks that the engine still answers so. After a change meant
to alter the answers, write them again and review the difference:
./gradlew :njmule-core:test --tests '*CorpusTest' -Pnjmule.corpus.write=true
The inputs stay the same; delete the file to draw new ones from the jqwik generators.
Interface tests
The interface's tests are in njmule-ui/src/__tests__/*.test.ts, run by Vitest on happy-dom, a
browser's document without a browser. They test the interface's logic, such as the formatting of
sizes and rates, the search syntax, the texts and languages, the places a guest may open, the
options and the command palette, against the API's types generated from openapi.yaml:
./gradlew :njmule-ui:unitTest
./gradlew build runs them too, with the Prettier check; in njmule-ui, npm run test runs them
alone. See Interface.
Desktop window tests
The desktop window's shell has Rust unit tests beside its code in njmule-desktop/src-tauri/src:
the eD2K links it is handed, its options, the window's place on the screen, the connections to
other njMules it remembers, and the commands of the installer and of an update. They are not part
of ./gradlew build, which needs no Rust; run them in njmule-desktop/src-tauri once
./gradlew :njmule-ui:site has built the interface the shell embeds:
cargo test --locked --release
The CI runs them, with cargo fmt --check and Clippy, on Linux, Windows and macOS
(Building). Before packing, the release workflow also starts each
desktop window with .github/scripts/desktop-smoke-test.ps1 or desktop-smoke-test.sh: the window
tells its version, starts the engine on a profile of its own and takes it along when closed
(Packaging).
Pictures of the interface
This site's screenshots are taken from the interface itself, on a demo engine: WebCaptureDemo, in
njmule-http's tests, starts a real engine in a temporary profile with no network, answers some of
its commands with the example data of CaptureDemo (free files and documentation addresses only),
and serves it through the web interface. website/scripts/capture-web.mjs then drives the Chrome or
Edge installed on your computer through Playwright and pictures the places, menus and dialogs the
pages show, in light and in dark, and the transfers in forced colours (high contrast):
./gradlew :website:webScreenshots
The pictures land in website/build/web-captures/, one folder per set. They are no test: a step
that fails is reported as "not pictured" and the others go on, while the site's build fails on a
picture a page names but the run did not make (Documentation).
Throughput tests
Two tests of the test kit, tagged perf, measure how fast a download from a peer on the same
machine goes (DownloadThroughputTest) and how fast njMule sends a shared file to another njMule
(UploadThroughputTest), to find what holds a transfer back on a LAN. They print their speed in
MiB/s:
./gradlew :njmule-testkit:test --tests '*ThroughputTest' '-Ptest.tags=perf' '-Ptest.excludedTags=interop'
For the speed of single operations, see Benchmarks.
Continuous integration
The Build workflow (.github/workflows/build.yml) runs on every push to main, on every pull
request, and once a week on everything:
| Job | What it does |
|---|---|
| verify | ./gradlew build --continue '-Ptest.parallelModules=1' on Ubuntu, Windows and macOS, starting from the build cache of main's latest build (saved by each build of main), so the modules a change leaves alone are not tested again; on Ubuntu then ./gradlew sonar, once the SONAR_TOKEN secret exists, which waits for the quality gate and fails the job when it is red (new code under 80% covered, say); on a failure, the test reports are kept as an artifact |
| desktop shell | on Ubuntu, Windows and macOS: builds the interface, then cargo fmt --check, Clippy with warnings as errors, and cargo test; only when the desktop window, the interface, the generated icons or the workflow change, and weekly |
| native image | the native command-line executable on Linux x64 (in the pinned GraalVM container), Windows x64 and macOS arm64, built with -Ob, then started with --version and --license; on Linux also the test kit's flows against the executable (The test kit); on Windows also the service host, installed as a real service, tried and removed |
| synology package | the static musl executable and the .spk around it, checked entry by entry; only when the build, the dependencies, the native configuration or the package's files change, and weekly |
| REUSE compliance | reuse lint over the repository |
| actionlint | lints the workflows when they change |
| CI result | the one check to require: passes when every job passed or was skipped |
Changes that cannot affect the build (Markdown files, repository metadata, the branding sources,
tools/, the website) skip the build jobs; skipped jobs count as passed. A newer commit of the same
pull request or branch cancels the run of the older one. The website has a
workflow of its own (Documentation).