Guide contents

Lesson 23 of 71

Testcontainers and Testing Concurrent Code

Unit tests with mocks check one class. An integration test checks that several real parts work together: code plus a database, a file system, or a message broker. This lesson covers how to structure such tests, how Testcontainers starts real services for them, and how to make two hard inputs testable: time and thread scheduling. Every runnable example uses the launcher helper from JUnit 5, Example 1, unchanged at the end of each program.

Topic Tool
Real dependency in a test @TempDir, Testcontainers PostgreSQLContainer
Time java.time.Clock passed into the class
Thread start order CountDownLatch as a start gate
Rare races repeated runs, forced interleavings
Scheduled work a manual scheduler driven by the test
JVM memory model outcomes jcstress (described)

The concurrency examples use only Thread and CountDownLatch. A Thread runs a Runnable on its own call stack after start(), and join() waits for it to finish. A CountDownLatch(n) blocks callers of await() until countDown() has been called n times. Both are covered in depth in Locks and Synchronizers, later in the course.

Example 1: integration test structure with a real file system

An integration test has the same three parts as a unit test (arrange, act, assert), plus setup and cleanup of the real resource. Each test gets a fresh resource so tests do not depend on each other's data. Here the resource is a directory from @TempDir, and the class under test stores records in a real file.

Java
import static org.junit.jupiter.api.Assertions.*;

import java.io.IOException;
import java.nio.file.*;
import java.util.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.platform.engine.discovery.DiscoverySelectors;
import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
import org.junit.platform.launcher.core.LauncherFactory;
import org.junit.platform.launcher.listeners.SummaryGeneratingListener;
import org.junit.platform.launcher.listeners.TestExecutionSummary;

class FileTradeStore {
    private final Path file;
    FileTradeStore(Path file) { this.file = file; }

    void append(String id, long qty) throws IOException {
        Files.writeString(file, id + "," + qty + "\n",
            StandardOpenOption.CREATE, StandardOpenOption.APPEND);
    }

    Map<String, Long> load() throws IOException {
        Map<String, Long> out = new TreeMap<>();
        if (!Files.exists(file)) return out;
        for (String line : Files.readAllLines(file)) {
            String[] p = line.split(",");
            out.merge(p[0], Long.parseLong(p[1]), Long::sum);
        }
        return out;
    }
}

class FileTradeStoreIT {
    @TempDir Path dir;
    FileTradeStore store;

    @BeforeEach void setUp() { store = new FileTradeStore(dir.resolve("trades.csv")); }

    @Test void emptyWhenNoFile() throws IOException {
        assertEquals(Map.of(), store.load());
    }

    @Test void survivesReopen() throws IOException {
        store.append("t1", 100);                                  // act
        store.append("t1", -30);
        FileTradeStore reopened = new FileTradeStore(dir.resolve("trades.csv"));
        assertEquals(Map.of("t1", 70L), reopened.load());         // assert on real data
    }
}

public class FileStoreDemo {
    public static void main(String[] args) {
        run(FileTradeStoreIT.class);
    }

    static void run(Class<?>... testClasses) {
        var request = LauncherDiscoveryRequestBuilder.request()
            .selectors(Arrays.stream(testClasses).map(DiscoverySelectors::selectClass).toList())
            .build();
        var listener = new SummaryGeneratingListener();
        LauncherFactory.create().execute(request, listener);
        TestExecutionSummary s = listener.getSummary();
        System.out.printf("tests: found=%d succeeded=%d failed=%d aborted=%d skipped=%d%n",
            s.getTestsFoundCount(), s.getTestsSucceededCount(), s.getTestsFailedCount(),
            s.getTestsAbortedCount(), s.getTestsSkippedCount());
        s.getFailures().stream()
            .map(f -> "FAILED " + f.getTestIdentifier().getDisplayName() + ": "
                + String.valueOf(f.getException().getMessage()).lines().findFirst().orElse(""))
            .sorted()
            .forEach(System.out::println);
    }
}
Output
tests: found=2 succeeded=2 failed=0 aborted=0 skipped=0

The IT suffix is the Maven Failsafe plugin's default naming pattern for integration tests, which keeps them out of the faster unit test run (Surefire). The test reopens the store to prove that data reached the disk and was not only kept in memory.

Example 2: Testcontainers with PostgreSQL

Testcontainers is a library that starts Docker containers from test code and stops them afterwards. The test then talks to a real PostgreSQL, Kafka, or Redis instead of a fake. The code below needs Docker and the Testcontainers and PostgreSQL JDBC jars, none of which are available to this site's checker, so it is marked ignore and has no output. It uses the 1.x package names (org.testcontainers.containers).

Java
import static org.junit.jupiter.api.Assertions.assertEquals;

import java.sql.*;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@Testcontainers
class AccountRepositoryIT {
    @Container
    static PostgreSQLContainer<?> pg = new PostgreSQLContainer<>("postgres:16-alpine");

    @Test void insertAndRead() throws SQLException {
        try (Connection c = DriverManager.getConnection(
                pg.getJdbcUrl(), pg.getUsername(), pg.getPassword());
             Statement st = c.createStatement()) {
            st.execute("create table account(id int primary key, owner text)");
            st.execute("insert into account values (1, 'ann')");
            ResultSet rs = st.executeQuery("select owner from account where id = 1");
            rs.next();
            assertEquals("ann", rs.getString(1));
        }
    }
}

How the lifecycle works:

Field Container lifetime
static field with @Container Started once before the first test of the class, stopped after the last
Instance field with @Container Started and stopped around every test method

@Testcontainers is a JUnit extension (the extension model from the JUnit lesson) that finds the @Container fields and calls start() and stop(). The container listens on a random free host port, so getJdbcUrl() must be read at run time and never hard-coded. java.sql.DriverManager opens a JDBC connection from a URL, user, and password; JDBC itself is outside this course.

Example 3: sharing and reusing containers

Starting PostgreSQL takes seconds, so many test classes share one container. The singleton container pattern starts it in a static initializer of a base class and never stops it explicitly; Testcontainers' cleanup container (Ryuk) removes it when the JVM exits. Same reason for ignore as Example 2.

Java
import org.testcontainers.containers.PostgreSQLContainer;

abstract class PostgresTestBase {
    static final PostgreSQLContainer<?> PG =
        new PostgreSQLContainer<>("postgres:16-alpine").withReuse(true);

    static {
        PG.start();
    }
}

class OrderRepositoryIT extends PostgresTestBase { /* tests use PG.getJdbcUrl() */ }
class AccountRepositoryIT extends PostgresTestBase { /* same container */ }
Technique Scope Cleanup
static @Container One test class After the class
Singleton base class All classes in one JVM run At JVM exit
withReuse(true) Across JVM runs on a developer machine Never automatic

Reuse takes effect only when the developer also sets testcontainers.reuse.enable=true in ~/.testcontainers.properties; CI machines normally leave it off. Because a shared database keeps data between tests, each test either uses unique keys or deletes its rows in @BeforeEach.

Example 4: testing time with an injectable Clock

Code that calls Instant.now() or System.currentTimeMillis() cannot be tested at a chosen time. java.time.Clock is an abstract class that supplies the current instant; production passes Clock.systemUTC() and tests pass Clock.fixed(...) or a clock they can move. Clock has three abstract methods (getZone, withZone, instant), so a mutable test clock is a short subclass.

Java
import static org.junit.jupiter.api.Assertions.*;

import java.time.*;
import java.util.*;
import org.junit.jupiter.api.Test;
import org.junit.platform.engine.discovery.DiscoverySelectors;
import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
import org.junit.platform.launcher.core.LauncherFactory;
import org.junit.platform.launcher.listeners.SummaryGeneratingListener;
import org.junit.platform.launcher.listeners.TestExecutionSummary;

class MutableClock extends Clock {
    private Instant now;
    MutableClock(Instant start) { now = start; }
    void advance(Duration d) { now = now.plus(d); }
    @Override public Instant instant() { return now; }
    @Override public ZoneId getZone() { return ZoneOffset.UTC; }
    @Override public Clock withZone(ZoneId zone) { throw new UnsupportedOperationException(); }
}

class SessionTokens {
    private final Clock clock;
    private final Duration ttl;
    private final Map<String, Instant> issued = new HashMap<>();
    SessionTokens(Clock clock, Duration ttl) { this.clock = clock; this.ttl = ttl; }

    void issue(String token) { issued.put(token, clock.instant()); }
    boolean valid(String token) {
        Instant t = issued.get(token);
        return t != null && clock.instant().isBefore(t.plus(ttl));
    }
}

class SessionTokensTest {
    MutableClock clock = new MutableClock(Instant.parse("2026-01-01T09:00:00Z"));
    SessionTokens tokens = new SessionTokens(clock, Duration.ofMinutes(15));

    @Test void validBeforeExpiry() {
        tokens.issue("abc");
        clock.advance(Duration.ofMinutes(14).plusSeconds(59));
        assertTrue(tokens.valid("abc"));
    }

    @Test void invalidAtExpiry() {
        tokens.issue("abc");
        clock.advance(Duration.ofMinutes(15));
        assertFalse(tokens.valid("abc"));
    }

    @Test void fixedClock() {
        Clock fixed = Clock.fixed(Instant.parse("2026-03-01T00:00:00Z"), ZoneOffset.UTC);
        assertEquals(LocalDate.of(2026, 3, 1), LocalDate.now(fixed));
    }
}

public class ClockDemo {
    public static void main(String[] args) {
        run(SessionTokensTest.class);
    }

    static void run(Class<?>... testClasses) {
        var request = LauncherDiscoveryRequestBuilder.request()
            .selectors(Arrays.stream(testClasses).map(DiscoverySelectors::selectClass).toList())
            .build();
        var listener = new SummaryGeneratingListener();
        LauncherFactory.create().execute(request, listener);
        TestExecutionSummary s = listener.getSummary();
        System.out.printf("tests: found=%d succeeded=%d failed=%d aborted=%d skipped=%d%n",
            s.getTestsFoundCount(), s.getTestsSucceededCount(), s.getTestsFailedCount(),
            s.getTestsAbortedCount(), s.getTestsSkippedCount());
        s.getFailures().stream()
            .map(f -> "FAILED " + f.getTestIdentifier().getDisplayName() + ": "
                + String.valueOf(f.getException().getMessage()).lines().findFirst().orElse(""))
            .sorted()
            .forEach(System.out::println);
    }
}
Output
tests: found=3 succeeded=3 failed=0 aborted=0 skipped=0

The boundary test (invalidAtExpiry) runs at exactly 15 minutes, which a test using the real clock and Thread.sleep could never hit reliably. The tests also run in microseconds instead of minutes. LocalDate.now(clock) and the other now(Clock) overloads read from the given clock.

Example 5: a start gate with CountDownLatch

Threads started one after another in a loop do not run at the same time: the first may finish before the last starts, and a race never happens. A start gate is a CountDownLatch(1) that every worker awaits; the test counts it down once all workers are started, so they begin together. A second latch, the end gate, lets the test wait for all of them.

Java
import static org.junit.jupiter.api.Assertions.*;

import java.util.*;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicLong;
import org.junit.jupiter.api.Test;
import org.junit.platform.engine.discovery.DiscoverySelectors;
import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
import org.junit.platform.launcher.core.LauncherFactory;
import org.junit.platform.launcher.listeners.SummaryGeneratingListener;
import org.junit.platform.launcher.listeners.TestExecutionSummary;

class IdGenerator {
    private final AtomicLong next = new AtomicLong();
    long nextId() { return next.incrementAndGet(); }
}

class IdGeneratorTest {
    @Test void idsAreUniqueUnderContention() throws InterruptedException {
        IdGenerator gen = new IdGenerator();
        int threads = 8, perThread = 10_000;
        CountDownLatch start = new CountDownLatch(1);
        CountDownLatch done = new CountDownLatch(threads);
        long[][] results = new long[threads][perThread];

        for (int t = 0; t < threads; t++) {
            int idx = t;
            new Thread(() -> {
                try {
                    start.await();                                  // wait for the gate
                    for (int i = 0; i < perThread; i++) results[idx][i] = gen.nextId();
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } finally {
                    done.countDown();
                }
            }).start();
        }
        start.countDown();                                          // open the gate
        done.await();                                               // wait for all workers

        Set<Long> all = new HashSet<>();
        for (long[] r : results) for (long id : r) all.add(id);
        assertEquals(threads * perThread, all.size());
    }
}

public class StartGateDemo {
    public static void main(String[] args) {
        run(IdGeneratorTest.class);
    }

    static void run(Class<?>... testClasses) {
        var request = LauncherDiscoveryRequestBuilder.request()
            .selectors(Arrays.stream(testClasses).map(DiscoverySelectors::selectClass).toList())
            .build();
        var listener = new SummaryGeneratingListener();
        LauncherFactory.create().execute(request, listener);
        TestExecutionSummary s = listener.getSummary();
        System.out.printf("tests: found=%d succeeded=%d failed=%d aborted=%d skipped=%d%n",
            s.getTestsFoundCount(), s.getTestsSucceededCount(), s.getTestsFailedCount(),
            s.getTestsAbortedCount(), s.getTestsSkippedCount());
        s.getFailures().stream()
            .map(f -> "FAILED " + f.getTestIdentifier().getDisplayName() + ": "
                + String.valueOf(f.getException().getMessage()).lines().findFirst().orElse(""))
            .sorted()
            .forEach(System.out::println);
    }
}
Output
tests: found=1 succeeded=1 failed=0 aborted=0 skipped=0

AtomicLong.incrementAndGet adds one and returns the new value as a single indivisible step, so no two threads get the same id. Each worker writes only its own row of results, and done.await() returns only after every countDown(); the latch guarantees that the writes made before countDown() are visible to the thread returning from await(). Counting down in finally keeps the test from hanging if a worker throws.

Example 6: repeated stress runs and a forced interleaving

A race shows up only when threads interleave in a particular way, so one passing run proves little. Two techniques make races visible:

  • Repeated stress runs: @RepeatedTest(n) runs the same test n times, each with its own start gate. This raises the chance of hitting a bad interleaving but never guarantees it.
  • Forced interleaving: the class gets a test hook at the point where a race can happen, and the test uses latches to pause one thread there while another runs. The bad interleaving then happens on every run.

The UnsafeCounter reads, calls a hook, then writes, so its read-modify-write is not atomic. The forced test reproduces the lost update deterministically.

Java
import static org.junit.jupiter.api.Assertions.*;

import java.util.*;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicLong;
import org.junit.jupiter.api.RepeatedTest;
import org.junit.jupiter.api.Test;
import org.junit.platform.engine.discovery.DiscoverySelectors;
import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
import org.junit.platform.launcher.core.LauncherFactory;
import org.junit.platform.launcher.listeners.SummaryGeneratingListener;
import org.junit.platform.launcher.listeners.TestExecutionSummary;

class UnsafeCounter {
    private long value;
    Runnable afterRead = () -> {};                       // test hook, no-op in production
    void increment() {
        long v = value;
        afterRead.run();
        value = v + 1;
    }
    long get() { return value; }
}

class SafeCounter {
    private final AtomicLong value = new AtomicLong();
    void increment() { value.incrementAndGet(); }
    long get() { return value.get(); }
}

class CounterTest {
    @RepeatedTest(20) void safeCounterUnderStress() throws InterruptedException {
        SafeCounter c = new SafeCounter();
        CountDownLatch start = new CountDownLatch(1);
        Thread[] ts = new Thread[4];
        for (int i = 0; i < ts.length; i++) {
            ts[i] = new Thread(() -> {
                try { start.await(); } catch (InterruptedException e) { return; }
                for (int k = 0; k < 1_000; k++) c.increment();
            });
            ts[i].start();
        }
        start.countDown();
        for (Thread t : ts) t.join();
        assertEquals(4_000, c.get());
    }

    @Test void lostUpdateReproduced() throws InterruptedException {
        UnsafeCounter c = new UnsafeCounter();
        CountDownLatch aHasRead = new CountDownLatch(1);
        CountDownLatch bHasWritten = new CountDownLatch(1);

        Thread a = new Thread(() -> {
            c.afterRead = () -> {
                aHasRead.countDown();
                try { bHasWritten.await(); } catch (InterruptedException e) { }
            };
            c.increment();                               // reads 0, pauses, writes 1
        });
        a.start();
        aHasRead.await();
        c.afterRead = () -> {};
        c.increment();                                   // main thread: reads 0, writes 1
        bHasWritten.countDown();
        a.join();
        assertEquals(1, c.get(), "two increments, one lost");
    }
}

public class StressDemo {
    public static void main(String[] args) {
        run(CounterTest.class);
    }

    static void run(Class<?>... testClasses) {
        var request = LauncherDiscoveryRequestBuilder.request()
            .selectors(Arrays.stream(testClasses).map(DiscoverySelectors::selectClass).toList())
            .build();
        var listener = new SummaryGeneratingListener();
        LauncherFactory.create().execute(request, listener);
        TestExecutionSummary s = listener.getSummary();
        System.out.printf("tests: found=%d succeeded=%d failed=%d aborted=%d skipped=%d%n",
            s.getTestsFoundCount(), s.getTestsSucceededCount(), s.getTestsFailedCount(),
            s.getTestsAbortedCount(), s.getTestsSkippedCount());
        s.getFailures().stream()
            .map(f -> "FAILED " + f.getTestIdentifier().getDisplayName() + ": "
                + String.valueOf(f.getException().getMessage()).lines().findFirst().orElse(""))
            .sorted()
            .forEach(System.out::println);
    }
}
Output
tests: found=21 succeeded=21 failed=0 aborted=0 skipped=0

The 20 repetitions are counted as 20 tests. lostUpdateReproduced asserts the buggy result on purpose to show that the interleaving is forced: two increments leave the counter at 1 on every run. In a real test suite the same setup would run against the fixed class and assert 2. Latches create the ordering here, so value is read and written in a fixed sequence and the result does not depend on the scheduler.

Example 7: a deterministic scheduler

Code that schedules work for later (retries, timeouts, heartbeats) depends on both time and a thread pool. Passing a small Scheduler interface instead of a real executor lets a test supply a manual scheduler: it stores tasks with their due time, and the test runs them by advancing a fake clock. Every task then runs on the test thread, in a fixed order.

Java
import static org.junit.jupiter.api.Assertions.*;

import java.time.*;
import java.util.*;
import java.util.function.Predicate;
import org.junit.jupiter.api.Test;
import org.junit.platform.engine.discovery.DiscoverySelectors;
import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
import org.junit.platform.launcher.core.LauncherFactory;
import org.junit.platform.launcher.listeners.SummaryGeneratingListener;
import org.junit.platform.launcher.listeners.TestExecutionSummary;

interface Scheduler {
    void schedule(Runnable task, Duration delay);
}

class ManualScheduler implements Scheduler {
    private record Task(long dueMillis, long seq, Runnable r) {}
    private final PriorityQueue<Task> queue = new PriorityQueue<>(
        Comparator.comparingLong(Task::dueMillis).thenComparingLong(Task::seq));
    private long nowMillis, seq;

    public void schedule(Runnable r, Duration delay) {
        queue.add(new Task(nowMillis + delay.toMillis(), seq++, r));
    }
    void advance(Duration d) {
        long target = nowMillis + d.toMillis();
        while (!queue.isEmpty() && queue.peek().dueMillis() <= target) {
            Task t = queue.poll();
            nowMillis = t.dueMillis();
            t.r().run();                     // may schedule more tasks
        }
        nowMillis = target;
    }
    int pending() { return queue.size(); }
}

class RetryingSender {
    private final Scheduler scheduler;
    private final Predicate<String> transport;
    final List<String> log = new ArrayList<>();
    RetryingSender(Scheduler s, Predicate<String> transport) { scheduler = s; this.transport = transport; }

    void send(String msg) { attempt(msg, 1); }

    private void attempt(String msg, int n) {
        boolean ok = transport.test(msg);
        log.add("attempt " + n + (ok ? " ok" : " failed"));
        if (!ok && n < 4) scheduler.schedule(() -> attempt(msg, n + 1), Duration.ofSeconds(1L << (n - 1)));
        if (!ok && n == 4) log.add("gave up");
    }
}

class RetryingSenderTest {
    ManualScheduler scheduler = new ManualScheduler();

    @Test void backoffDoublesThenSucceeds() {
        int[] calls = {0};
        RetryingSender s = new RetryingSender(scheduler, m -> ++calls[0] == 3);
        s.send("hello");
        assertEquals(List.of("attempt 1 failed"), s.log);
        scheduler.advance(Duration.ofMillis(999));
        assertEquals(1, s.log.size());                       // 1 s backoff not reached
        scheduler.advance(Duration.ofMillis(1));
        scheduler.advance(Duration.ofSeconds(2));
        assertEquals(List.of("attempt 1 failed", "attempt 2 failed", "attempt 3 ok"), s.log);
        assertEquals(0, scheduler.pending());
    }

    @Test void givesUpAfterFourAttempts() {
        RetryingSender s = new RetryingSender(scheduler, m -> false);
        s.send("hello");
        scheduler.advance(Duration.ofMinutes(1));
        System.out.println(s.log);
        assertEquals("gave up", s.log.getLast());
    }
}

public class SchedulerDemo {
    public static void main(String[] args) {
        run(RetryingSenderTest.class);
    }

    static void run(Class<?>... testClasses) {
        var request = LauncherDiscoveryRequestBuilder.request()
            .selectors(Arrays.stream(testClasses).map(DiscoverySelectors::selectClass).toList())
            .build();
        var listener = new SummaryGeneratingListener();
        LauncherFactory.create().execute(request, listener);
        TestExecutionSummary s = listener.getSummary();
        System.out.printf("tests: found=%d succeeded=%d failed=%d aborted=%d skipped=%d%n",
            s.getTestsFoundCount(), s.getTestsSucceededCount(), s.getTestsFailedCount(),
            s.getTestsAbortedCount(), s.getTestsSkippedCount());
        s.getFailures().stream()
            .map(f -> "FAILED " + f.getTestIdentifier().getDisplayName() + ": "
                + String.valueOf(f.getException().getMessage()).lines().findFirst().orElse(""))
            .sorted()
            .forEach(System.out::println);
    }
}
Output
[attempt 1 failed, attempt 2 failed, attempt 3 failed, attempt 4 failed, gave up]
tests: found=2 succeeded=2 failed=0 aborted=0 skipped=0

The backoff waits are 1, 2, and 4 seconds (1L << (n - 1)). The first test checks the boundary to the millisecond, which is impossible with a real executor and Thread.sleep. In production, an adapter implements Scheduler with a ScheduledExecutorService (covered in Executors and CompletableFuture), and only that adapter needs a slow real-time test.

Example 8: jcstress concepts

Some concurrency bugs depend on the Java Memory Model (the rules for when one thread's writes become visible to another) and on CPU reordering, and appear perhaps once in millions of runs. jcstress (Java Concurrency Stress, an OpenJDK project) runs a small test body billions of times across threads, with varied compiler settings, and counts every distinct outcome. The jcstress jar and its harness are not on this site's class path, so this code is marked ignore.

Java
import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.II_Result;

@JCStressTest
@Outcome(id = "1, 1", expect = Expect.ACCEPTABLE, desc = "both see the other's write")
@Outcome(id = "0, 1", expect = Expect.ACCEPTABLE, desc = "actor1 ran first")
@Outcome(id = "1, 0", expect = Expect.ACCEPTABLE, desc = "actor2 ran first")
@Outcome(id = "0, 0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "reordering visible")
@State
public class StoreLoadTest {
    int x, y;

    @Actor public void actor1(II_Result r) { x = 1; r.r1 = y; }
    @Actor public void actor2(II_Result r) { y = 1; r.r2 = x; }
}

Each @Actor method runs on its own thread against a fresh @State object, and the actors write their observations into a result object (II_Result holds two int fields, r1 and r2). Every outcome is matched against the @Outcome declarations:

Expect value Meaning
ACCEPTABLE Allowed and expected
ACCEPTABLE_INTERESTING Allowed by the memory model but surprising; reported separately
FORBIDDEN Not allowed; the test fails if it is seen
UNKNOWN Not classified; outcomes without a matching @Outcome are treated as unknown, which fails the test

The outcome 0, 0 here means both threads read before either write became visible, which plain fields allow. Declaring x and y volatile makes 0, 0 forbidden. The memory model behind this result is taught in the memory model section.

Interview points

  • An integration test uses real dependencies; each test gets fresh or isolated data so tests stay independent.
  • Testcontainers starts Docker containers from tests; a static @Container field lives for the class, an instance field for each test.
  • Read the mapped port or JDBC URL from the container at run time; it is random.
  • Singleton containers share one instance per JVM; withReuse(true) keeps it across runs only with the opt-in property.
  • Inject Clock instead of calling Instant.now(); tests use Clock.fixed or a mutable subclass.
  • A CountDownLatch(1) start gate makes worker threads begin together; an end gate or join waits for them.
  • A passing stress test does not prove thread safety; a forced interleaving reproduces a known race on every run.
  • A manual scheduler plus a fake clock makes retries and timeouts deterministic and fast.
  • jcstress counts outcomes of tiny concurrent tests and classifies them as acceptable, interesting, or forbidden.

Summary

  • Integration tests follow arrange, act, assert, with setup and cleanup of a real resource per test.
  • Testcontainers needs Docker; its lifecycle follows the @Container field's scope.
  • Clock injection makes time an input of the test.
  • Start gates, repeated runs, and test hooks with latches expose races.
  • A scheduler interface with a manual implementation runs delayed work on the test thread in a fixed order.
  • jcstress tests memory model behavior that ordinary tests cannot reach.