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); + } +}