Skip to content
Merged
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,19 @@ and try to answer that question in the javadocs.

We use Lombok sparingly. Most of its features are disabled in lombok.config.

### Commits

- Commits in a PR should ideally be rebased and massaged to follow these guidelines prior to committing, to give a clean history:

- Commits should be in this order:
1. Fixes for existing bugs (including new tests for those bugs)
2. Refactoring to make subsequent work easier
3. The newly added functionality
- Mechanical refactorings (eg. using an IDE) should be in their own commit describing what they do in ehough detail that they could be repeated if necessary
- Avoid merging a bug and its fix in the same PR. Prefer squashing the fix into the commit with the bug so it looks like the bug was never there.
- Each commit should have correct spotless formatting
- Each commit should pass all tests unless it's marked as WIP or is explicitly doing test-driven development

### What to avoid

- Do not use Mockito or other mocking libraries for tests.
Expand Down
2 changes: 1 addition & 1 deletion bosk-core/src/test/java/works/bosk/BoskContextTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ void setupBosk() {
bosk = new Bosk<>(
boskName(),
TestEntity.class,
AbstractDriverTest::initialState,
this::initialState,
BoskConfig.simple()
);
}
Expand Down
20 changes: 10 additions & 10 deletions bosk-core/src/test/java/works/bosk/IdentifierTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import java.lang.reflect.Parameter;
import java.lang.reflect.AnnotatedElement;
import java.util.List;
import works.bosk.junit.InjectFrom;
import works.bosk.junit.InjectedTest;
import works.bosk.junit.ParameterInjector;
import works.bosk.junit.Injector;

import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
Expand All @@ -30,11 +30,11 @@ void invalidString_throws(@Invalid String invalidString) {
@Target(PARAMETER)
@interface Invalid {}

record ValidInjector() implements ParameterInjector {
record ValidInjector() implements Injector {
@Override
public boolean supportsParameter(Parameter parameter) {
return parameter.getType().equals(String.class)
&& !parameter.isAnnotationPresent(Invalid.class);
public boolean supports(AnnotatedElement element, Class<?> elementType) {
return elementType.equals(String.class)
&& !element.isAnnotationPresent(Invalid.class);
}

@Override
Expand All @@ -43,11 +43,11 @@ public List<String> values() {
}
}

record InvalidInjector() implements ParameterInjector {
record InvalidInjector() implements Injector {
@Override
public boolean supportsParameter(Parameter parameter) {
return parameter.getType().equals(String.class)
&& parameter.isAnnotationPresent(Invalid.class);
public boolean supports(AnnotatedElement element, Class<?> elementType) {
return elementType.equals(String.class)
&& element.isAnnotationPresent(Invalid.class);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import org.junit.jupiter.api.BeforeEach;
import works.bosk.Bosk;
import works.bosk.BoskConfig;
import works.bosk.testing.drivers.AbstractDriverTest;
import works.bosk.testing.drivers.DriverConformanceTest;
import works.bosk.testing.drivers.state.TestEntity;

Expand All @@ -20,7 +19,7 @@ void setupDriverFactory() {
replicaBosk = new Bosk<>(
boskName("Replica"),
TestEntity.class,
AbstractDriverTest::initialState,
this::initialState,
BoskConfig.<TestEntity>builder().driverFactory(replicaSet.driverFactory()).build());
driverFactory = replicaSet.driverFactory();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@ public interface Refs {
@Test
void joinAfterUpdate_correctInitialState() throws InvalidTypeException {
var replicaSet = new ReplicaSet<TestEntity>();
var bosk1 = new Bosk<>(boskName("bosk1"), TestEntity.class, AbstractDriverTest::initialState, BoskConfig.<TestEntity>builder().driverFactory(replicaSet.driverFactory()).build());
var bosk1 = new Bosk<>(boskName("bosk1"), TestEntity.class, this::initialState, BoskConfig.<TestEntity>builder().driverFactory(replicaSet.driverFactory()).build());
var refs1 = bosk1.rootReference().buildReferences(Refs.class);
bosk1.driver().submitReplacement(refs1.string(), "New value");

var bosk2 = new Bosk<>(boskName("bosk2"), TestEntity.class, AbstractDriverTest::initialState, BoskConfig.<TestEntity>builder().driverFactory(replicaSet.driverFactory()).build());
var bosk2 = new Bosk<>(boskName("bosk2"), TestEntity.class, this::initialState, BoskConfig.<TestEntity>builder().driverFactory(replicaSet.driverFactory()).build());
var refs2 = bosk2.rootReference().buildReferences(Refs.class);
try (var _ = bosk2.readSession()) {
assertEquals("New value", refs2.string().value());
Expand All @@ -34,13 +34,13 @@ void joinAfterUpdate_correctInitialState() throws InvalidTypeException {
@Test
void secondaryConstructedInPrimaryReadSession_seesLatestState() throws InvalidTypeException {
var replicaSet = new ReplicaSet<TestEntity>();
var bosk1 = new Bosk<>(boskName("bosk1"), TestEntity.class, AbstractDriverTest::initialState, BoskConfig.<TestEntity>builder().driverFactory(replicaSet.driverFactory()).build());
var bosk1 = new Bosk<>(boskName("bosk1"), TestEntity.class, this::initialState, BoskConfig.<TestEntity>builder().driverFactory(replicaSet.driverFactory()).build());
var refs1 = bosk1.rootReference().buildReferences(Refs.class);

Bosk<TestEntity> bosk2;
try (var _ = bosk1.readSession()) {
bosk1.driver().submitReplacement(refs1.string(), "New value");
bosk2 = new Bosk<>(boskName("bosk2"), TestEntity.class, AbstractDriverTest::initialState, BoskConfig.<TestEntity>builder().driverFactory(replicaSet.driverFactory()).build());
bosk2 = new Bosk<>(boskName("bosk2"), TestEntity.class, this::initialState, BoskConfig.<TestEntity>builder().driverFactory(replicaSet.driverFactory()).build());
}
var refs2 = bosk2.rootReference().buildReferences(Refs.class);
try (var _ = bosk2.readSession()) {
Expand Down
257 changes: 257 additions & 0 deletions bosk-junit/USERS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
# Bosk JUnit Parameter Injection - User Guide

_Written with an LLM. This document needs a lot of work to be a decent user guide._

This document describes the parameter injection feature that lets you write parameterized tests where parameters are injected by custom `Injector` implementations.

### Core Annotations

**`@InjectFrom`**
Applied to a test class. Specifies which `Injector` classes to use:

```java
@InjectFrom({StringInjector.class, IntInjector.class})
class MyTest {
@InjectedTest
void test(String s, int i) { }
}
```

The order matters: earlier injectors can provide constructor parameters for later injectors.

---

**`@Injected`**
Applied to fields or method parameters to mark them for injection:

```java
// For fields (class-level injection)
@Injected String myField;

// For parameters (method-level injection)
@InjectedTest
void test(String s) { }
```
Comment thread
prdoyle marked this conversation as resolved.

---

**`@InjectedTest`**
Marks a method as a test that receives injected parameters:

```java
@InjectedTest
void test(String name, int count) { }
```

---

**`@InjectFields`**
Required when using field injection. Enables class-level templates:

```java
@InjectFields
@InjectFrom(MyInjector.class)
class MyTest {
@Injected String value;

@InjectedTest
void test() { }
}
```

---

### The Injector Interface

Create your own `Injector` implementations to provide test values:

```java
record StringInjector() implements Injector {
@Override
public boolean supports(AnnotatedElement element, Class<?> elementType) {
return elementType == String.class;
}

@Override
public List<String> values() {
return List.of("a", "b", "c");
}
}
```

An injector can target either fields or parameters (or both) by implementing `supportsField()` and `supportsParameter()`.

---

### How It Works

**Method-Level Injection:**
Each combination of injector values creates a separate test invocation. If you have:
- `StringInjector` with values: `"a", "b"`
- `IntInjector` with values: `1, 2`

You get 4 test invocations (2 × 2), one for each combination.

---

**Field Injection:**
The test class is instantiated multiple times, once per combination. Each instance has its `@Injected` fields set to the corresponding values:

```java
@InjectFields
@InjectFrom(StringInjector.class)
class MyTest {
@Injected String value;

@InjectedTest
void test() {
// Called once per value: "a", then "b"
}
}
```

---

### Dependent Injectors

Injectors can depend on other injectors. An injector with constructor parameters receives values from earlier injectors:

```java
record BaseInjector() implements Injector {
@Override
public boolean supports(AnnotatedElement e, Class<?> t) { return t == int.class; }
@Override
public List<Integer> values() { return List.of(10, 20); }
}

record DependentInjector(int baseValue) implements Injector {
@Override
public boolean supports(AnnotatedElement e, Class<?> t) { return t == String.class; }
@Override
public List<String> values() {
return List.of("based-on-" + baseValue); // Uses baseValue!
}
}

@InjectFrom({BaseInjector.class, DependentInjector.class})
class MyTest {
@Injected int baseValue;
@Injected String dependentValue;

@InjectedTest
void test() { }
}
```

**Important:** The test runs 2 times, not 4. The values are correlated:
- baseValue=10, dependentValue="based-on-10"
- baseValue=20, dependentValue="based-on-20"

This is because `DependentInjector` receives `baseValue` from `BaseInjector` as a constructor parameter. They are instantiated together, one instance per base value.

---

### Combined Field + Parameter Injection

You can use both field and method-level injection together:

```java
@InjectFields
@InjectFrom({BaseInjector.class, DependentInjector.class})
class MyTest {
@Injected int fieldValue;

@InjectedTest
void test(String paramValue) {
// fieldValue from field injection
// paramValue from method injection
}
}
```

When an injector is used for both a field and a parameter, the SAME injector instance is used for both, preserving value correlations.

---

### Inheritance

Field injection works through class inheritance. If a superclass has `@InjectFields` and `@InjectFrom`, all subclasses will also have their fields injected, multiplying the number of test runs.

```java
// Superclass
@InjectFields
@InjectFrom(ValueInjector.class)
class ParentTest {
@Injected String parentValue;

@InjectedTest
void test() { }
}

// Subclass - runs twice (once per parentValue)
@InjectFrom(ChildInjector.class)
class ChildTest extends ParentTest {
@Injected String childValue;

// Runs 2 × 2 = 4 times:
// parentValue="a", childValue="x"
// parentValue="a", childValue="y"
// parentValue="b", childValue="x"
// parentValue="b", childValue="y"
}

record ValueInjector() implements Injector {
@Override public boolean supports(AnnotatedElement e, Class<?> t) { return t == String.class; }
@Override public List<String> values() { return List.of("a", "b"); }
}

record ChildInjector() implements Injector {
@Override public boolean supports(AnnotatedElement e, Class<?> t) { return t == String.class; }
@Override public List<String> values() { return List.of("x", "y"); }
}
```

The `@InjectFrom` annotations from parent and child are combined, and all injectors participate in the cartesian product (or correlation, if dependent).

---

### Corner Cases

**1. Injector with multiple values + dependent injector:**
```java
record MultiValueInjector() implements Injector {
@Override public List<String> values() { return List.of("a", "b"); }
}

record DependentInjector(String prefix) implements Injector {
@Override public List<String> values() {
return List.of(prefix + "-x", prefix + "-y"); // 2 values per prefix
}
}

@InjectFrom({MultiValueInjector.class, DependentInjector.class})
```
Result: 4 test invocations (2 × 2 = 4), all valid combinations.

**2. Injector not supporting an element:**
If an injector returns `false` from `supports()`, it's simply not used for that element. No error occurs.

**3. No matching injector:**
Parameter injection can coexist with other resolvers.
If a parameter has no matching resolver, that is an error.

Fields annotated with `@Injected` can only be resolved by injectors.
If such a field has no matching injector, that is an error.

**4. Injector order determines precedence:**
When multiple injectors could provide values for the same element, the LATER injector in `@InjectFrom` takes precedence.

---

### Summary of Expectations

1. **Cartesian product by default:** Unrelated injectors multiply test count
2. **Correlation when dependent:** Dependent injectors maintain value consistency
3. **Field + parameter sharing:** Same injector class used for both field and parameter uses the same instance
4. **Order matters:** `@InjectFrom` order determines constructor injection dependencies and override precedence
5. **Inheritance multiplies:** Field injection in a superclass multiplies all subclass test runs
1 change: 1 addition & 0 deletions bosk-junit/src/main/java/module-info.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/
module works.bosk.junit {
requires transitive org.junit.jupiter.api;
requires org.slf4j;

exports works.bosk.junit;
}
Loading
Loading