Skip to content

Commit 103dc3b

Browse files
dfa1claude
andcommitted
test: add property-based tests for NaturalSortOrder, merge, sort, take/drop (#658)
- ValuesTest: reflexivity, antisymmetry, transitivity for NaturalSortOrder; commutativity for NumericValue.merge() and SizeValue.merge() - TextModuleTest: idempotence for Sort; partition property for Take+Drop - Allow @Property and @provide in UnitTestsFitnessTest architecture rule - Add jqwik dependency to text and test-support modules - Add CLAUDE.md with PBT guidance (notes jqwik/JUnit 6 engine incompatibility) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f117cd3 commit 103dc3b

6 files changed

Lines changed: 128 additions & 1 deletion

File tree

CLAUDE.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# hosh project guidance
2+
3+
## Testing
4+
5+
### Property-Based Testing (PBT)
6+
7+
This project uses [jqwik](https://jqwik.net/) for property-based tests.
8+
9+
**Known issue:** jqwik 1.9.3 targets JUnit Platform 1.x but the project uses JUnit 6 (Platform 6.x). `@Property` tests compile and are structurally correct but the jqwik engine does not execute them at runtime. Track https://github.com/jqwik-team/jqwik/issues for jqwik 2.x release targeting JUnit 6.
10+
11+
**Writing property tests:** use `@Property` + `@ForAll` for parameters, `@Provide` for custom arbitraries, `Assume.that(...)` for preconditions. Follow the existing patterns in `ValuesTest.java` (spi module).
12+
13+
**Key properties to test:**
14+
- Comparator contract: reflexivity, antisymmetry, transitivity
15+
- Merge commutativity: `a.merge(b) == b.merge(a)`
16+
- Sort idempotence: `sort(sort(xs)) == sort(xs)`
17+
- Partition: `take(n, xs) + drop(n, xs) == xs`
18+
19+
**Architecture rule:** `UnitTestsFitnessTest` allows `@Property` and `@Provide` annotations alongside the standard JUnit annotations (`@Test`, `@BeforeEach`, `@AfterEach`, `@ParameterizedTest`).

modules/text/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,5 +47,10 @@
4747
<artifactId>mockito-junit-jupiter</artifactId>
4848
<scope>test</scope>
4949
</dependency>
50+
<dependency>
51+
<groupId>net.jqwik</groupId>
52+
<artifactId>jqwik</artifactId>
53+
<scope>test</scope>
54+
</dependency>
5055
</dependencies>
5156
</project>

modules/text/src/test/java/hosh/modules/text/TextModuleTest.java

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@
4141
import hosh.modules.text.TextModule.Trim;
4242
import hosh.spi.test.support.RecordMatcher;
4343
import hosh.spi.CommandArguments;
44+
import net.jqwik.api.Arbitrary;
45+
import net.jqwik.api.Arbitraries;
46+
import net.jqwik.api.Assume;
47+
import net.jqwik.api.ForAll;
48+
import net.jqwik.api.Property;
49+
import net.jqwik.api.Provide;
50+
import net.jqwik.api.constraints.IntRange;
4451
import org.junit.jupiter.api.BeforeEach;
4552
import org.junit.jupiter.api.Nested;
4653
import org.junit.jupiter.api.Test;
@@ -54,6 +61,9 @@
5461

5562
import java.time.Clock;
5663
import java.time.Instant;
64+
import java.util.ArrayList;
65+
import java.util.Iterator;
66+
import java.util.List;
5767
import java.util.Optional;
5868

5969
import static hosh.spi.test.support.ExitStatusAssert.assertThat;
@@ -1933,4 +1943,57 @@ void zeroArgs() {
19331943
}
19341944
}
19351945

1946+
@Nested
1947+
class SortPropertyTest {
1948+
1949+
@Property
1950+
void sortIsIdempotent(@ForAll("textRecordLists") List<Record> input) {
1951+
Sort sut = new Sort();
1952+
List<Record> firstSort = runSort(sut, input);
1953+
List<Record> secondSort = runSort(sut, firstSort);
1954+
assertThat(firstSort).containsExactlyElementsOf(secondSort);
1955+
}
1956+
1957+
private List<Record> runSort(Sort sut, List<Record> input) {
1958+
List<Record> result = new ArrayList<>();
1959+
sut.run(CommandArguments.of("name"), fromList(input), result::add, record -> {});
1960+
return result;
1961+
}
1962+
}
1963+
1964+
@Nested
1965+
class TakeDropPropertyTest {
1966+
1967+
@Property
1968+
void takeAndDropPartitionInput(@ForAll("textRecordLists") List<Record> input,
1969+
@ForAll @IntRange(min = 0, max = 20) int n) {
1970+
Assume.that(n <= input.size());
1971+
Take takeSut = new Take();
1972+
Drop dropSut = new Drop();
1973+
OutputChannel noopErr = record -> {};
1974+
1975+
List<Record> taken = new ArrayList<>();
1976+
takeSut.run(CommandArguments.of(String.valueOf(n)), fromList(input), taken::add, noopErr);
1977+
1978+
List<Record> dropped = new ArrayList<>();
1979+
dropSut.run(CommandArguments.of(String.valueOf(n)), fromList(input), dropped::add, noopErr);
1980+
1981+
List<Record> combined = new ArrayList<>(taken);
1982+
combined.addAll(dropped);
1983+
assertThat(combined).containsExactlyElementsOf(input);
1984+
}
1985+
}
1986+
1987+
@Provide
1988+
Arbitrary<List<Record>> textRecordLists() {
1989+
return Arbitraries.strings().alpha().ofMinLength(0).ofMaxLength(10)
1990+
.map(s -> Records.singleton(Keys.NAME, Values.ofText(s)))
1991+
.list().ofMinSize(0).ofMaxSize(20);
1992+
}
1993+
1994+
private static InputChannel fromList(List<Record> records) {
1995+
Iterator<Record> it = records.iterator();
1996+
return () -> it.hasNext() ? Optional.of(it.next()) : Optional.empty();
1997+
}
1998+
19361999
}

spi/src/test/java/hosh/spi/ValuesTest.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,11 @@
3232
import org.junit.jupiter.params.provider.CsvSource;
3333
import net.jqwik.api.Arbitraries;
3434
import net.jqwik.api.Arbitrary;
35+
import net.jqwik.api.Assume;
3536
import net.jqwik.api.ForAll;
3637
import net.jqwik.api.Property;
3738
import net.jqwik.api.Provide;
39+
import net.jqwik.api.constraints.IntRange;
3840

3941
import java.nio.file.Path;
4042
import java.nio.file.Paths;
@@ -907,6 +909,22 @@ void sortMisc() {
907909
// Then
908910
assertThat(input).containsExactly("1.a", "2.a", "a.1", "b.1");
909911
}
912+
913+
@Property
914+
void reflexive(@ForAll String s) {
915+
assertThat(sut.compare(s, s)).isEqualTo(0);
916+
}
917+
918+
@Property
919+
void antisymmetric(@ForAll String a, @ForAll String b) {
920+
assertThat(Integer.signum(sut.compare(a, b))).isEqualTo(-Integer.signum(sut.compare(b, a)));
921+
}
922+
923+
@Property
924+
void transitive(@ForAll String a, @ForAll String b, @ForAll String c) {
925+
Assume.that(sut.compare(a, b) <= 0 && sut.compare(b, c) <= 0);
926+
assertThat(sut.compare(a, c)).isLessThanOrEqualTo(0);
927+
}
910928
}
911929

912930
@Nested
@@ -984,6 +1002,20 @@ void mergeSizeValues() {
9841002
assertThat(none.merge(size)).hasValue(size);
9851003
assertThat(size.merge(none)).hasValue(size);
9861004
}
1005+
1006+
@Property
1007+
void numericMergeIsCommutative(@ForAll int a, @ForAll int b) {
1008+
Value va = Values.ofNumeric(a);
1009+
Value vb = Values.ofNumeric(b);
1010+
assertThat(va.merge(vb)).isEqualTo(vb.merge(va));
1011+
}
1012+
1013+
@Property
1014+
void sizesMergeIsCommutative(@ForAll @IntRange(min = 0) int a, @ForAll @IntRange(min = 0) int b) {
1015+
Value va = Values.ofSize(a);
1016+
Value vb = Values.ofSize(b);
1017+
assertThat(va.merge(vb)).isEqualTo(vb.merge(va));
1018+
}
9871019
}
9881020

9891021
}

test-support/pom.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@
3232
<groupId>org.slf4j</groupId>
3333
<artifactId>slf4j-simple</artifactId>
3434
</dependency>
35+
<dependency>
36+
<groupId>net.jqwik</groupId>
37+
<artifactId>jqwik</artifactId>
38+
</dependency>
3539
<!-- test classpath -->
3640
<dependency>
3741
<groupId>org.junit.jupiter</groupId>

test-support/src/main/java/hosh/test/fitness/UnitTestsFitnessTest.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
import com.tngtech.archunit.lang.ConditionEvents;
3232
import com.tngtech.archunit.lang.SimpleConditionEvent;
3333
import com.tngtech.archunit.lang.syntax.ArchRuleDefinition;
34+
import net.jqwik.api.Property;
35+
import net.jqwik.api.Provide;
3436
import org.junit.jupiter.api.AfterEach;
3537
import org.junit.jupiter.api.BeforeEach;
3638
import org.junit.jupiter.api.Test;
@@ -55,7 +57,9 @@ public abstract class UnitTestsFitnessTest {
5557
.should().beAnnotatedWith(Test.class)
5658
.orShould().beAnnotatedWith(BeforeEach.class)
5759
.orShould().beAnnotatedWith(AfterEach.class)
58-
.orShould().beAnnotatedWith(ParameterizedTest.class);
60+
.orShould().beAnnotatedWith(ParameterizedTest.class)
61+
.orShould().beAnnotatedWith(Property.class)
62+
.orShould().beAnnotatedWith(Provide.class);
5963

6064
@SuppressWarnings("unused")
6165
@ArchTag("fitness")

0 commit comments

Comments
 (0)