Skip to content

About

@AsyncTest (async-test-lib) is an enterprise-grade test framework that makes concurrency bugs reproducible and detectable. Rather than hoping random thread scheduling will expose bugs, async-test forces them to happen using synchronized barriers and then diagnoses exactly what went wrong using specialized detectors.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

11 stars

Watchers

0 watching

Forks

Latest commit

 

History

2,481 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@AsyncTest - Asynchronous Testing Library for Java

JUnit 5 & 6 concurrency stress testing — one annotation, 149 detectors

Maven Central Javadoc License: PolyForm Noncommercial Build codecov Java Lines of Code OpenSSF Scorecard Checkstyle PMD SpotBugs find-sec-bugs NullAway ArchUnit Analyzed with codekoll Error Prone PIT Mutation Testing

@AsyncTest catching a lost update: six threads read, increment and write back a shared counter, and the race detector names the field, the thread count and the interleavings it observed


Why async-test?

  • One annotation — @AsyncTest runs your test body on N threads × M rounds, collided on a CyclicBarrier so every round starts at the same instant. No executor boilerplate, no CountDownLatch, no Thread.join loops.
  • 149 detectors — deadlocks, race conditions, virtual-thread pinning, lifecycle bugs, misused JDK types and more; a bare @AsyncTest runs the 12 in Preset.ESSENTIALS and detectAll = true runs them all. See Detectors for what feeds them.
  • JUnit native, 5 and 6 — a plain @TestTemplate: no JVM flags, no required configuration, and it works from Kotlin, Groovy, Scala and Clojure. Jupiter 5.9.3 through 6.1.2, verified per release (compatibility table, language notes).
  • Every finding says how far to trust it — each detector carries a trust tier, so failOn can gate a merge on the measured end of the scale while everything else is still reported (the tiers, Evidence).
  • Optional agent — async-test-agent rewrites field accesses, collection and lock calls, shared JDK objects, coordination primitives, Object.wait and notify, Thread.sleep, System.gc, Thread.start and Thread.setDaemon, and the monitor of synchronized (owner.field), with Byte Buddy, so 53 of them can see code you did not modify instead of 4. Being able to see is not the same as firing: on 82 third-party subjects two of the 21 exposed at the time produced every finding, and the other nineteen were correctly silent because nothing in that corpus writes the idiom they model. Not needed for default use, and the core artifact does not depend on Byte Buddy (docs/AGENT.md).
  • CI-ready out of the box — JUnit XML, machine-readable JSON, SARIF, or plain AssertionError fail-gates, straight into GitHub Actions, Jenkins and GitLab CI.


Evidence: what has been measured, and on whose code

A concurrency detector is easy to make loud and hard to make right, so this project publishes two independent bodies of evidence and the denominator each was measured over. Neither is a claim about the JVM ecosystem. Both are reproducible from this repository.

The corpus: 139 subjects nobody here wrote

corpus-eval/ runs @AsyncTest over 139 subjects from commons-lang3, commons-collections4, Guava, Jackson, Caffeine, Netty, Spring, HikariCP and the JDK whose own javadoc states a thread-safety contract. That sentence is the ground truth, quoted in the corpus table with the file and line it came from, so a reader can check the classification without trusting this project. A finding on a class documented as thread-safe is noise; a finding on one documented as not thread-safe is a true positive. Nothing is inferred from how the code looks.

Result
Documented not thread-safe 39 of 39 detected
Documented thread-safe, with any finding at all 0 of 100
Documented thread-safe, with a VERDICT-tier HIGH or CRITICAL 0 of 100

Identical on four platforms: JDK 21, 25 and 26 on Linux, and 26 on Windows. A hundred documented-safe subjects is what puts a 95% upper bound of 3.0% on the false-positive rate; the bound comes from the size of that denominator rather than from the run of zeroes, which is why the safe side is the larger half of the corpus on purpose. The last forty were chosen before they were first run, and none was dropped for what it drew.

The zero was not tuned. The last forty safe subjects first drew PROMPT-tier findings on 2 or 3 of them per run, and both causes became rules rather than exclusions: Caffeine's striped buffer table written under a compare-and-swap spinlock the model could not see (#554), and netty's chunks and pooled buffers handed between threads through queues and atomic slots, each owner under its own lock or none (#555). They join the four before them: ConcurrentReferenceHashMap's hint read re-established under its own lock, Netty's pool metadata built while the receiver is still exclusive to its builder, Jackson's racy single-check cache recognised by how it converges, and Guava's synchronized-method fields that compile to a flag and no monitor instruction were each closed by a rule that names the idiom and ships a twin pair in both directions. Detection stayed at the full unsafe group through every one of them, which is the number that matters while chasing the noise column: a rule that quietens a false positive by weakening detection has not fixed anything.

The eval prints its denominator before any rate, because a finding count on its own cannot tell "no false positive from detector X" apart from "X never ran". In code that records nothing, only 21 of the 146 shipped when this was first measured could see anything at all, and seven of those produced every finding in the corpus. Saying so is the difference between a measurement and a marketing number. Two more lanes exist for exactly that reason. One records what the body did, the way a user following AsyncTestContext would; the other attaches the agent and writes the bug next to its fix. Between them 149 of the 149 detectors are paired with a case that must fire and a twin that must stay silent, and a detector with no pair fails the build. 35 of those pairs are what let their detectors carry VERDICT, the tier a build can fail on; a pair alone is not enough, because the detector must also decide from something other than the test's own record call. HikariCP joins the corpus as an eighth library in the recording lane, because a connection pool is the one subject that cannot be exercised without something to pool. The idioms lane writes correct user-code concurrency the way a test body would, a queue hand-off, a volatile flag, a latch, a guarded wait loop, each beside its broken twin and with every detector on: a correct idiom fails the run on any finding at FACT or above, and the correct idioms the happens-before model does not see yet are listed as known gaps with the reason, so that a fix flips them visibly.

The library agent pairs put the woven JDK call inside Guava, Jackson, HikariCP, Spring, commons-lang3 or Groovy instead of the test file, so 19 of the 49 agent-fed detectors are measured on a call site nobody here compiled; EXPLICIT_GC, COMPLETABLE_FUTURE_OBTRUDE_ABUSE, MISSED_SIGNAL, ABA_PROBLEM, SYNCHRONIZED_NON_FINAL, CONCURRENT_MAP_CHECK_THEN_ACT, EXECUTOR_SHUTDOWN, FUTURE_IGNORED, the three CompletableFuture detectors, the two interrupt detectors and SYNCHRONIZED_COLLECTION_ITERATION, SYSTEM_PROPERTY_MUTATION and PROCESS_STREAM_STARVATION fed by the agent have a JDK pair only, because no corpus library waits behind an if, pushes a popped node back, reassigns a field it synchronizes on, checks a concurrent map before acting on it, leaves a pool it created running, drops a failing task's future, blocks inside a callback or on the common pool, or waits for a child process. Their first run found a detector that dropped every sleep held under a ReentrantLock, which the test-file pair could not see because it sleeps inside a synchronized method.

The method, the misses, the four platforms it was run on and what the numbers do not support are in the corpus eval.

Three real projects, on every release

The library's own CI proves the library builds. It does not prove the library can be upgraded to, which is a different claim and the one that has actually failed here before. So every release is swept through three unrelated projects that use @AsyncTest for their own reasons, each with its own concurrency surface, its own build system and its own idea of what a hard test is:

Project What it is Concurrency it puts under @AsyncTest Build
BlindBean Fully homomorphic encryption for Java, over Microsoft SEAL through Project Panama's FFM 7 test classes, 29 methods: ciphertext lifecycle and close races, key rotation under load, the Paillier signed path, and the FHE native bridge, where a data race crosses into C++ and out of the JVM's reach Maven
VibeTags AI guardrails for Java: an annotation processor that generates agent-facing rule files from @AI* annotations 5 test classes: the guardrail file writer, the module sidecar, the write cache and the logger, all of which are written concurrently by an annotation processor running inside javac Maven and Gradle, both declared separately and bumped together
Skill3 A fully local AI skill relearner 1 test class, 8 methods: shared ObjectMapper, process-resource management, a parallel retrieval path and several stateless-under-concurrency claims Gradle

Why these three are worth more than a bigger number of synthetic cases:

  • None of them is a test fixture. They were written to do a job, and their concurrency is whatever that job required. A fixture written to exercise a detector will exercise it; a homomorphic-encryption library will not politely arrange itself around one.
  • They span the two failure directions. BlindBean's FHE bridge is native code where a race produces a segfault rather than a wrong answer, and VibeTags' processor runs inside javac, where the threading model is somebody else's. Both are places a false positive is expensive, because the maintainer cannot easily prove the detector wrong.
  • Two build systems, and both are run. VibeTags declares the dependency in Maven and in Gradle separately. Bumping one and not the other leaves its two tiers on different detector engines, so the sweep bumps both or neither, and runs both.
  • The tag matters, and the sweep knows it. Most of VibeTags' @AsyncTest classes carry @Tag("e2e"). A plain mvn test reports 957 green tests having run one of its five async classes, which would sign off on a bump that never touched the other four. The sweep runs -Pe2e and counts the async classes that actually ran, from the result XML rather than from the console: Gradle prints BUILD SUCCESSFUL without listing a single test, so stdout cannot answer the question at all.

The sweep bumps each consumer to the published artifact, never to a working-tree build. That rule is not pedantry: a poisoned ~/.m2 once made a sweep read the wrong bytes and produce a confident, wrong account of which release removed a detector constant. The preflight now compares sha1 against Maven Central before anything downstream is believed.

The rules, the failure each one prevents and the numbers from the latest sweep are in the regression sweep write-up; the procedure itself is the regression-test skill.

Latest run, 1.9.8 on 2026-08-25: 2793 tests across the three projects, zero failures, and no change to any consumer beyond the version string. Two of the three were four releases behind, so one sweep exercised eleven new detectors and the whole agent lockset overhaul at once.

⚡ Quick Start

Upgrading from 1.12? 1.13.0 removes the deprecated per-detector @AsyncTest attributes and makes a bare @AsyncTest run Preset.ESSENTIALS instead of every detector. Read docs/migration/1.13.0.md before bumping.

Maven
  1. Add the dependency to pom.xml:

    <dependency>
        <groupId>se.deversity.async-test-lib</groupId>
        <artifactId>async-test-lib</artifactId>
        <version>1.14.1</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>se.deversity.async-test-lib</groupId>
        <artifactId>async-test-agent</artifactId>
        <version>1.14.1</version>
        <scope>test</scope>
    </dependency>

    The second artifact is the instrumentation agent. Start the test JVM with it, in <build><plugins>: dependency:properties puts the agent jar's path in a property, and surefire's argLine hands it to -javaagent:.

    <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-dependency-plugin</artifactId>
        <version>3.11.0</version>
        <executions>
            <execution>
                <goals><goal>properties</goal></goals>
            </execution>
        </executions>
    </plugin>
    <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <configuration>
            <argLine>-javaagent:${se.deversity.async-test-lib:async-test-agent:jar}=fields=true,collections=true,includes=com.example</argLine>
        </configuration>
    </plugin>

    Replace com.example with your own package prefix: the agent weaves only classes whose name starts with it. If JaCoCo or another plugin also sets argLine, start the line with @{argLine} so both agents load. CI builds this exact snippet in consumer-fixture-langs/quickstart and fails if it stops attaching the agent or stops finding the example's race.

  2. Write your first stress test:

    package com.example;
    
    import se.deversity.asynctest.AsyncTest;
    import se.deversity.asynctest.FailOn;
    
    class CounterTest {
        private int counter = 0;
    
        @AsyncTest(threads = 10, invocations = 100, detectAll = true,
                   failOn = FailOn.CRITICAL)
        void counter_mustBeThreadSafe() {
            counter++;  // compound read-modify-write: not atomic
        }
    }

    The package matters: the agent weaves only classes under step 1's includes= prefix, and a test in the default package is woven by nothing and reports nothing. Keep the two in step when you move the test into your own package.

    failOn = FailOn.CRITICAL makes a finding fail the test. The default is FailOn.NONE, which prints findings and lets the test pass — useful when adding @AsyncTest to an existing suite, surprising as a first experience.

  3. Run your tests:

    mvn test -Dlicense.mock.mode=true
    • -Dlicense.mock.mode=true: without a licence key the run stops with LICENSE DENIED. CI sets mock mode automatically (CI or GITHUB_ACTIONS in the environment); a local run needs the flag. See License.
    • The agent is what sees counter++. It compiles to a field read and a field write with no method call between them, so only instrumented bytecode shows it. Without the agent this example passes and reports nothing. Woven code runs slower: a body making 200,000 synchronized calls took 3.4 s instead of 1.1 s.
    • Skipped the plugin blocks? With async-test-agent on the test classpath but not on the command line, the runner loads it into the running JVM before the first round instead, and logs runner.agent.attached source=classpath. That works on JDK 21 to 26, but the JVM prints a four-line WARNING about it, and JEP 451 says a future JDK will refuse it; the runner then logs runner.agent.attach.failed and the example reports nothing again. Treat it as a fallback, not a setup. -Dasynctest.agent=off turns it off (attaching the agent).

    Detection that needs no agent: deadlocks, which are read from the JVM's own thread state. If you want to see the library find something with zero setup, make two threads take two locks in opposite orders.

Gradle
  1. Add the dependency to build.gradle.kts:

    val asyncTestAgent = configurations.create("asyncTestAgent") {
        isTransitive = false
    }
    
    dependencies {
        testImplementation("se.deversity.async-test-lib:async-test-lib:1.14.1")
        asyncTestAgent("se.deversity.async-test-lib:async-test-agent:1.14.1")
    }
    
    tasks.test {
        useJUnitPlatform()
        doFirst {
            jvmArgs("-javaagent:${asyncTestAgent.singleFile}=fields=true,collections=true,includes=com.example")
        }
    }

    The second artifact is the instrumentation agent, given its own configuration so the test JVM can be started with it (-javaagent:). Replace com.example with your own package prefix: the agent weaves only classes whose name starts with it. CI builds this exact snippet in consumer-fixture-langs/quickstart.

  2. Write your first stress test:

    package com.example;
    
    import se.deversity.asynctest.AsyncTest;
    import se.deversity.asynctest.FailOn;
    
    class CounterTest {
        private int counter = 0;
    
        @AsyncTest(threads = 10, invocations = 100, detectAll = true,
                   failOn = FailOn.CRITICAL)
        void counter_mustBeThreadSafe() {
            counter++;  // compound read-modify-write: not atomic
        }
    }

    The package must sit under step 1's includes= prefix: a test in the default package is woven by nothing and reports nothing. failOn = FailOn.CRITICAL makes a finding fail the test; the default FailOn.NONE prints findings and passes.

  3. Run your tests:

    ./gradlew test -Dlicense.mock.mode=true

    -Dlicense.mock.mode=true is what a local run without a licence key needs (CI activates mock mode by itself). The -javaagent: from step 1 is what sees counter++, a field read and write with no method call between them: without it this example passes and reports nothing.

    Deadlock detection needs no agent; it reads the JVM's own thread state.


Table of Contents


What is async-test?

async-test is a JUnit 5 @TestTemplate extension that stress-tests concurrent code by running the annotated method body simultaneously across N threads, repeated M times. A CyclicBarrier forces all threads to start each round at the same instant, maximising contention and surfacing bugs that only appear under real concurrency — not in sequential unit tests.

After the run, the detector registry analyses what was observed and reports any issues via the standard JUnit failure mechanism, so they surface in your IDE, CI dashboard, and test reports without any extra tooling.

@AsyncTest
  └─► ConcurrencyRunner
        ├─ CyclicBarrier (all N threads collide on each invocation)
        ├─ Phase 1–N detectors observe the run
        └─ DetectorRegistry.analyzeAll() → JUnit failure / listener events

Detectors

149 detectors. A bare @AsyncTest runs the curated ESSENTIALS preset; one flag turns on every detector, or cherry-pick:

// The default: Preset.ESSENTIALS, 12 high-signal detectors
@AsyncTest

// Everything on
@AsyncTest(detectAll = true)

// Everything on except false sharing (too slow for this suite)
@AsyncTest(detectAll = true, excludes = { DetectorType.FALSE_SHARING })

// Explicit opt-in
@AsyncTest(includes = {DetectorType.DEADLOCKS, DetectorType.RACE_CONDITIONS})

To turn every detector on for a whole run without editing annotations, for an audit or a nightly job, pass -Dasync-test.detectAll=true (mvn test -Dasync-test.detectAll=true; in Gradle, systemProperty("async-test.detectAll", "true") on the test task). It reads as detectAll = true on every @AsyncTest: includes still select exactly their list and excludes still apply.

What feeds them. Four read the JVM and the harness directly and need no configuration at all. With the agent attached that becomes 52, because the woven streams carry what 48 more detectors need without a line of instrumentation. The remaining 96 observe what the test body records explicitly through AsyncTestContext, and the catalog says which of three reasons keeps each of them there. When a detector is enabled but nothing can feed it, the runner says so once per JVM at INFO rather than letting an empty report read as a clean bill of health; docs/agent/diagnostics.md lists those notices. Which detectors fall in which group is tabulated in docs/DETECTOR_CATALOG.md, and how they behave on code nobody here wrote is in the accuracy eval.

Detector categories

Category What it catches
Core Deadlocks, livelocks, memory-model visibility (volatile gaps)
Race conditions Unsynchronized field access, non-atomic compound ops (get+set on Atomic*)
Common JDK types ArrayList, HashMap, StringBuilder, Calendar, SimpleDateFormat, DecimalFormat, Matcher, MessageDigest, TimeZone, Timer shared across threads
Virtual threads Thread pinning (JDK-version-aware: synchronized pins only before JDK 24/JEP 491, class-init waits before JDK 26, native calls always), CPU-bound tasks, carrier exhaustion, ScopedValue misuse, context leak
Locks & monitors Boxed-primitive lock (synchronized(Integer)), public lock exposure, lock leak, nested monitor lockout, StampedLock optimistic-read without validate(), lock downgrade
Lifecycle Executor never shut down, thread leak, Future result ignored, CountDownLatch misuse, CyclicBarrier trip count wrong
Concurrency primitives CompletableFuture chain issues, blocking on common pool, ForkJoinTask blocking, Exchanger, Phaser, Semaphore misuse
Hygiene Interrupt swallowing, MDC context leak, System.setProperty from multiple threads, Process.waitFor before the child's output is drained, System.gc() in tests, deprecated thread API (Thread.stop() etc.)
Phase 13 Daemon-thread hygiene, illegal notify*(), shared SecureRandom, shared WeakHashMap/IdentityHashMap, shared JDBC Connection/Statement/ResultSet
Phase 14 (new) Shared stateful crypto (Cipher/Mac/Signature), non-atomic ConcurrentMap check-then-act, shared Deflater/Inflater, constructor this-escape, cached ThreadLocalRandom used off-thread
Phase 16 — JDK 25/26 preview StableValue misuse (read-before-set / double-set / reentrant orElseSet), StructuredTaskScope lifecycle (fork-after-join, result-before-join, owner-confinement, missing join, and JDK 26 join-timeout hazards), parallel-Gatherer without a combiner
Phase 17 — shared stateful JDK objects Shared ByteBuffer, CharsetEncoder/Decoder, Checksum, Deflater, iterators, FileChannel implicit-position races, high-contention Atomic* advisories, JSON-mapper reconfiguration after concurrent use
Phase 18 — JDK 25/26 GA (new) LazyConstant misuse (JDK 26 Lazy Constants: reentrant / null-producing / repeat-running suppliers), reflective final-field mutation (JEP 500 — warned on JDK 26, denied later, JMM violation today), shared javax.crypto.KDF (JEP 510 — documented not thread-safe)

Full parameter reference: docs/USAGE.md

JDK 25/26 detectors are wired into the pipeline (Phases 16 and 18). They are part of detectAll and the Preset.ALL / STRICT bundles, each with a DetectorType constant (STABLE_VALUE_MISUSE, STRUCTURED_TASK_SCOPE_MISUSE, GATHERER_CONCURRENCY_MISUSE, LAZY_CONSTANT_MISUSE, FINAL_FIELD_MUTATION, SHARED_KDF) that includes and excludes name. Record events against them via the matching AsyncTestContext accessors (stableValueMisuseDetector() … lazyConstantMisuseDetector(), finalFieldMutationDetector(), sharedKdfDetector()); findings surface through the standard report and failOn gate. VirtualThreadPinningDetector is JDK-version-aware since 1.7.0: synchronized/Object.wait events are annotated as no-longer-pinning on JDK 24+ (JEP 491), class-init waits on JDK 26+. See docs/DETECTOR_CATALOG.md.


Configuration

@AsyncTest(
    threads      = 10,                       // concurrent threads per invocation round
    threadCounts = {2, 4, 8, 16, 32},        // OR: sweep multiple counts (one JUnit invocation per entry)
    invocations  = 100,                      // how many rounds to run
    timeoutMs    = 5000,                     // per-test timeout
    useVirtualThreads = true,                // Java 21+ virtual threads
    virtualThreadStressMode = "HIGH",        // OFF / LOW / MEDIUM / HIGH / EXTREME
    preset       = Preset.ESSENTIALS,        // curated detector bundle (overrides detectAll)
    detectAll    = true,                     // legacy umbrella when preset = ALL
    includes     = { DetectorType.DEADLOCKS },      // OR: exactly these detectors, nothing else
    excludes     = { DetectorType.FALSE_SHARING },  // prune even from a preset/includes
    failOn       = FailOn.HIGH,              // findings at/above this severity fail the test
    replaySeed   = 0L,                       // 0 = fresh per round; set on failure to replay it
    controlledSchedule = false               // true: the seed decides the interleaving too
)
Parameter Default Description
threads 10 Threads spawned per round
threadCounts {} Schedule matrix — one invocation per entry; ignored when empty. Sweeps thread counts cheaply since race sensitivity is count-dependent
invocations 100 Number of barrier rounds
timeoutMs 5000 Whole-test timeout (ms)
useVirtualThreads true Use Thread.ofVirtual() (Java 21+)
preset Preset.ESSENTIALS Curated bundle: ESSENTIALS / ALL / STRICT / CI_FAST / NONE
detectAll false Enable every detector, whatever preset says (includes still wins)
includes {} Enable exactly these detectors — overrides preset/detectAll when non-empty
excludes {} Detectors to skip — layers on top of any preset or includes and wins on conflict
excludeIds {} Detectors to skip by id: a third-party detector's own id(), or a built-in's DetectorType name (1.13.0+)
failOn FailOn.NONE Severity gate: findings at/above this level (LOW/MEDIUM/HIGH/CRITICAL) fail the test; NONE = report-only
replaySeed 0 Per-round seed. 0 = fresh per round (printed on failure); set explicitly to replay the seed's random choices, and with controlledSchedule the interleaving too
controlledSchedule false Run each round's workers one at a time, switching only at scheduling points (woven field accesses and substituted collection and atomic calls, AsyncTestContext.schedulePoint()) in an order the replay seed decides, so a failing seed replays its interleaving (1.13.2+, experimental)

@AsyncTest can also be placed on a class (shared config for all @TestTemplate methods; method-level @AsyncTest wins) or on an annotation to compose reusable presets like @EssentialsAsyncTest.

Fail gates & baseline

Gate CI on serious findings while adopting incrementally:

@AsyncTest(failOn = FailOn.HIGH)   // HIGH and CRITICAL findings fail the test
void checkout_concurrently() { ... }

For a legacy codebase, record the current findings once and ratchet them down:

mvn test -Dasync-test.baseline=async-test-baseline.txt -Dasync-test.baseline.update=true  # record
mvn test -Dasync-test.baseline=async-test-baseline.txt                                    # enforce

Each baseline line is com.example.MyTest#method | DetectorName | finding — diff-friendly and hand-editable; delete lines as you fix the findings. A finding the file does not name still fails, even when the same detector has other findings in the file. Two-field lines written by releases before 1.12.3 keep accepting the whole detector for that test.

On a passing run, PROMPT and ADVISORY reports print as one line each; add -Dasync-test.report.full=true to print them in full. Listeners and report files always get the full text.


Examples

Catching a race condition

class CounterTest {
    private int counter = 0;

    @AsyncTest(threads = 10, invocations = 100, detectAll = true)
    void increment_mustBeAtomic() {
        counter++;  // BUG: compound read-modify-write, not atomic
    }
}
// Fix: use AtomicInteger.incrementAndGet()

Catching a deadlock

class LockTest {
    private final Object lockA = new Object();
    private final Object lockB = new Object();

    @AsyncTest(threads = 4, invocations = 50, includes = DetectorType.DEADLOCKS)
    void acquireLocks() {
        if (Thread.currentThread().getId() % 2 == 0) {
            synchronized (lockA) { synchronized (lockB) { /* work */ } }
        } else {
            synchronized (lockB) { synchronized (lockA) { /* work */ } }
            //  ^^^ opposite order — deadlock waiting to happen
        }
    }
}

Virtual thread stress test

class VirtualThreadTest {
    private final List<String> items = Collections.synchronizedList(new ArrayList<>());

    @AsyncTest(
        threads = 100_000,
        invocations = 5,
        useVirtualThreads = true,
        virtualThreadStressMode = "EXTREME",
        detectAll = true
    )
    void highConcurrency() {
        items.add("item-" + Thread.currentThread().threadId());
    }
}

Sweep thread counts to find the contention sweet spot

class HashMapCacheTest {
    private final Map<String, String> cache = new HashMap<>(); // BUG: not thread-safe

    @AsyncTest(threadCounts = {2, 4, 8, 16, 32, 64})  // 6 separate JUnit invocations
    void put_thenGet() {
        cache.put(UUID.randomUUID().toString(), "v");
    }
}
// JUnit emits one test per count; race condition surfaces reliably at 16+

Async test body — await a CompletionStage inside @AsyncTest

class AsyncPipelineTest {
    @AsyncTest(threads = 8)
    void hammer_pipeline() {
        CompletableFuture<String> stage = service.processAsync(payload);
        String result = AsyncAssert.awaitAsync(stage, Duration.ofSeconds(5));
        assertEquals("ok", result);
    }
}
// awaitAsync unwraps ExecutionException — failures surface as the real exception type

Declare the body void and await inside it. JUnit runs no template method that returns a value, so CompletableFuture<Void> hammer_pipeline() would never run; since 1.15.0 such an @AsyncTest fails discovery with a message naming the method, instead of passing with nothing tested (#1119). The same goes for a private or static @AsyncTest, which JUnit also skips with only a warning, and for a Kotlin suspend fun, which the runner cannot call (#1146). A method annotated @TestTemplate in a class annotated @AsyncTest takes the class's settings, so it is checked the same way; a @ParameterizedTest or other composed template in that class is left to JUnit (#1149).

Make the workers meet mid-body, and count what they did

class LazyInitTest {
    private static final RunOutcomes OUTCOMES = new RunOutcomes();

    @AsyncTest(threads = 8, invocations = 100)
    void initialise() {
        Config config = Config.load();               // each worker prepares its own input
        AsyncTestContext.rendezvous();               // all 8 meet here before anyone initialises
        if (service.initialiseIfNeeded(config)) OUTCOMES.record("initialised");
    }

    @AfterAll
    static void ranOnce() {
        OUTCOMES.assertExactlyOnce("initialised");   // names the threads if it ran twice
    }
}

Reproduce a flaky failure

class FlakyTest {
    @AsyncTest                                          // 1st run: failure prints replaySeed=4242L
    @AsyncTest(replaySeed = 4242L)                      // re-run with the printed seed
    void randomised_workload() {
        var rng = new Random(AsyncTestContext.replaySeed());
        Thread.sleep(rng.nextInt(10));                  // randomised jitter is now deterministic
        service.handle(payload(rng));
    }
}

The seed alone replays the body's random choices, not the thread schedule: the OS still decides which worker runs when. controlledSchedule = true hands that decision to the seed too. Workers run one at a time and change hands only at scheduling points: every field access the agent weaves with fields=true, every collection, concurrency-utility and atomic call it substitutes, and every AsyncTestContext.schedulePoint(), so the seed a failing round prints replays its interleaving:

class LostUpdateTest {
    private int count;

    @AsyncTest(threads = 2, invocations = 200, controlledSchedule = true)  // each round tries a schedule
    // @AsyncTest(threads = 2, controlledSchedule = true, replaySeed = 4242L) // the failing one, every time
    void increment() {
        int read = count;
        AsyncTestContext.schedulePoint();   // not needed with the agent: the read and write are points
        count = read + 1;
    }
}

Workers run one at a time, so a controlled round explores interleavings, not memory-model reorderings. A worker that blocks without reaching a point (a lock its paused peer holds, a sleep, a get) leaves the schedule until its next point; the failure line says when that happened, since only a round without such an escape is guaranteed to replay exactly.

Scoped listener (no JVM-wide leak)

@Test
void capture_findings_for_one_test() {
    try (var ignored = AsyncTestListenerRegistry.registerScoped(myListener)) {
        // myListener fires only inside this block
        runMyAsyncTest();
    }
    // automatic unregister on close
}

More examples with runnable code: examples/

Release notes: docs/CHANGELOG.md


CI/CD Integration

Register a listener in @BeforeAll to get structured output alongside the standard JUnit failure:

@BeforeAll
static void setup() {
    // JUnit XML → GitHub Actions / Jenkins / GitLab CI test dashboards
    AsyncTestListenerRegistry.register(new JUnitXmlReportListener());

    // Structured JSON → dashboards, quality gates, custom tooling
    AsyncTestListenerRegistry.register(new JsonReportListener());

    // Throw AssertionError immediately on any finding
    AsyncTestListenerRegistry.register(new StrictModeListener());
}

GitHub Actions example

- name: Run tests
  run: mvn test

- name: Upload async-test reports
  uses: actions/upload-artifact@v4
  if: always()
  with:
    name: async-test-reports
    path: target/async-test-reports/

Findings appear as named test-case failures in the Actions UI — not just as stderr noise.

Flaky-test policy: the build does not configure Surefire's rerunFailingTestsCount — an intermittently failing @AsyncTest is a detector finding a real concurrency bug, not infrastructure noise, so we don't auto-rerun it away.

Scaling timeouts on slow/shared runners: every @AsyncTest(timeoutMs=...) budget can be scaled globally with -Dasync-test.timeout.multiplier=<factor> or the ASYNC_TEST_TIMEOUT_MULTIPLIER environment variable (precedence: system property, then env var, then 1.0). Use this instead of bumping individual annotations when detector setup overhead eats into a short timeout on a slow or oversubscribed runner (e.g. a 3-core macOS or Windows CI box) — an invalid or non-positive value falls back to 1.0. Prefer the env var in CI: it propagates automatically into Surefire's forked test JVMs, whereas a -D passed to the outer mvn process does not.

Full CI/CD setup guide: docs/CI_INTEGRATION.md


IntelliJ Plugin

A companion IntelliJ IDEA plugin reads the JSON report and surfaces findings in a dedicated tool window — with severity colouring, full report text, and a Refresh action to pick up new results without leaving the IDE.

View → Tool Windows → async-test Findings

Setup:

  1. Build the plugin: cd intellij-plugin && ./gradlew buildPlugin
  2. Install in IntelliJ: Settings → Plugins → Install Plugin from Disk
  3. Point it at your report: Settings → Tools → async-test
  4. Run your tests, then click Refresh in the tool window

See intellij-plugin/README.md for full instructions.


Project layout

async-test-lib/          The library: @AsyncTest, the runner, and every detector.
                         This is the artifact consumers depend on.
async-test-agent/        Opt-in Java agent (Byte Buddy) that weaves field and
                         collection accesses so detectors see unmodified code.
async-test-analysis/     Opt-in ASM pre-scanner for virtual-thread pinning sites.
                         Depends on nothing else here.

consumer-fixture/        A consumer-shaped project proving every detector is
                         reachable through the published API.
consumer-fixture-langs/  The same proof from Kotlin, Groovy, Scala and Clojure.
corpus-eval/             Runs the detectors against real third-party libraries
                         and gates the false-positive and detection rates.
load-tests/              JMH throughput and memory benchmarks, with the measured
                         results pinned per release under results/.
evals/                   Evals that measure whether coding agents actually obey
                         this repository's instruction files.

examples/                Self-contained example projects, each demonstrating a
                         concurrency defect against the release its pom pins.
intellij-plugin/         IDE tool window that renders the JSON report.
async-test-gradle-plugin/
async-test-maven-plugin/ Build plugins that attach the agent at startup in one block,
                         published with each release from 1.14.0 on (#1099).
tools/                   Release and licensing utilities, and the demo.
docs/                    All documentation, routed from docs/INDEX.md.

Only the first three are Maven reactor modules; they and the Maven plugin are what publishes to Maven Central, and the Gradle plugin goes to the Gradle Plugin Portal. The fixture, corpus, load-test and example projects consume the library the way a user would, which is what makes their results evidence; the rest is documentation and tooling around them.


Documentation

Resource Description
API reference Javadoc for every public type, one directory per release. All versions
docs/USAGE.md Full @AsyncTest parameter reference, all detectors, examples
docs/CI_INTEGRATION.md GitHub Actions, Jenkins, GitLab CI setup
docs/ARCHITECTURE.md Execution flow, detector phases, extension points
docs/CHANGELOG.md Version history
examples/ 30+ runnable example projects
intellij-plugin/README.md IntelliJ plugin setup
docs/INDEX.md Documentation index — every document mapped to what it is for
docs/QUALITY_GATES.md What must stay green: static analysis, coverage, mutation testing, japicmp

Publications

Books that cover @AsyncTest and the problem it exists to solve.

Vibe Architecture: Designing, Scaling, and Guardrailing Large-Scale Systems in the Age of AI Orchestration by Peter Isberg.

Chapter 17, "The Asynchronous Trap", is about the class of bug AI-generated concurrent code produces and sequential unit tests never see. It uses this library as the worked example: forced-collision testing on a CyclicBarrier, what the detectors turn a bare timeout into, and how to feed that named diagnosis back to the assistant that wrote the bug.


License

PolyForm Noncommercial License 1.0.0 — free for non-commercial use.

Commercial licensing — pricing

Commercial use requires an annual license, priced in bands by the number of developers whose builds run the library. One key covers every developer in your band, with no per-seat keys. Prices are per year, excluding VAT or sales tax:

Developers Price (EUR/year)
1–9 €250
10–49 €900
50–199 €2,500
200–399 €6,000
400–599 €9,000
600–799 €12,000
800–999 €15,000
1,000 or more quoted by email
OEM / redistribution from €10,000, negotiated

Buy a license at deversity.se/pricing.html →

Checkout is handled by Paddle, our merchant of record, which shows prices in your local currency and handles VAT and sales tax. OEM and redistribution are negotiated — email us.

Your license key is sent by email after purchase; it is issued by hand, so allow one business day. The license is bound to one email address that you nominate — set it once in your shared build config as -Dlicense.user.email and every developer and CI job uses that same value. Operator runbook and what to send customers: docs/LICENSING.md.

Running without a license key

Environment Behavior
CI (any GITHUB_ACTIONS or CI env var set, no key) Auto-mocked — tests run freely
Local, no key, -Dlicense.mock.mode=true Mock mode active — tests run freely
Local, no key, no mock flag The gate runs and can refuse. See below
Real key via -Dlicense.key=<key> Full validation against the licensing backend, in CI too. A key without the provider ids it needs (keygen.account.id and keygen.product.id, or ls.store.id) fails with LICENSE MISCONFIGURED naming the missing one
Offline file via -Dlicense.file=<path> Signature-verified locally, no network attempted (air-gapped CI)

First run on a new machine. With no key configured and no mock flag, the gate consults the licensing backend and a denial throws, before any test body runs:

java.lang.SecurityException: LICENSE DENIED: <reason>
  To run locally without a key: -Dlicense.mock.mode=true
  In CI (GITHUB_ACTIONS or CI env var set, no key): mock mode activates automatically.

This is the gate working as intended, not a bug in your test. CI is unaffected: mock mode turns itself on there when no key is present, which is why a suite that passes in CI can still stop on a developer laptop.

To run locally without a key during development:

mvn test -Dlicense.mock.mode=true

Or add to your IDE's JVM args: -Dlicense.mock.mode=true. Setting it once in your IDE's default JUnit configuration is the usual fix, so it applies to every run rather than being remembered per-test.

Every run that proceeds without a validated commercial key (mock mode, CI auto-mock, or a free-mail address) prints a three-line licence notice to stderr, once per JVM, naming the licence, the pricing page and the contact address. It is not a warning and it does not fail anything; it goes away when a key is validated. It cannot be switched off by a flag, because it is the licence terms restated on the one path where they are easiest to forget.

Set your email identity when using a real key: -Dlicense.user.email=you@example.com

Enterprise CI: outages, proxies and air-gapped runners

Licensed builds do not depend on the licensing provider being up or reachable:

  • Provider outage or blocked egress. If the validator host cannot be reached, the build proceeds and logs one LICENSE: validator unavailable warning per JVM instead of failing. A validator that answers and rejects the key still fails the build. Restore unconditional fail-closed with -Dlicense.network.mode=strict.
  • Validation caching. A successful validation is recorded (a SHA-256 hash of the configuration, never the key) and reused for license.cache.ttl.hours (default 24), so a fork-per-class suite makes one licensing call per day rather than one per test class.
  • Air-gapped CI. Ask for an offline license file and point -Dlicense.file at it: the file is Ed25519-signed and verified inside the JVM, with no network and no provider account. An invalid file fails closed; it never falls back to online validation.
  • Proxies. Validation uses Java's standard HTTP client, so the usual -Dhttps.proxyHost=<host> -Dhttps.proxyPort=<port> flags apply. Set them on the forked test JVM (Surefire argLine or systemPropertyVariables), not only on the Maven process.

Exact failure semantics, the operator runbook and the offline-file format: docs/LICENSING.md.

About

@AsyncTest (async-test-lib) is an enterprise-grade test framework that makes concurrency bugs reproducible and detectable. Rather than hoping random thread scheduling will expose bugs, async-test forces them to happen using synchronized barriers and then diagnoses exactly what went wrong using specialized detectors.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

11 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages