Skip to main content

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​

ToolUsed for
JUnit (Jupiter)every Java test, including parameterized tests
AssertJassertions
jqwikproperty-based tests of codecs and parsers
Jimfsin-memory file systems for simulations whose many nodes would otherwise wait on the disk
openapi-parserchecking that the API's document is a valid OpenAPI 3.2 description
Vitest with happy-domthe interface's tests
cargo testthe 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: the given...() steps, if any, then one when...(), then one or more then...().
  • 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, and thenExceptionOccurred(...) or thenNoExceptionOccurred() 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:

PropertyEffect
njmule.upnp.disabled=trueUPnP stays off: a test never changes your router's port mappings
njmule.defaultlists.disabled=truea new profile starts without the shipped server list and Kad contacts, so no test reaches real servers or nodes
njmule.update.disabled=truenjMule never looks for new versions on GitHub
njmule.countries.disabled=truethe 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):

TagWhat the tests doHow to run them
interoptalk 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-fixturesread 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'
perfmeasure 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.

No personal data in the repository

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:

ClassWhat it is
FakeServeran 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
FakePeeran 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
FreePortsports 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:

JobWhat 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 shellon 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 imagethe 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 packagethe 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 compliancereuse lint over the repository
actionlintlints the workflows when they change
CI resultthe 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).