Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

<groupId>it.aboutbits</groupId>
<artifactId>spring-boot-testing</artifactId>
<version>2.5.1</version>
<version>2.6.0-RC1</version>
<description>Testing library for Spring Boot projects.</description>

<properties>
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -68,22 +68,34 @@ public CREATOR modifyResult(Consumer<ITEM> resultMutator) {
}

@Override
protected List<ITEM> create() {
var result = new ArrayList<ITEM>();
@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<ITEM> 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.");
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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<ITEM> {
/// 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<ITEM> 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<ITEM> sequential() {
this.parallel = false;
return this;
}

@SuppressWarnings("unused")
public void commit() {
create();
Expand Down Expand Up @@ -73,15 +108,55 @@ public Set<ITEM> returnSet() {
}

protected List<ITEM> create() {
var result = new ArrayList<ITEM>();
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<ITEM> createItems(IntFunction<ITEM> itemForIndex) {
if (!parallel || numberOfItems <= 1) {
var result = new ArrayList<ITEM>();

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<Future<ITEM>>();
for (var index = 0; index < numberOfItems; index++) {
var itemIndex = index;
futures.add(
executor.submit(() -> itemForIndex.apply(itemIndex))
);
}

var result = new ArrayList<ITEM>();
for (var future : futures) {
result.add(awaitItem(future));
}

return result;
}
}

private ITEM awaitItem(Future<ITEM> 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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<T> {
private boolean shared;
private @Nullable T value;

private TestDataDependency(boolean shared) {
this.shared = shared;
}

public static <T> TestDataDependency<T> perItem() {
return new TestDataDependency<>(false);
}

public static <T> TestDataDependency<T> 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<T> creator) {
if (!shared) {
return creator.get();
}

synchronized (this) {
if (value == null) {
value = creator.get();
}
return value;
}
}
}
Original file line number Diff line number Diff line change
@@ -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<String> {
private final IntFunction<String> itemForIndex;
private final Set<String> threads = ConcurrentHashMap.newKeySet();

private ThreadRecordingCreator(int numberOfItems, IntFunction<String> itemForIndex) {
super(numberOfItems);
this.itemForIndex = itemForIndex;
}

@Override
protected String create(int index) {
threads.add(Thread.currentThread().getName());
return itemForIndex.apply(index);
}
}
}
Loading