async-test-lib
Health Warn
- License — License: NOASSERTION
- Description — Repository has a description
- Active repo — Last push 0 days ago
- Low visibility — Only 5 GitHub stars
Code Fail
- rm -rf — Recursive force deletion command in .github/scripts/build-javadoc-site.sh
Permissions Pass
- Permissions — No dangerous permissions requested
No AI report is available for this listing yet.
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.
@AsyncTest - Asynchronous Testing Library for Java
JUnit 5 & 6 concurrency stress testing — one annotation, 135 detectors

Why async-test?
- One annotation —
@AsyncTesthammers your code with N threads × M invocations using aCyclicBarrierto force maximum contention. No executor boilerplate, no manualCountDownLatch, noThread.joinloops. - 135 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 native, 5 and 6 — zero required configuration, no special JVM flags.
@AsyncTestis a JUnit@TestTemplate, so it works from Kotlin and Groovy too. Supported range: Jupiter 5.9.3 through 6.1.2, verified per release by a CI matrix that runs the consumer fixture against every version in it — see the compatibility table. Keep whichever Jupiter your project already declares; yours wins over the library's transitive one. An optional Java agent, shipped as a separateasync-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.
⚡ Quick Start
MavenAdd the dependency to
pom.xml:<dependency> <groupId>se.deversity.async-test-lib</groupId> <artifactId>async-test-lib</artifactId> <version>1.9.1</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.9.1")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
Table of Contents
- What is async-test?
- Detectors
- Configuration
- Examples
- CI/CD Integration
- IntelliJ Plugin
- Documentation
- License
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
135 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)
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, 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
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@AsyncTest
boolean 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.
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 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.
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 — diff-friendly and
hand-editable; delete lines as you fix the findings.
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, 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
}
}
}
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
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));
}
}
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:
- 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.
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 |
License
PolyForm Noncommercial License 1.0.0 — free for non-commercial use.
Commercial licensing — pricing
Commercial use requires an annual license. One key covers your whole team — there are 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+ | €6,000 |
| 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 |
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.
Set your email identity when using a real key: [email protected]
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 oneLICENSE: validator unavailablewarning 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 forlicense.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.fileat 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 (SurefireargLineorsystemPropertyVariables), not only on the Maven process.
Exact failure semantics, the operator runbook and the offline-file format:
docs/LICENSING.md.
Reviews (0)
Sign in to leave a review.
Leave a reviewNo results found
