Creating a KTestify Plugin
This guide walks you through building a complete KTestify plugin from scratch. We'll use a fictional Example Plugin that reads and validates records from a hypothetical Example Transport.
A KTestify plugin typically has two main responsibilities:
- Implement the
KtestifyPluginSPI interface - Provide Cucumber step definitions that users can call in Gherkin
Phase 0: Project Setupโ
Clone the Skeletonโ
The easiest way to start is with the plugin skeleton template:
git clone https://github.com/ktestify/ktestify-plugin-skeleton.git \
ktestify-plugin-example
cd ktestify-plugin-example
Or you can set up from scratch with Maven:
mvn archetype:generate \
-DgroupId=io.github.ktestify \
-DartifactId=ktestify-plugin-example \
-DarchetypeArtifactId=maven-archetype-quickstart \
-Dpackage=io.github.ktestify.example
POM configurationโ
Here's the essential POM setup:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" ...>
<modelVersion>4.0.0</modelVersion>
<groupId>io.github.ktestify</groupId>
<artifactId>ktestify-plugin-example</artifactId>
<version>1.0-SNAPSHOT</version>
<name>KTestify Example Plugin</name>
<description>Example plugin demonstrating RecordFetcher implementation</description>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>25</maven.compiler.source>
<maven.compiler.target>25</maven.compiler.target>
</properties>
<dependencyManagement>
<dependencies>
<!-- ktestify-core provides the plugin SPI and matching infrastructure -->
<dependency>
<groupId>io.github.ktestify</groupId>
<artifactId>ktestify-core</artifactId>
<version>0.0.1-SNAPSHOT</version>
<scope>provided</scope>
</dependency>
<!-- Typesafe Config for HOCON parsing -->
<dependency>
<groupId>com.typesafe</groupId>
<artifactId>config</artifactId>
<version>1.4.3</version>
</dependency>
<!-- Logging -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.11</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.23.0</version>
<scope>test</scope>
</dependency>
<!-- Cucumber for step definitions -->
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-java</artifactId>
<version>7.15.0</version>
</dependency>
<!-- Lombok for @Slf4j annotation -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
<scope>provided</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.3.0</version>
</plugin>
</plugins>
</build>
</project>
Phase 1: Create the Plugin Classโ
Step 1.1, Implement KtestifyPluginโ
Create src/main/java/io/github/ktestify/example/ExamplePlugin.java:
package io.github.ktestify.example;
import io.github.ktestify.example.config.ExampleConfig;
import io.github.ktestify.plugin.KtestifyPlugin;
import io.github.ktestify.plugin.PluginContext;
import lombok.extern.slf4j.Slf4j;
/**
* KTestify plugin for the Example transport.
*
* Implementation summary,
* - Loads config from ktestify.plugins.example in HOCON
* - Provides Cucumber steps in io.github.ktestify.example.steps package
* - Manages the ExampleClient singleton lifecycle
*/
@Slf4j
public class ExamplePlugin implements KtestifyPlugin {
private static final String PLUGIN_ID = "example";
private static final String VERSION = "1.0-SNAPSHOT";
// Shared client singleton across all scenarios
private ExampleClient exampleClient;
@Override
public String getId() {
return PLUGIN_ID;
}
@Override
public String getVersion() {
return VERSION;
}
@Override
public String getAuthorName() {
return "Your Team";
}
@Override
public String getAuthorEmail() {
return "your-team@example.com";
}
@Override
public String getGluePackage() {
return "io.github.ktestify.example.steps";
}
@Override
public void initialize(PluginContext context) {
log.info("Initializing {} plugin v{}โฆ", getId(), getVersion());
// Load plugin configuration
ExampleConfig cfg = ExampleConfig.from(context.getConfig().getRaw());
// Validate required settings
if (!cfg.hasEndpoint()) {
throw new io.github.ktestify.exceptions.PluginException(
"Example plugin requires 'endpoint' configuration. "
+ "Set KTESTIFY_EXAMPLE_ENDPOINT or add to application.conf");
}
// Initialize shared client
this.exampleClient = new ExampleClient(cfg);
log.info("{} plugin initialized successfully.", getId());
}
@Override
public void shutdown() {
if (exampleClient != null) {
try {
exampleClient.close();
log.info("{} plugin shut down.", getId());
} catch (Exception e) {
log.warn("Error closing {} client: {}", getId(), e.getMessage());
}
}
}
/**
* Retrieve the shared Example client (for use by step definitions).
*/
public ExampleClient getClient() {
return exampleClient;
}
}
Step 1.2, Create the Configuration Classโ
Create src/main/java/io/github/ktestify/example/config/ExampleConfig.java:
package io.github.ktestify.example.config;
import com.typesafe.config.Config;
import java.time.Duration;
import lombok.Value;
/**
* Configuration for the Example plugin.
* Reads from ktestify.plugins.example in HOCON.
*/
@Value
public class ExampleConfig {
private final String endpoint;
private final String apiKey;
private final Duration readTimeout;
public static ExampleConfig from(Config fullConfig) {
Config cfg = fullConfig.getConfig("ktestify.plugins.example");
return new ExampleConfig(
cfg.getString("endpoint"),
cfg.getString("api-key"),
cfg.getDuration("read-timeout"));
}
public boolean hasEndpoint() {
return endpoint != null && !endpoint.isBlank();
}
public boolean hasApiKey() {
return apiKey != null && !apiKey.isBlank();
}
}
Step 1.3, Create the Transport Clientโ
Create src/main/java/io/github/ktestify/example/ExampleClient.java:
package io.github.ktestify.example;
import io.github.ktestify.example.config.ExampleConfig;
import lombok.extern.slf4j.Slf4j;
/**
* Wrapper around the external Example Transport SDK.
* This is where you interact with the actual external system.
*/
@Slf4j
public class ExampleClient implements AutoCloseable {
private final ExampleConfig config;
// Replace with your actual SDK client
// private final ExternalSdkClient sdkClient;
public ExampleClient(ExampleConfig config) {
this.config = config;
// Initialize SDK client
// this.sdkClient = new ExternalSdkClient()
// .withEndpoint(config.getEndpoint())
// .withApiKey(config.getApiKey())
// .build();
log.debug("ExampleClient initialized with endpoint: {}", config.getEndpoint());
}
/**
* Fetch records from the Example source.
* This is called by ExampleRecordFetcher.
*/
public String fetchRecord(String recordId) {
// Call the external SDK
// return sdkClient.get(recordId);
return "example-record-content";
}
@Override
public void close() throws Exception {
// Clean up SDK resources
// if (sdkClient != null) sdkClient.close();
log.debug("ExampleClient closed");
}
}
Phase 2: Create the Transport Layerโ
Step 2.1, Implement RecordFetcherโ
This is how ktestify-core will fetch records from your transport. Create src/main/java/io/github/ktestify/example/transport/ExampleRecordFetcher.java:
package io.github.ktestify.example.transport;
import io.github.ktestify.example.ExamplePlugin;
import io.github.ktestify.io.RecordFetcher;
import io.github.ktestify.models.ConsumedRecord;
import io.github.ktestify.plugin.PluginRegistry;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
/**
* Fetches records from the Example transport.
*
* Implements the RecordFetcher interface from ktestify-core,
* which separates transport concerns from matching logic.
*/
@Slf4j
public class ExampleRecordFetcher implements RecordFetcher<String> {
private final String recordId;
private final Long readTimeout;
public ExampleRecordFetcher(String recordId, Long readTimeout) {
this.recordId = recordId;
this.readTimeout = readTimeout != null ? readTimeout : 30_000L;
}
@Override
public List<ConsumedRecord<String>> fetch() {
log.debug("Fetching record: {} (timeout: {}ms)", recordId, readTimeout);
long startTime = System.currentTimeMillis();
List<ConsumedRecord<String>> records = new ArrayList<>();
while (System.currentTimeMillis() - startTime < readTimeout) {
try {
// Get the Example plugin
ExamplePlugin plugin = (ExamplePlugin) PluginRegistry.load(null).getPlugins()
.stream()
.filter(p -> p.getId().equals("example"))
.findFirst()
.orElseThrow(() -> new RuntimeException("Example plugin not loaded"));
// Fetch from the transport
String content = plugin.getClient().fetchRecord(recordId);
if (content != null) {
ConsumedRecord<String> record = ConsumedRecord.<String>builder()
.topic("example-topic")
.partition(0)
.offset(1L)
.timestamp(Instant.now())
.key(recordId)
.value(content)
.build();
records.add(record);
log.debug("Record found: {}", recordId);
return records;
}
// Not found yet, wait and retry
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
log.warn("Record not found within timeout: {} ({}ms)", recordId, readTimeout);
return records;
}
}
Step 2.2, Create the Consumer Orchestratorโ
Create src/main/java/io/github/ktestify/example/orchestration/ExampleConsumer.java:
package io.github.ktestify.example.orchestration;
import io.github.ktestify.example.transport.ExampleRecordFetcher;
import io.github.ktestify.io.RecordFetcher;
import io.github.ktestify.match.RecordMatcher;
import io.github.ktestify.match.RecordMatcherFactory;
import io.github.ktestify.models.ConsumerContext;
import io.github.ktestify.models.ConsumedRecord;
import java.util.List;
import java.util.concurrent.Callable;
import lombok.extern.slf4j.Slf4j;
/**
* Orchestrates the Example transport flow: fetch, record โ match.
*
* This is the "orchestration layer" that wires together:
* - Transport (RecordFetcher)
* - Assertion (RecordMatcher from ktestify-core)
*/
@Slf4j
public class ExampleConsumer implements Callable<Boolean> {
private final ConsumerContext<String, String> context;
private final RecordMatcher<String> matcher;
public ExampleConsumer(ConsumerContext<String, String> context) {
this.context = context;
// Use the matcher specified in context or resolve a default
this.matcher = RecordMatcherFactory.forRaw(context.getMatchMethod());
}
@Override
public Boolean call() throws Exception {
log.info("Starting Example consumer (timeout: {}ms)", context.getReadTimeout());
// Phase 1: Fetch records using our transport fetcher
RecordFetcher<String> fetcher = new ExampleRecordFetcher(
context.getExpectedRecordKey(), context.getReadTimeout());
List<ConsumedRecord<String>> records = fetcher.fetch();
if (records.isEmpty()) {
log.warn("No records fetched from Example transport");
return false;
}
// Phase 2: Match records using ktestify matchers
var matchResult = matcher.match(records, context);
if (matchResult.passed()) {
log.info("Example consumer: assertion PASSED");
return true;
} else {
log.error("Example consumer: assertion FAILED, {}", matchResult.getDiffMessage());
return false;
}
}
}
Phase 3: Create Cucumber Step Definitionsโ
Step 3.1, Background Stepsโ
Create src/main/java/io/github/ktestify/example/steps/BackgroundSteps.java:
package io.github.ktestify.example.steps;
import io.cucumber.datatable.DataTable;
import io.cucumber.java.en.Given;
import lombok.extern.slf4j.Slf4j;
/**
* Cucumber steps for setting up the Example transport in Gherkin scenarios.
* These are called in the Background to configure test resources.
*/
@Slf4j
public class BackgroundSteps {
private final SharedExampleResources resources;
public BackgroundSteps(SharedExampleResources resources) {
this.resources = resources;
}
/**
* Register an Example record source in the scenario context.
*
* Example usage:
* Given Example source
* | sourceName | sourceAlias | readTimeout |
* | source-1 | main | 30 |
*/
@Given("Example source")
public void givenExampleSource(DataTable table) {
var row = table.asMap(String.class, String.class);
String sourceName = row.get("sourceName");
String sourceAlias = row.get("sourceAlias");
Integer readTimeout = row.get("readTimeout") != null
? Integer.parseInt(row.get("readTimeout"))
: 30;
log.info("Registering Example source: {} (alias: {})", sourceName, sourceAlias);
resources.registerSource(sourceAlias, sourceName, readTimeout * 1000L);
}
}
Step 3.2, Action Stepsโ
Create src/main/java/io/github/ktestify/example/steps/ActionSteps.java:
package io.github.ktestify.example.steps;
import io.cucumber.datatable.DataTable;
import io.cucumber.java.en.When;
import lombok.extern.slf4j.Slf4j;
/**
* Steps that perform actions: produce records, upload files, etc.
*/
@Slf4j
public class ActionSteps {
private final SharedExampleResources resources;
public ActionSteps(SharedExampleResources resources) {
this.resources = resources;
}
/**
* Send a record to an Example source.
*
* Example usage:
* When record is sent to Example
* | sourceAlias | recordId | payload |
* | main | REC-12345 | {"order": "ORD-001"} |
*/
@When("record is sent to Example")
public void whenRecordIsSentToExample(DataTable table) {
var row = table.asMap(String.class, String.class);
String sourceAlias = row.get("sourceAlias");
String recordId = row.get("recordId");
String payload = row.get("payload");
log.info("Sending record {} to source {}", recordId, sourceAlias);
resources.sendRecord(sourceAlias, recordId, payload);
}
}
Step 3.3, Validation Stepsโ
Create src/main/java/io/github/ktestify/example/steps/ValidationSteps.java:
package io.github.ktestify.example.steps;
import io.cucumber.datatable.DataTable;
import io.cucumber.java.en.Then;
import lombok.extern.slf4j.Slf4j;
/**
* Steps for asserting on records: "Then expected record..."
*/
@Slf4j
public class ValidationSteps {
private final SharedExampleResources resources;
public ValidationSteps(SharedExampleResources resources) {
this.resources = resources;
}
/**
* Validate a record against expected content.
*
* Example usage:
* Then expected record from Example
* | sourceAlias | recordId | file | readTimeout |
* | main | REC-001 | expected.json | 30 |
*/
@Then("expected record from Example")
public void thenExpectedRecordFromExample(DataTable table) {
var row = table.asMap(String.class, String.class);
String sourceAlias = row.get("sourceAlias");
String recordId = row.get("recordId");
String file = row.get("file");
Integer readTimeout = row.get("readTimeout") != null
? Integer.parseInt(row.get("readTimeout"))
: 30;
log.info("Validating record {} from {} (timeout: {}s)", recordId, sourceAlias, readTimeout);
boolean passed = resources.validateRecord(sourceAlias, recordId, file, readTimeout * 1000L);
if (!passed) {
throw new AssertionError("Record validation failed: " + recordId);
}
}
}
Step 3.4, Shared Resourcesโ
Create src/main/java/io/github/ktestify/example/steps/SharedExampleResources.java:
package io.github.ktestify.example.steps;
import io.github.ktestify.example.orchestration.ExampleConsumer;
import io.github.ktestify.models.ConsumerContext;
import lombok.extern.slf4j.Slf4j;
/**
* Holds shared state and resources across Example steps within a single scenario.
* Injected by Cucumber into step classes via PicoContainer.
*/
@Slf4j
public class SharedExampleResources {
private final java.util.Map<String, ExampleSourceContext> sources = new java.util.HashMap<>();
static class ExampleSourceContext {
String sourceName;
Long readTimeout;
ExampleSourceContext(String sourceName, Long readTimeout) {
this.sourceName = sourceName;
this.readTimeout = readTimeout;
}
}
void registerSource(String alias, String sourceName, Long readTimeout) {
sources.put(alias, new ExampleSourceContext(sourceName, readTimeout));
}
void sendRecord(String sourceAlias, String recordId, String payload) {
// TODO: implement sending logic
log.info("Sent record {} to source {}", recordId, sourceAlias);
}
boolean validateRecord(String sourceAlias, String recordId, String file, Long readTimeout) {
ExampleSourceContext ctx = sources.get(sourceAlias);
if (ctx == null) {
throw new IllegalArgumentException("Source not registered: " + sourceAlias);
}
// Build a ConsumerContext for the assertion
ConsumerContext<String, String> consumerCtx = ConsumerContext.<String, String>builder()
.matchMethod(io.github.ktestify.match.RecordMatcherFactory.METHOD_MATCH_FILE)
.matchFilePath(file)
.expectedRecordKey(recordId)
.readTimeout(readTimeout)
.build();
try {
ExampleConsumer consumer = new ExampleConsumer(consumerCtx);
return consumer.call();
} catch (Exception e) {
log.error("Validation failed", e);
return false;
}
}
}
Phase 4: Configuration and Service Registrationโ
Step 4.1, Create reference.confโ
Create src/main/resources/reference.conf:
ktestify.plugins.example {
# Example Transport endpoint (required)
# Can be overridden via KTESTIFY_EXAMPLE_ENDPOINT
endpoint = ""
endpoint = ${?KTESTIFY_EXAMPLE_ENDPOINT}
# API key for authentication
api-key = ""
api-key = ${?KTESTIFY_EXAMPLE_API_KEY}
# Record read timeout (how long to wait for a record to appear)
read-timeout = 30s
read-timeout = ${?KTESTIFY_EXAMPLE_READ_TIMEOUT}
}
Step 4.2, RegisterServiceProviderโ
Create the SPI descriptor at src/main/resources/META-INF/services/io.github.ktestify.plugin.KtestifyPlugin:
io.github.ktestify.example.ExamplePlugin
This single line tells Java's ServiceLoader to auto-discover your plugin.
Phase 5: Complete Example Scenarioโ
Create src/test/resources/features/example.feature:
Feature: Example transport integration
Background:
Given namespace
| namespace |
| test-org |
Given Example source
| sourceName | sourceAlias | readTimeout |
| source-1 | main | 30 |
Scenario: Send and validate a record
When record is sent to Example
| sourceAlias | recordId | payload |
| main | REC-001 | {"order_id": "ORD-1001"} |
Then expected record from Example
| sourceAlias | recordId | file | readTimeout |
| main | REC-001 | expected.json | 30 |
Phase 6: Building and Testingโ
Compile the plugin:โ
mvn clean compile
Run unit tests:โ
mvn test
Build the JAR:โ
mvn package
This produces target/ktestify-plugin-example-1.0-SNAPSHOT.jar.
Use in ktestify-cucumberโ
Option A: As a Maven dependency
<dependency>
<groupId>io.github.ktestify</groupId>
<artifactId>ktestify-plugin-example</artifactId>
<version>1.0-SNAPSHOT</version>
<scope>test</scope>
</dependency>
Option B: Drop the JAR into /workspace/plugins
docker run --rm \
-v $(pwd)/features:/workspace/features \
-v $(pwd)/plugins:/workspace/plugins \
ghcr.io/ktestify/ktestify-cucumber:latest \
/workspace/features
Checklist for Production Qualityโ
Before publishing your plugin, ensure it meets these criteria:
- Plugin implements all methods of
KtestifyPlugininterface - Configuration is loaded from
ktestify.plugins.<id>HOCON subtree -
initialize()validates required config and throwsPluginExceptionon failure -
shutdown()releases all resources (connections, threads, etc.) - Plugin is registered in
META-INF/services/io.github.ktestify.plugin.KtestifyPlugin - Step definitions are in a discoverable package (returned by
getGluePackage()) -
RecordFetcher<V>implementation returnsConsumedRecord<V>correctly -
reference.confdocuments all configuration keys with sensible defaults - Logging uses SLF4J via
@Slf4jannotation - Documentation includes configuration examples and usage scenarios
- README contains a clear description of what the plugin does
- Unit and integration tests pass
- Code is formatted with Maven Spotless
- Dependency versions are managed in POM parent or BOM
Common Patternsโ
Accessing Plugin from Stepsโ
@Given("some step")
public void someStep() {
ExamplePlugin plugin = (ExamplePlugin) PluginRegistry.load(null)
.getPlugins()
.stream()
.filter(p -> p.getId().equals("example"))
.findFirst()
.orElseThrow();
plugin.getClient().doSomething();
}
Reading Complex Configโ
@Override
public void initialize(PluginContext context) {
Config cfg = context.getConfig().getRaw();
if (cfg.hasPath("ktestify.plugins.example.advanced")) {
Config advCfg = cfg.getConfig("ktestify.plugins.example.advanced");
// Navigate nested HOCON structure
}
}
Three-Layer Pattern for Pluginsโ
Plugin --(provides)--> Transport (RecordFetcher)
Transport --(returns)--> ConsumedRecord<V>
Orchestration (Consumer) --(uses)--> RecordMatcher
Resourcesโ
- ktestify-plugin-skeleton, template repo
- ktestify-plugin-azureblob, full reference implementation
- Plugin System Documentation, system overview
- ktestify-core API, base classes and interfaces
Next Stepsโ
Once your plugin is complete and tested,
- Publish to Maven Central, follow Sonatype's guide
- Create a GitHub repository, use
ktestify-plugin-*naming convention - Document on docs.ktestify.xyz, add a page next to the Azure Blob example
- Announce in the ktestify community, open a discussion thread
Happy plugin building!