Skip to content

Commit cb5fa92

Browse files
committed
Add parallel item creation to TestDataCreator
Opt-in per call site via parallel(), opt-out via sequential(). The it.aboutbits.testing.testdata.parallel-by-default system property flips the default, so a whole suite can run parallel without touching call sites. The result list keeps index order; worker failures are rethrown unwrapped.
1 parent 2d1fa4b commit cb5fa92

3 files changed

Lines changed: 231 additions & 16 deletions

File tree

src/main/java/it/aboutbits/springboot/testing/testdata/base/ModifiableTestDataCreator.java

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
package it.aboutbits.springboot.testing.testdata.base;
22

3+
import com.google.errorprone.annotations.CanIgnoreReturnValue;
34
import com.google.errorprone.annotations.CheckReturnValue;
45
import lombok.extern.slf4j.Slf4j;
56
import org.jspecify.annotations.NullMarked;
67
import org.jspecify.annotations.Nullable;
78

8-
import java.util.ArrayList;
99
import java.util.List;
1010
import java.util.function.BiFunction;
1111
import java.util.function.Consumer;
@@ -68,22 +68,34 @@ public CREATOR modifyResult(Consumer<ITEM> resultMutator) {
6868
}
6969

7070
@Override
71-
protected List<ITEM> create() {
72-
var result = new ArrayList<ITEM>();
71+
@SuppressWarnings("unchecked")
72+
@CanIgnoreReturnValue
73+
public CREATOR parallel() {
74+
super.parallel();
75+
return (CREATOR) this;
76+
}
7377

74-
for (var index = 0; index < numberOfItems; index++) {
78+
@Override
79+
@SuppressWarnings("unchecked")
80+
@CanIgnoreReturnValue
81+
public CREATOR sequential() {
82+
super.sequential();
83+
return (CREATOR) this;
84+
}
85+
86+
@Override
87+
protected List<ITEM> create() {
88+
var result = createItems(index -> {
7589
var item = create(index);
7690

7791
if (resultMutator != null) {
7892
resultMutator.accept(item, index);
7993

80-
result.add(
81-
saveMutation(item)
82-
);
83-
} else {
84-
result.add(item);
94+
return saveMutation(item);
8595
}
86-
}
96+
97+
return item;
98+
});
8799

88100
if (mutatorSet && !mutatorCalled) {
89101
log.error("Parameter-mutation is defined but was never called.");

src/main/java/it/aboutbits/springboot/testing/testdata/base/TestDataCreator.java

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package it.aboutbits.springboot.testing.testdata.base;
22

3+
import com.google.errorprone.annotations.CanIgnoreReturnValue;
34
import it.aboutbits.springboot.testing.testdata.FakerExtended;
45
import org.jspecify.annotations.NullMarked;
56

@@ -8,20 +9,54 @@
89
import java.util.HashSet;
910
import java.util.List;
1011
import java.util.Set;
12+
import java.util.concurrent.ExecutionException;
13+
import java.util.concurrent.Executors;
14+
import java.util.concurrent.Future;
1115
import java.util.function.Function;
16+
import java.util.function.IntFunction;
1217

1318
@SuppressWarnings("java:S119")
1419
@NullMarked
1520
public abstract class TestDataCreator<ITEM> {
21+
/// System property that flips the default creation mode of every creator to parallel;
22+
/// [#sequential()] then opts a single call site back out.
23+
public static final String PARALLEL_BY_DEFAULT_PROPERTY =
24+
"it.aboutbits.testing.testdata.parallel-by-default";
25+
1626
@SuppressWarnings("unused")
1727
protected static final FakerExtended FAKER = new FakerExtended();
1828

1929
protected final int numberOfItems;
2030

31+
private boolean parallel = Boolean.getBoolean(PARALLEL_BY_DEFAULT_PROPERTY);
32+
2133
protected TestDataCreator(int numberOfItems) {
2234
this.numberOfItems = numberOfItems;
2335
}
2436

37+
/// Creates the items concurrently, one thread per item, instead of sequentially. The returned
38+
/// list keeps index order, but database side effects (e.g. sequence-assigned ids) interleave
39+
/// arbitrarily across items.
40+
///
41+
/// Only safe for creators whose per-item creation is independent: a creator that shares lazily
42+
/// created state across items (e.g. `sameXyz()` memoization) must resolve that state before
43+
/// creation fans out.
44+
@SuppressWarnings("unused")
45+
@CanIgnoreReturnValue
46+
public TestDataCreator<ITEM> parallel() {
47+
this.parallel = true;
48+
return this;
49+
}
50+
51+
/// Creates the items sequentially — the default, unless [#PARALLEL_BY_DEFAULT_PROPERTY]
52+
/// flipped it; then this is the per-call opt-out.
53+
@SuppressWarnings("unused")
54+
@CanIgnoreReturnValue
55+
public TestDataCreator<ITEM> sequential() {
56+
this.parallel = false;
57+
return this;
58+
}
59+
2560
@SuppressWarnings("unused")
2661
public void commit() {
2762
create();
@@ -73,15 +108,55 @@ public Set<ITEM> returnSet() {
73108
}
74109

75110
protected List<ITEM> create() {
76-
var result = new ArrayList<ITEM>();
111+
return createItems(this::create);
112+
}
113+
114+
/// Runs one full item creation per index and returns the items in index order — sequentially
115+
/// by default, concurrently after [#parallel()].
116+
protected final List<ITEM> createItems(IntFunction<ITEM> itemForIndex) {
117+
if (!parallel || numberOfItems <= 1) {
118+
var result = new ArrayList<ITEM>();
77119

78-
for (var index = 0; index < numberOfItems; index++) {
79-
result.add(
80-
create(index)
81-
);
120+
for (var index = 0; index < numberOfItems; index++) {
121+
result.add(
122+
itemForIndex.apply(index)
123+
);
124+
}
125+
126+
return result;
82127
}
83128

84-
return result;
129+
try (var executor = Executors.newFixedThreadPool(numberOfItems)) {
130+
var futures = new ArrayList<Future<ITEM>>();
131+
for (var index = 0; index < numberOfItems; index++) {
132+
var itemIndex = index;
133+
futures.add(
134+
executor.submit(() -> itemForIndex.apply(itemIndex))
135+
);
136+
}
137+
138+
var result = new ArrayList<ITEM>();
139+
for (var future : futures) {
140+
result.add(awaitItem(future));
141+
}
142+
143+
return result;
144+
}
145+
}
146+
147+
private ITEM awaitItem(Future<ITEM> future) {
148+
try {
149+
return future.get();
150+
} catch (InterruptedException e) {
151+
Thread.currentThread().interrupt();
152+
throw new IllegalStateException("Parallel test data creation was interrupted", e);
153+
} catch (ExecutionException e) {
154+
switch (e.getCause()) {
155+
case RuntimeException runtimeException -> throw runtimeException;
156+
case Error error -> throw error;
157+
case null, default -> throw new IllegalStateException("Parallel test data creation failed", e.getCause());
158+
}
159+
}
85160
}
86161

87162
protected abstract ITEM create(int index);
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package it.aboutbits.springboot.testing.testdata.base;
2+
3+
import org.jspecify.annotations.NullMarked;
4+
import org.junit.jupiter.api.Test;
5+
6+
import java.util.Set;
7+
import java.util.concurrent.ConcurrentHashMap;
8+
import java.util.concurrent.CountDownLatch;
9+
import java.util.concurrent.TimeUnit;
10+
import java.util.function.IntFunction;
11+
12+
import static org.assertj.core.api.Assertions.assertThat;
13+
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
14+
15+
@NullMarked
16+
class TestDataCreatorTest {
17+
@Test
18+
void sequentialByDefault() {
19+
var creator = new ThreadRecordingCreator(5, index -> "item-" + index);
20+
21+
var result = creator.returnAll();
22+
23+
assertThat(result).containsExactly("item-0", "item-1", "item-2", "item-3", "item-4");
24+
assertThat(creator.threads).hasSize(1);
25+
}
26+
27+
@Test
28+
void parallelKeepsIndexOrder() {
29+
var creator = new ThreadRecordingCreator(5, index -> "item-" + index);
30+
31+
var result = creator.parallel().returnAll();
32+
33+
assertThat(result).containsExactly("item-0", "item-1", "item-2", "item-3", "item-4");
34+
}
35+
36+
@Test
37+
void parallelRunsItemsConcurrently() {
38+
var allItemsStarted = new CountDownLatch(3);
39+
var creator = new ThreadRecordingCreator(3, index -> {
40+
allItemsStarted.countDown();
41+
try {
42+
// Only finishes if all items run at the same time.
43+
if (!allItemsStarted.await(5, TimeUnit.SECONDS)) {
44+
throw new IllegalStateException("Items did not run concurrently");
45+
}
46+
} catch (InterruptedException e) {
47+
Thread.currentThread().interrupt();
48+
throw new IllegalStateException(e);
49+
}
50+
return "item-" + index;
51+
});
52+
53+
var result = creator.parallel().returnAll();
54+
55+
assertThat(result).hasSize(3);
56+
assertThat(creator.threads).hasSize(3);
57+
}
58+
59+
@Test
60+
void parallelPropagatesItemFailure() {
61+
var creator = new ThreadRecordingCreator(3, index -> {
62+
if (index == 1) {
63+
throw new IllegalArgumentException("item 1 failed");
64+
}
65+
return "item-" + index;
66+
});
67+
68+
assertThatExceptionOfType(IllegalArgumentException.class)
69+
.isThrownBy(() -> creator.parallel().returnAll())
70+
.withMessage("item 1 failed");
71+
}
72+
73+
@Test
74+
void parallelWithSingleItemStaysSequential() {
75+
var creator = new ThreadRecordingCreator(1, index -> "item-" + index);
76+
77+
var result = creator.parallel().returnAll();
78+
79+
assertThat(result).containsExactly("item-0");
80+
assertThat(creator.threads).containsExactly(Thread.currentThread().getName());
81+
}
82+
83+
@Test
84+
void parallelByDefaultProperty_flipsTheDefault() {
85+
System.setProperty(TestDataCreator.PARALLEL_BY_DEFAULT_PROPERTY, "true");
86+
try {
87+
var creator = new ThreadRecordingCreator(3, index -> "item-" + index);
88+
89+
var result = creator.returnAll();
90+
91+
assertThat(result).containsExactly("item-0", "item-1", "item-2");
92+
assertThat(creator.threads).doesNotContain(Thread.currentThread().getName());
93+
} finally {
94+
System.clearProperty(TestDataCreator.PARALLEL_BY_DEFAULT_PROPERTY);
95+
}
96+
}
97+
98+
@Test
99+
void sequential_optsOutOfTheParallelDefault() {
100+
System.setProperty(TestDataCreator.PARALLEL_BY_DEFAULT_PROPERTY, "true");
101+
try {
102+
var creator = new ThreadRecordingCreator(3, index -> "item-" + index);
103+
104+
var result = creator.sequential().returnAll();
105+
106+
assertThat(result).containsExactly("item-0", "item-1", "item-2");
107+
assertThat(creator.threads).containsExactly(Thread.currentThread().getName());
108+
} finally {
109+
System.clearProperty(TestDataCreator.PARALLEL_BY_DEFAULT_PROPERTY);
110+
}
111+
}
112+
113+
private static final class ThreadRecordingCreator extends TestDataCreator<String> {
114+
private final IntFunction<String> itemForIndex;
115+
private final Set<String> threads = ConcurrentHashMap.newKeySet();
116+
117+
private ThreadRecordingCreator(int numberOfItems, IntFunction<String> itemForIndex) {
118+
super(numberOfItems);
119+
this.itemForIndex = itemForIndex;
120+
}
121+
122+
@Override
123+
protected String create(int index) {
124+
threads.add(Thread.currentThread().getName());
125+
return itemForIndex.apply(index);
126+
}
127+
}
128+
}

0 commit comments

Comments
 (0)