Skip to content

Commit 736629e

Browse files
committed
Add TestDataDependency for creator dependencies
Replaces the hand-rolled field-plus-sameXyz-flag memoization in creators: fixed, shared, and per-item modes in one holder. resolve() is safe under parallel item creation - a shared value is created exactly once, per-item creation stays unsynchronized.
1 parent cb5fa92 commit 736629e

2 files changed

Lines changed: 151 additions & 0 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package it.aboutbits.springboot.testing.testdata.base;
2+
3+
import org.jspecify.annotations.NullMarked;
4+
import org.jspecify.annotations.Nullable;
5+
6+
import java.util.function.Supplier;
7+
8+
/// A dependency of a test-data creator that each created item needs a value for.
9+
/// Three modes, chosen while the creator is being configured:
10+
///
11+
/// - fixed: [#set(Object)] — every item uses the explicitly provided value (`withXyz(id)`)
12+
/// - shared: [#share()] — the first resolution creates the value, every later one reuses it
13+
/// (`sameXyz()`, or dependencies that are always shared across the items)
14+
/// - per item: neither — every resolution creates a fresh value (the default)
15+
///
16+
/// [#resolve(Supplier)] is safe under parallel item creation: a shared value is created exactly
17+
/// once (competing items wait for it), while per-item creation runs unsynchronized. The
18+
/// configuration methods ([#set(Object)], [#share()]) belong to the builder phase and must not
19+
/// be called once item creation has started.
20+
@NullMarked
21+
public final class TestDataDependency<T> {
22+
private boolean shared;
23+
private @Nullable T value;
24+
25+
private TestDataDependency(boolean shared) {
26+
this.shared = shared;
27+
}
28+
29+
public static <T> TestDataDependency<T> perItem() {
30+
return new TestDataDependency<>(false);
31+
}
32+
33+
public static <T> TestDataDependency<T> shared() {
34+
return new TestDataDependency<>(true);
35+
}
36+
37+
public synchronized void set(T value) {
38+
this.value = value;
39+
this.shared = true;
40+
}
41+
42+
public synchronized void share() {
43+
this.shared = true;
44+
}
45+
46+
/// True once a value is fixed or has been resolved — used by creators whose dependency is
47+
/// optional, to decide between "absent" and "resolve".
48+
public synchronized boolean isSet() {
49+
return value != null;
50+
}
51+
52+
public T resolve(Supplier<T> creator) {
53+
if (!shared) {
54+
return creator.get();
55+
}
56+
57+
synchronized (this) {
58+
if (value == null) {
59+
value = creator.get();
60+
}
61+
return value;
62+
}
63+
}
64+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
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.ArrayList;
7+
import java.util.HashSet;
8+
import java.util.concurrent.ExecutionException;
9+
import java.util.concurrent.Executors;
10+
import java.util.concurrent.Future;
11+
import java.util.concurrent.atomic.AtomicInteger;
12+
13+
import static org.assertj.core.api.Assertions.assertThat;
14+
15+
@NullMarked
16+
class TestDataDependencyTest {
17+
@Test
18+
void perItem_resolvesFreshValueEveryTime() {
19+
var creations = new AtomicInteger();
20+
var dependency = TestDataDependency.<Integer>perItem();
21+
22+
var first = dependency.resolve(creations::incrementAndGet);
23+
var second = dependency.resolve(creations::incrementAndGet);
24+
25+
assertThat(first).isEqualTo(1);
26+
assertThat(second).isEqualTo(2);
27+
assertThat(creations).hasValue(2);
28+
assertThat(dependency.isSet()).isFalse();
29+
}
30+
31+
@Test
32+
void set_pinsTheValueAndNeverCreates() {
33+
var creations = new AtomicInteger();
34+
var dependency = TestDataDependency.<Integer>perItem();
35+
36+
dependency.set(42);
37+
38+
assertThat(dependency.isSet()).isTrue();
39+
assertThat(dependency.resolve(creations::incrementAndGet)).isEqualTo(42);
40+
assertThat(dependency.resolve(creations::incrementAndGet)).isEqualTo(42);
41+
assertThat(creations).hasValue(0);
42+
}
43+
44+
@Test
45+
void share_createsOnceAndReuses() {
46+
var creations = new AtomicInteger();
47+
var dependency = TestDataDependency.<Integer>perItem();
48+
49+
dependency.share();
50+
51+
assertThat(dependency.isSet()).isFalse();
52+
assertThat(dependency.resolve(creations::incrementAndGet)).isEqualTo(1);
53+
assertThat(dependency.isSet()).isTrue();
54+
assertThat(dependency.resolve(creations::incrementAndGet)).isEqualTo(1);
55+
assertThat(creations).hasValue(1);
56+
}
57+
58+
@Test
59+
void shared_startsInSharedMode() {
60+
var creations = new AtomicInteger();
61+
var dependency = TestDataDependency.<Integer>shared();
62+
63+
assertThat(dependency.resolve(creations::incrementAndGet)).isEqualTo(1);
64+
assertThat(dependency.resolve(creations::incrementAndGet)).isEqualTo(1);
65+
assertThat(creations).hasValue(1);
66+
}
67+
68+
@Test
69+
void shared_resolvesExactlyOnceUnderConcurrency() throws InterruptedException, ExecutionException {
70+
var creations = new AtomicInteger();
71+
var dependency = TestDataDependency.<Integer>shared();
72+
73+
var results = new HashSet<Integer>();
74+
try (var executor = Executors.newFixedThreadPool(8)) {
75+
var futures = new ArrayList<Future<Integer>>();
76+
for (var i = 0; i < 8; i++) {
77+
futures.add(executor.submit(() -> dependency.resolve(creations::incrementAndGet)));
78+
}
79+
for (var future : futures) {
80+
results.add(future.get());
81+
}
82+
}
83+
84+
assertThat(creations).hasValue(1);
85+
assertThat(results).containsExactly(1);
86+
}
87+
}

0 commit comments

Comments
 (0)