- One annotation —
@AsyncTesthammers your code with N threads × M invocations using aCyclicBarrierto force maximum contention. No executor boilerplate, no manualCountDownLatch, noThread.joinloops. - 127 detectors — deadlocks, race conditions, virtual-thread pinning, lifecycle bugs, misused JDK types, JDBC sharing, MessageDigest/SecureRandom/Cipher integrity, and more — all on by default (
detectAll = true), or pick aPresetfor a curated subset. Deadlock detection needs zero configuration; most other detectors observe what the test body records explicitly or what the optional agent weaves, and the runner says so at INFO the first time agent-backed detection is inactive. Measured firing behavior, including where detectors flag correct-but-shared code, is published in the detector-accuracy eval. - JUnit 5 native — zero required configuration. Works anywhere JUnit 5 runs with no special JVM flags. An optional Java agent, shipped as a separate
async-test-agentartifact (-javaagent:async-test-agent.jar), weaves JavaBean accessors with Byte Buddy so detectors observe reads and writes without hand-written hooks; a field touched only inside a method body is not observed. Default usage needs no agent, and the core artifact does not carry Byte Buddy. - CI-ready out of the box — ship JUnit XML reports, machine-readable JSON, or
AssertionErrorfail-gates directly to GitHub Actions, Jenkins, and GitLab CI.
▶ Watch the walkthrough on YouTube
-
Add the dependency to
pom.xml:<dependency> <groupId>se.deversity.async-test-lib</groupId> <artifactId>async-test-lib</artifactId> <version>1.7.0-RC8</version> <scope>test</scope> </dependency>
-
Write your first stress test:
import se.deversity.asynctest.AsyncTest; class CounterTest { private int counter = 0; @AsyncTest(threads = 10, invocations = 100, detectAll = true) void counter_mustBeThreadSafe() { counter++; // Race condition — async-test will catch it } }
-
Run your tests:
mvn test
-
Add the dependency to
build.gradle.kts:testImplementation("se.deversity.async-test-lib:async-test-lib:1.7.0-RC8") -
Write your first stress test:
import se.deversity.asynctest.AsyncTest; class CounterTest { private int counter = 0; @AsyncTest(threads = 10, invocations = 100, detectAll = true) void counter_mustBeThreadSafe() { counter++; // Race condition — async-test will catch it } }
-
Run your tests:
./gradlew test
- What is async-test?
- Detectors
- Configuration
- Examples
- CI/CD Integration
- IntelliJ Plugin
- Documentation
- License
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
127 detectors enabled by default with a single flag, or cherry-pick:
// Everything on (default for bare @AsyncTest)
@AsyncTest
// Curated preset for everyday CI
@AsyncTest(preset = Preset.ESSENTIALS)
// Everything on except false sharing (too slow for this suite)
@AsyncTest(excludes = { DetectorType.FALSE_SHARING })
// Explicit opt-in
@AsyncTest(detectAll = false, detectDeadlocks = true, detectRaceConditions = true)| 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, System.gc() in tests, deprecated thread API (Thread.stop() etc.) |
| Environment | Uncommitted Git changes (reproducibility gate) |
| 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
detectAlland thePreset.ALL/STRICTbundles, each with aDetectorTypeconstant (STABLE_VALUE_MISUSE,STRUCTURED_TASK_SCOPE_MISUSE,GATHERER_CONCURRENCY_MISUSE,LAZY_CONSTANT_MISUSE,FINAL_FIELD_MUTATION,SHARED_KDF) and a deprecated@AsyncTestboolean flag. Record events against them via the matchingAsyncTestContextaccessors (stableValueMisuseDetector()…lazyConstantMisuseDetector(),finalFieldMutationDetector(),sharedKdfDetector()); findings surface through the standard report andfailOngate.VirtualThreadPinningDetectoris JDK-version-aware since 1.7.0:synchronized/Object.waitevents are annotated as no-longer-pinning on JDK 24+ (JEP 491), class-init waits on JDK 26+. See docs/DETECTOR_CATALOG.md.
@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 reproduce
)| 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.ALL |
Curated bundle: ALL / STRICT / ESSENTIALS / CI_FAST / NONE |
detectAll |
true | Enable all detectors in one shot (honored when preset = ALL) |
includes |
{} |
Enable exactly these detectors — overrides preset/detectAll/per-detector flags when non-empty |
excludes |
{} |
Detectors to skip — layers on top of any preset or includes and wins on conflict |
failOn |
FailOn.NONE |
Severity gate: findings at/above this level (LOW/MEDIUM/HIGH/CRITICAL) fail the test; NONE = report-only |
replaySeed |
0 | Per-round RNG seed. 0 = fresh per round (printed on failure); set explicitly to reproduce a failing schedule |
@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.
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 # enforceEach baseline line is com.example.MyTest#method | DetectorName — diff-friendly and
hand-editable; delete lines as you fix the findings.
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()class LockTest {
private final Object lockA = new Object();
private final Object lockB = new Object();
@AsyncTest(threads = 4, invocations = 50, detectDeadlocks = true)
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
}
}
}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());
}
}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+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 typeclass 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));
}
}@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
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());
}- 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
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:
- Build the plugin:
cd intellij-plugin && ./gradlew buildPlugin - Install in IntelliJ: Settings → Plugins → Install Plugin from Disk
- Point it at your report: Settings → Tools → async-test
- Run your tests, then click Refresh in the tool window
See intellij-plugin/README.md for full instructions.
| Resource | Description |
|---|---|
| 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 |
PolyForm Noncommercial License 1.0.0 — free for non-commercial use.
| 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 |
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.
Set your email identity when using a real key: -Dlicense.user.email=you@example.com

