diff --git a/pom.xml b/pom.xml
index e1b4d5e..27aab4c 100644
--- a/pom.xml
+++ b/pom.xml
@@ -11,7 +11,7 @@
it.aboutbits
spring-boot-testing
- 2.5.1
+ 2.6.0-RC1
Testing library for Spring Boot projects.
diff --git a/src/main/java/it/aboutbits/springboot/testing/testdata/base/ModifiableTestDataCreator.java b/src/main/java/it/aboutbits/springboot/testing/testdata/base/ModifiableTestDataCreator.java
index 955e9c9..ef5bb73 100644
--- a/src/main/java/it/aboutbits/springboot/testing/testdata/base/ModifiableTestDataCreator.java
+++ b/src/main/java/it/aboutbits/springboot/testing/testdata/base/ModifiableTestDataCreator.java
@@ -1,11 +1,11 @@
package it.aboutbits.springboot.testing.testdata.base;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.errorprone.annotations.CheckReturnValue;
import lombok.extern.slf4j.Slf4j;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
-import java.util.ArrayList;
import java.util.List;
import java.util.function.BiFunction;
import java.util.function.Consumer;
@@ -68,22 +68,34 @@ public CREATOR modifyResult(Consumer- resultMutator) {
}
@Override
- protected List
- create() {
- var result = new ArrayList
- ();
+ @SuppressWarnings("unchecked")
+ @CanIgnoreReturnValue
+ public CREATOR parallel() {
+ super.parallel();
+ return (CREATOR) this;
+ }
- for (var index = 0; index < numberOfItems; index++) {
+ @Override
+ @SuppressWarnings("unchecked")
+ @CanIgnoreReturnValue
+ public CREATOR sequential() {
+ super.sequential();
+ return (CREATOR) this;
+ }
+
+ @Override
+ protected List
- create() {
+ var result = createItems(index -> {
var item = create(index);
if (resultMutator != null) {
resultMutator.accept(item, index);
- result.add(
- saveMutation(item)
- );
- } else {
- result.add(item);
+ return saveMutation(item);
}
- }
+
+ return item;
+ });
if (mutatorSet && !mutatorCalled) {
log.error("Parameter-mutation is defined but was never called.");
diff --git a/src/main/java/it/aboutbits/springboot/testing/testdata/base/TestDataCreator.java b/src/main/java/it/aboutbits/springboot/testing/testdata/base/TestDataCreator.java
index 96287dd..943bcfb 100644
--- a/src/main/java/it/aboutbits/springboot/testing/testdata/base/TestDataCreator.java
+++ b/src/main/java/it/aboutbits/springboot/testing/testdata/base/TestDataCreator.java
@@ -1,5 +1,6 @@
package it.aboutbits.springboot.testing.testdata.base;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
import it.aboutbits.springboot.testing.testdata.FakerExtended;
import org.jspecify.annotations.NullMarked;
@@ -8,20 +9,54 @@
import java.util.HashSet;
import java.util.List;
import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
import java.util.function.Function;
+import java.util.function.IntFunction;
@SuppressWarnings("java:S119")
@NullMarked
public abstract class TestDataCreator
- {
+ /// System property that flips the default creation mode of every creator to parallel;
+ /// [#sequential()] then opts a single call site back out.
+ public static final String PARALLEL_BY_DEFAULT_PROPERTY =
+ "it.aboutbits.testing.testdata.parallel-by-default";
+
@SuppressWarnings("unused")
protected static final FakerExtended FAKER = new FakerExtended();
protected final int numberOfItems;
+ private boolean parallel = Boolean.getBoolean(PARALLEL_BY_DEFAULT_PROPERTY);
+
protected TestDataCreator(int numberOfItems) {
this.numberOfItems = numberOfItems;
}
+ /// Creates the items concurrently, one thread per item, instead of sequentially. The returned
+ /// list keeps index order, but database side effects (e.g. sequence-assigned ids) interleave
+ /// arbitrarily across items.
+ ///
+ /// Only safe for creators whose per-item creation is independent: a creator that shares lazily
+ /// created state across items (e.g. `sameXyz()` memoization) must resolve that state before
+ /// creation fans out.
+ @SuppressWarnings("unused")
+ @CanIgnoreReturnValue
+ public TestDataCreator
- parallel() {
+ this.parallel = true;
+ return this;
+ }
+
+ /// Creates the items sequentially — the default, unless [#PARALLEL_BY_DEFAULT_PROPERTY]
+ /// flipped it; then this is the per-call opt-out.
+ @SuppressWarnings("unused")
+ @CanIgnoreReturnValue
+ public TestDataCreator
- sequential() {
+ this.parallel = false;
+ return this;
+ }
+
@SuppressWarnings("unused")
public void commit() {
create();
@@ -73,15 +108,55 @@ public Set
- returnSet() {
}
protected List
- create() {
- var result = new ArrayList
- ();
+ return createItems(this::create);
+ }
+
+ /// Runs one full item creation per index and returns the items in index order — sequentially
+ /// by default, concurrently after [#parallel()].
+ protected final List
- createItems(IntFunction
- itemForIndex) {
+ if (!parallel || numberOfItems <= 1) {
+ var result = new ArrayList
- ();
- for (var index = 0; index < numberOfItems; index++) {
- result.add(
- create(index)
- );
+ for (var index = 0; index < numberOfItems; index++) {
+ result.add(
+ itemForIndex.apply(index)
+ );
+ }
+
+ return result;
}
- return result;
+ try (var executor = Executors.newFixedThreadPool(numberOfItems)) {
+ var futures = new ArrayList>();
+ for (var index = 0; index < numberOfItems; index++) {
+ var itemIndex = index;
+ futures.add(
+ executor.submit(() -> itemForIndex.apply(itemIndex))
+ );
+ }
+
+ var result = new ArrayList
- ();
+ for (var future : futures) {
+ result.add(awaitItem(future));
+ }
+
+ return result;
+ }
+ }
+
+ private ITEM awaitItem(Future
- future) {
+ try {
+ return future.get();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException("Parallel test data creation was interrupted", e);
+ } catch (ExecutionException e) {
+ switch (e.getCause()) {
+ case RuntimeException runtimeException -> throw runtimeException;
+ case Error error -> throw error;
+ case null, default -> throw new IllegalStateException("Parallel test data creation failed", e.getCause());
+ }
+ }
}
protected abstract ITEM create(int index);
diff --git a/src/main/java/it/aboutbits/springboot/testing/testdata/base/TestDataDependency.java b/src/main/java/it/aboutbits/springboot/testing/testdata/base/TestDataDependency.java
new file mode 100644
index 0000000..4288713
--- /dev/null
+++ b/src/main/java/it/aboutbits/springboot/testing/testdata/base/TestDataDependency.java
@@ -0,0 +1,64 @@
+package it.aboutbits.springboot.testing.testdata.base;
+
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+import java.util.function.Supplier;
+
+/// A dependency of a test-data creator that each created item needs a value for.
+/// Three modes, chosen while the creator is being configured:
+///
+/// - fixed: [#set(Object)] — every item uses the explicitly provided value (`withXyz(id)`)
+/// - shared: [#share()] — the first resolution creates the value, every later one reuses it
+/// (`sameXyz()`, or dependencies that are always shared across the items)
+/// - per item: neither — every resolution creates a fresh value (the default)
+///
+/// [#resolve(Supplier)] is safe under parallel item creation: a shared value is created exactly
+/// once (competing items wait for it), while per-item creation runs unsynchronized. The
+/// configuration methods ([#set(Object)], [#share()]) belong to the builder phase and must not
+/// be called once item creation has started.
+@NullMarked
+public final class TestDataDependency {
+ private boolean shared;
+ private @Nullable T value;
+
+ private TestDataDependency(boolean shared) {
+ this.shared = shared;
+ }
+
+ public static TestDataDependency perItem() {
+ return new TestDataDependency<>(false);
+ }
+
+ public static TestDataDependency shared() {
+ return new TestDataDependency<>(true);
+ }
+
+ public synchronized void set(T value) {
+ this.value = value;
+ this.shared = true;
+ }
+
+ public synchronized void share() {
+ this.shared = true;
+ }
+
+ /// True once a value is fixed or has been resolved — used by creators whose dependency is
+ /// optional, to decide between "absent" and "resolve".
+ public synchronized boolean isSet() {
+ return value != null;
+ }
+
+ public T resolve(Supplier creator) {
+ if (!shared) {
+ return creator.get();
+ }
+
+ synchronized (this) {
+ if (value == null) {
+ value = creator.get();
+ }
+ return value;
+ }
+ }
+}
diff --git a/src/test/java/it/aboutbits/springboot/testing/testdata/base/TestDataCreatorTest.java b/src/test/java/it/aboutbits/springboot/testing/testdata/base/TestDataCreatorTest.java
new file mode 100644
index 0000000..ef13e05
--- /dev/null
+++ b/src/test/java/it/aboutbits/springboot/testing/testdata/base/TestDataCreatorTest.java
@@ -0,0 +1,128 @@
+package it.aboutbits.springboot.testing.testdata.base;
+
+import org.jspecify.annotations.NullMarked;
+import org.junit.jupiter.api.Test;
+
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.function.IntFunction;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+
+@NullMarked
+class TestDataCreatorTest {
+ @Test
+ void sequentialByDefault() {
+ var creator = new ThreadRecordingCreator(5, index -> "item-" + index);
+
+ var result = creator.returnAll();
+
+ assertThat(result).containsExactly("item-0", "item-1", "item-2", "item-3", "item-4");
+ assertThat(creator.threads).hasSize(1);
+ }
+
+ @Test
+ void parallelKeepsIndexOrder() {
+ var creator = new ThreadRecordingCreator(5, index -> "item-" + index);
+
+ var result = creator.parallel().returnAll();
+
+ assertThat(result).containsExactly("item-0", "item-1", "item-2", "item-3", "item-4");
+ }
+
+ @Test
+ void parallelRunsItemsConcurrently() {
+ var allItemsStarted = new CountDownLatch(3);
+ var creator = new ThreadRecordingCreator(3, index -> {
+ allItemsStarted.countDown();
+ try {
+ // Only finishes if all items run at the same time.
+ if (!allItemsStarted.await(5, TimeUnit.SECONDS)) {
+ throw new IllegalStateException("Items did not run concurrently");
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException(e);
+ }
+ return "item-" + index;
+ });
+
+ var result = creator.parallel().returnAll();
+
+ assertThat(result).hasSize(3);
+ assertThat(creator.threads).hasSize(3);
+ }
+
+ @Test
+ void parallelPropagatesItemFailure() {
+ var creator = new ThreadRecordingCreator(3, index -> {
+ if (index == 1) {
+ throw new IllegalArgumentException("item 1 failed");
+ }
+ return "item-" + index;
+ });
+
+ assertThatExceptionOfType(IllegalArgumentException.class)
+ .isThrownBy(() -> creator.parallel().returnAll())
+ .withMessage("item 1 failed");
+ }
+
+ @Test
+ void parallelWithSingleItemStaysSequential() {
+ var creator = new ThreadRecordingCreator(1, index -> "item-" + index);
+
+ var result = creator.parallel().returnAll();
+
+ assertThat(result).containsExactly("item-0");
+ assertThat(creator.threads).containsExactly(Thread.currentThread().getName());
+ }
+
+ @Test
+ void parallelByDefaultProperty_flipsTheDefault() {
+ System.setProperty(TestDataCreator.PARALLEL_BY_DEFAULT_PROPERTY, "true");
+ try {
+ var creator = new ThreadRecordingCreator(3, index -> "item-" + index);
+
+ var result = creator.returnAll();
+
+ assertThat(result).containsExactly("item-0", "item-1", "item-2");
+ assertThat(creator.threads).doesNotContain(Thread.currentThread().getName());
+ } finally {
+ System.clearProperty(TestDataCreator.PARALLEL_BY_DEFAULT_PROPERTY);
+ }
+ }
+
+ @Test
+ void sequential_optsOutOfTheParallelDefault() {
+ System.setProperty(TestDataCreator.PARALLEL_BY_DEFAULT_PROPERTY, "true");
+ try {
+ var creator = new ThreadRecordingCreator(3, index -> "item-" + index);
+
+ var result = creator.sequential().returnAll();
+
+ assertThat(result).containsExactly("item-0", "item-1", "item-2");
+ assertThat(creator.threads).containsExactly(Thread.currentThread().getName());
+ } finally {
+ System.clearProperty(TestDataCreator.PARALLEL_BY_DEFAULT_PROPERTY);
+ }
+ }
+
+ private static final class ThreadRecordingCreator extends TestDataCreator {
+ private final IntFunction itemForIndex;
+ private final Set threads = ConcurrentHashMap.newKeySet();
+
+ private ThreadRecordingCreator(int numberOfItems, IntFunction itemForIndex) {
+ super(numberOfItems);
+ this.itemForIndex = itemForIndex;
+ }
+
+ @Override
+ protected String create(int index) {
+ threads.add(Thread.currentThread().getName());
+ return itemForIndex.apply(index);
+ }
+ }
+}
diff --git a/src/test/java/it/aboutbits/springboot/testing/testdata/base/TestDataDependencyTest.java b/src/test/java/it/aboutbits/springboot/testing/testdata/base/TestDataDependencyTest.java
new file mode 100644
index 0000000..12ed087
--- /dev/null
+++ b/src/test/java/it/aboutbits/springboot/testing/testdata/base/TestDataDependencyTest.java
@@ -0,0 +1,87 @@
+package it.aboutbits.springboot.testing.testdata.base;
+
+import org.jspecify.annotations.NullMarked;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@NullMarked
+class TestDataDependencyTest {
+ @Test
+ void perItem_resolvesFreshValueEveryTime() {
+ var creations = new AtomicInteger();
+ var dependency = TestDataDependency.perItem();
+
+ var first = dependency.resolve(creations::incrementAndGet);
+ var second = dependency.resolve(creations::incrementAndGet);
+
+ assertThat(first).isEqualTo(1);
+ assertThat(second).isEqualTo(2);
+ assertThat(creations).hasValue(2);
+ assertThat(dependency.isSet()).isFalse();
+ }
+
+ @Test
+ void set_pinsTheValueAndNeverCreates() {
+ var creations = new AtomicInteger();
+ var dependency = TestDataDependency.perItem();
+
+ dependency.set(42);
+
+ assertThat(dependency.isSet()).isTrue();
+ assertThat(dependency.resolve(creations::incrementAndGet)).isEqualTo(42);
+ assertThat(dependency.resolve(creations::incrementAndGet)).isEqualTo(42);
+ assertThat(creations).hasValue(0);
+ }
+
+ @Test
+ void share_createsOnceAndReuses() {
+ var creations = new AtomicInteger();
+ var dependency = TestDataDependency.perItem();
+
+ dependency.share();
+
+ assertThat(dependency.isSet()).isFalse();
+ assertThat(dependency.resolve(creations::incrementAndGet)).isEqualTo(1);
+ assertThat(dependency.isSet()).isTrue();
+ assertThat(dependency.resolve(creations::incrementAndGet)).isEqualTo(1);
+ assertThat(creations).hasValue(1);
+ }
+
+ @Test
+ void shared_startsInSharedMode() {
+ var creations = new AtomicInteger();
+ var dependency = TestDataDependency.shared();
+
+ assertThat(dependency.resolve(creations::incrementAndGet)).isEqualTo(1);
+ assertThat(dependency.resolve(creations::incrementAndGet)).isEqualTo(1);
+ assertThat(creations).hasValue(1);
+ }
+
+ @Test
+ void shared_resolvesExactlyOnceUnderConcurrency() throws InterruptedException, ExecutionException {
+ var creations = new AtomicInteger();
+ var dependency = TestDataDependency.shared();
+
+ var results = new HashSet();
+ try (var executor = Executors.newFixedThreadPool(8)) {
+ var futures = new ArrayList>();
+ for (var i = 0; i < 8; i++) {
+ futures.add(executor.submit(() -> dependency.resolve(creations::incrementAndGet)));
+ }
+ for (var future : futures) {
+ results.add(future.get());
+ }
+ }
+
+ assertThat(creations).hasValue(1);
+ assertThat(results).containsExactly(1);
+ }
+}