If you want to see working tests before reading anything, clone the demo and run it first — it covers everything described here against a real stack:
Try the demo in 5 minutesOtherwise, keep reading to understand the setup step by step.
Requirements #
- Java 21+
- Maven 3.8+
Add dependencies #
Add the modules you need to your test pom.xml:
<dependency>
<groupId>dev.garlandframework</groupId>
<artifactId>garland-http</artifactId>
<version>1.0.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>dev.garlandframework</groupId>
<artifactId>garland-postgres</artifactId>
<version>1.0.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>dev.garlandframework</groupId>
<artifactId>garland-kafka</artifactId>
<version>1.0.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>dev.garlandframework</groupId>
<artifactId>garland-mongodb</artifactId>
<version>1.0.0</version>
<scope>test</scope>
</dependency>Configure clients #
Set up clients once per suite — typically in a @BeforeSuite method in a shared base class:
HttpTestClient http = new HttpTestClient(RetryConfig.of(3, Duration.ofSeconds(2)));
// authenticate once, reuse the token for all tests
TokenDto token = Pipeline.given(TestAuthRequests.login())
.then(http.makeCall(200, TokenDto.class))
.execute();
http = http.withBearer(token.token());
PostgresWrapper postgres = new PostgresWrapper(
PostgresConfig.builder()
.url("jdbc:postgresql://localhost:5432/mydb")
.username("user")
.password("pass")
.entity(UserEntity.class)
.entity(AddressEntity.class)
.build()
);
PostgresTestClient db = new PostgresTestClient(postgres, RetryConfig.of(5, Duration.ofSeconds(2)))
.withTemporalTolerance(Duration.ofNanos(1000));
KafkaTestClient kafka = new KafkaTestClient(
KafkaConfig.builder()
.bootstrapServers("localhost:9092")
.topic("user.created")
.topic("user.updated")
.topic("user.deleted")
.groupId(UUID.randomUUID().toString())
.build(),
RetryConfig.of(5, Duration.ofSeconds(2))
).withTemporalTolerance(Duration.ofMillis(1));Mapping between models #
Each system in a pipeline typically uses a different model: the HTTP response DTO needs to become a database entity for Postgres, an event object for Kafka, and a document for MongoDB. Mappers bridge these types so the pipeline stays a clean linear chain.
This is manual work — you write the mapper once per domain type and reuse it across all tests. In practice most of it can be generated by an LLM given the source and target classes. The field-level mapping declarations are the part that requires your input: you need to tell the mapper which fields map to which, what to compute (like fullName from name + surname), and what to ignore. Once those decisions are captured, the bridge methods and the rest are mechanical.
The examples below are taken from the demo project — replace the types with those from your own project. The pattern itself is the same regardless of domain.
The demo uses MapStruct to generate mapping code at compile time. The mapper interface declares the field-level conversions:
@Mapper
public interface UserTestMapper {
UserTestMapper INSTANCE = Mappers.getMapper(UserTestMapper.class);
@Mapping(source = "uuid", target = "id")
UserEntity toEntity(UserDto dto);
@Mapping(source = "uuid", target = "userId")
@Mapping(target = "fullName", expression = "java(dto.getName() + \" \" + dto.getSurname())")
@Mapping(target = "eventTimestamp", ignore = true)
@Mapping(target = "sourceSystem", constant = "user-service")
UserCreatedEvent toCreatedEvent(UserDto dto);
}Fields set by the production system (like timestamps) are ignored in the mapping — the test builds a partial expected object with only the fields it controls and asserts the rest via Verify.matching, which skips null fields.
Pipeline bridge methods wrap each mapper method in Step.lift(...) so it can be passed directly to .then(). These are purely mechanical and can be generated in full:
static Step<UserDto, UserEntity> toEntity() {
return Step.lift(INSTANCE::toEntity);
}
static Step<UserDto, UserCreatedEvent> toCreatedEvent() {
return Step.lift(INSTANCE::toCreatedEvent);
}
static Step<UserDto, UserProjectionDoc> dtoToCreatedProjectionDoc() {
return Step.lift(dto -> INSTANCE.toProjectionDoc(INSTANCE.toCreatedEvent(dto)));
}These static methods are what appear in the test pipeline — each one is a typed transformation the compiler can verify:
.then(YourMapper.toEntity()) // ResponseDto → DbEntity
.then(YourMapper.toEvent()) // ResponseDto → KafkaEvent
.then(YourMapper.toDocument()) // ResponseDto → MongoDocumentMapStruct is not a Garland requirement. Any approach that produces a Function<I, O> works — hand-written converters, ModelMapper, or inline lambdas passed directly to Step.lift.
Write a pipeline test #
The examples below come from the demo project. The types (UserDto, UserEntity, UserCreatedEvent, etc.) are specific to that domain — use the types from your own project in their place. The pipeline structure itself is regular and well-suited to LLM generation once your mappers and clients are in place.
Endpoint test — POST creates a user, assert 201 and the entity appears in Postgres:
@Test
public void createUser_persistedInDb() {
HttpCallRequest<UserDto> request = TestUserRequests.createUser();
Pipeline.given(request)
.then(http.makeCall(201, UserDto.class))
.then(trackUser())
.then(Verify.matching(request.dto()))
.then(UserTestMapper.toEntity())
.then(db.findByFields())
.execute();
}End-to-end test — same request, verified across all three systems in a single pipeline using Verify.allOf for fan-out:
@Test
public void createUser_fullSystemFlow() {
UserDto expected = TestUsers.defaultUser();
Pipeline.given(TestUserRequests.createUser(expected))
.then(http.makeCall(201, UserDto.class))
.then(Verify.matching(expected))
.then(trackUser())
.then(Verify.allOf(
UserTestMapper.toEntity().andThen(db.findByFields()),
UserTestMapper.toCreatedEvent().andThen(kafka.consumeMatching(UserCreatedEvent.class)),
UserTestMapper.dtoToCreatedProjectionDoc().andThen(mongo.findByFields())
))
.execute();
}Verify.allOf runs all three branches against the same input, collects every failure, and reports them together — one pipeline, one assertion, three systems checked.
Run the demo #
All examples on this page come from garland-demo — two Spring Boot microservices with a complete test suite covering HTTP, Postgres, Kafka, and MongoDB. Running it gives you working tests to read alongside this guide.
Try the demo in 5 minutes