Skip to main content

Creating a KTestify Plugin

This guide walks you through creating a ktestify plugin from start to finish using the ktestify-plugin-archetype Maven archetype. The archetype generates a complete, compilable project with all the boilerplate β€” plugin SPI class, config reader, transport layer, Cucumber step definitions, services, unit tests, and build configuration.

tip

The archetype is the recommended way to start a new plugin. It ensures your project follows the correct structure, naming conventions, and build setup from day one.


How the Archetype Works​

The ktestify plugin archetype is a standard Maven archetype that uses Velocity templating to generate a project tailored to your plugin. When you run mvn archetype:generate, Maven prompts you for a set of parameters (plugin name, author, GitHub repo, etc.) and produces a complete project directory.

What gets generated​

ktestify-plugin-myplugin/
β”œβ”€β”€ pom.xml ← Full POM with Spotless, JaCoCo, release profile
β”œβ”€β”€ README.md ← Plugin README with step examples
β”œβ”€β”€ LICENSE ← Apache 2.0
β”œβ”€β”€ cliff.toml ← git-cliff changelog config
β”œβ”€β”€ .gitignore
β”œβ”€β”€ .spotless/
β”‚ └── HEADER.txt ← Apache 2.0 license header (with your name)
└── src/
β”œβ”€β”€ main/
β”‚ β”œβ”€β”€ java/.../
β”‚ β”‚ β”œβ”€β”€ MyPluginPlugin.java ← KtestifyPlugin SPI implementation
β”‚ β”‚ β”œβ”€β”€ config/
β”‚ β”‚ β”‚ └── MyPluginConfig.java ← Typed HOCON config reader
β”‚ β”‚ β”œβ”€β”€ io/
β”‚ β”‚ β”‚ β”œβ”€β”€ MyPluginRecordFetcher.java ← RecordFetcher<String> (transport layer)
β”‚ β”‚ β”‚ β”œβ”€β”€ MyPluginConsumer.java ← Orchestration (fetch β†’ match β†’ result)
β”‚ β”‚ β”‚ └── MyPluginConsumerContext.java ← Immutable per-fetch context
β”‚ β”‚ β”œβ”€β”€ entities/
β”‚ β”‚ β”‚ └── KtestifyMyPluginEntity.java ← Resource entity
β”‚ β”‚ β”œβ”€β”€ services/
β”‚ β”‚ β”‚ β”œβ”€β”€ MyPluginActionService.java ← Send/upload service
β”‚ β”‚ β”‚ └── MyPluginValidationService.java ← Validation + timeout orchestration
β”‚ β”‚ └── steps/
β”‚ β”‚ β”œβ”€β”€ MyPluginBackgroundSteps.java ← @Given steps
β”‚ β”‚ β”œβ”€β”€ MyPluginActionSteps.java ← @When steps
β”‚ β”‚ β”œβ”€β”€ MyPluginValidationSteps.java ← @Then / @And steps
β”‚ β”‚ └── SharedMyPluginResources.java ← PicoContainer shared state
β”‚ └── resources/
β”‚ β”œβ”€β”€ reference.conf ← Default HOCON config
β”‚ β”œβ”€β”€ log4j2.properties ← Logging config
β”‚ └── META-INF/services/
β”‚ └── io.github.ktestify.plugin.KtestifyPlugin
└── test/
└── java/.../
β”œβ”€β”€ MyPluginPluginTest.java ← Plugin lifecycle tests
└── config/
└── MyPluginConfigTest.java ← Config loading tests

Three-layer architecture​

The generated project follows the same three-layer separation as ktestify-core:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ TRANSPORT β”‚ ORCHESTRATION β”‚ ASSERTION β”‚
β”‚ MyPluginRecordFetcher β”‚ MyPluginConsumerβ”‚ RecordMatcher β”‚
β”‚ implements RecordFetcher β”‚ β”‚ (from core) β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
  • Transport β€” MyPluginRecordFetcher implements RecordFetcher<String> from ktestify-core. Returns List<ConsumedRecord<String>> β€” the only type crossing layer boundaries.
  • Orchestration β€” MyPluginConsumer wires fetch β†’ match β†’ result. Uses RecordMatcherFactory.forRaw() to select the correct matcher.
  • Assertion β€” all standard RecordMatcher implementations from ktestify-core are reused as-is. No matcher code needed in your plugin.

Phase 1: Generate the Project​

Step 1.1 β€” Run the archetype​

mvn archetype:generate \
-DarchetypeGroupId=io.github.ktestify \
-DarchetypeArtifactId=ktestify-plugin-archetype \
-DarchetypeVersion=1.0-SNAPSHOT \
-DgroupId=io.github.ktestify \
-DartifactId=ktestify-plugin-s3 \
-Dversion=1.0-SNAPSHOT \
-Dpackage=io.github.ktestify.s3 \
-DpluginShortName=s3 \
-DpluginKebabId=s3 \
-DpluginPascalName=S3 \
-DauthorName="Your Name" \
-DauthorEmail="your.email@example.com" \
-DgithubOrg=your-github-username \
-DgithubRepoName=ktestify-plugin-s3 \
-DinteractiveMode=false

Step 1.2 β€” Archetype parameters​

ParameterDescriptionExample
groupIdMaven groupId for the generated projectio.github.ktestify
artifactIdMaven artifactId (should start with ktestify-plugin-)ktestify-plugin-s3
versionInitial Maven version1.0-SNAPSHOT
packageBase Java packageio.github.ktestify.s3
pluginShortNameShort lowercase name (file names, log files)s3
pluginKebabIdKebab-case plugin ID (HOCON config paths)s3
pluginPascalNamePascalCase name (Java class names)S3
authorNameAuthor display name (startup banner + POM)Your Name
authorEmailAuthor contact emailyour.email@example.com
githubOrgGitHub username or organisationyour-github-username
githubRepoNameGitHub repository namektestify-plugin-s3

ktestify-core version: The generated project depends on the latest released version of ktestify-core. This version is managed by Dependabot in the archetype repository β€” when a new version of ktestify-core is released, Dependabot automatically opens a PR to bump it in the template POM.

Step 1.3 β€” Verify it compiles​

cd ktestify-plugin-s3
mvn compile

If this succeeds, you have a valid plugin skeleton. The generated code compiles out of the box β€” all TODO stubs are syntactically valid.


Phase 2: Implement Your Transport​

The generated code contains TODO markers where you need to add your transport-specific logic. Here's what to implement:

Step 2.1 β€” Add your transport SDK dependency​

Add your transport SDK to pom.xml:

<!-- Example: AWS S3 SDK -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
<version>2.25.0</version>
</dependency>

Step 2.2 β€” Implement the RecordFetcher​

Open src/main/java/.../io/S3RecordFetcher.java and implement the fetch() method:

@Override
public List<ConsumedRecord<String>> fetch() throws FetchException {
String recordId = context.getRecordId();
String resourceName = context.getResourceName();
long deadlineMs = System.currentTimeMillis() + resolveReadTimeoutMs();
long pollMs = resolvePollIntervalMs();

while (System.currentTimeMillis() < deadlineMs) {
// TODO: Replace with your SDK call
boolean exists = s3Client.headObject(
HeadObjectRequest.builder()
.bucket(resourceName)
.key(recordId)
.build()
).sdkHttpResponse().isSuccessful();

if (exists) {
return List.of(downloadRecord(resourceName, recordId));
}

sleep(pollMs, recordId);
}

throw new FetchException("Timed out waiting for record '" + recordId + "'");
}

Step 2.3 β€” Implement the Action Service​

Open src/main/java/.../services/S3ActionService.java and implement the send() method:

public void send(KtestifyS3Entity resource, String recordId, String sourceFile) {
String connStr = resolveConnectionString(resource);
// TODO: Build your client and upload the file
byte[] content = Files.readAllBytes(Path.of(sourceFile));
s3Client.putObject(
PutObjectRequest.builder()
.bucket(resource.getResourceName())
.key(recordId)
.build(),
RequestBody.fromBytes(content)
);
}

Step 2.4 β€” Customize step wording (optional)​

The generated steps use generic wording like "S3 resource" and "expected S3 record from file". You can rename these to be more natural for your transport:

// Before (generated)
@Given("S3 resource")

// After (customized)
@Given("S3 bucket")

Phase 3: Configuration​

Step 3.1 β€” Update reference.conf​

The generated reference.conf has a basic connection-string pattern. Update it to match your transport's authentication model:

ktestify.plugins.s3 {
# AWS credentials (uses DefaultCredentialsProvider if not set)
access-key-id = ""
access-key-id = ${?AWS_ACCESS_KEY_ID}

secret-access-key = ""
secret-access-key = ${?AWS_SECRET_ACCESS_KEY}

region = "us-east-1"
region = ${?AWS_REGION}

endpoint = "" # for LocalStack
endpoint = ${?AWS_ENDPOINT}

read-timeout = 30s
poll-interval = 500ms
}

Step 3.2 β€” Update the Config class​

Add fields to S3Config.java to match your new config keys:

private S3Config(Config cfg) {
this.accessKeyId = cfg.getString("access-key-id");
this.secretAccessKey = cfg.getString("secret-access-key");
this.region = cfg.getString("region");
this.endpoint = cfg.getString("endpoint");
this.readTimeoutMs = cfg.getDuration("read-timeout").toMillis();
this.pollIntervalMs = cfg.getDuration("poll-interval").toMillis();
}

Phase 4: Testing​

Step 4.1 β€” Unit tests​

The archetype generates unit tests for the plugin lifecycle and config loading. Run them:

mvn test

Step 4.2 β€” Integration tests​

Add integration tests using Testcontainers. Create src/test/java/.../S3PluginIT.java:

@Testcontainers
class S3PluginIT {

@Container
static final GenericContainer<?> localstack =
new GenericContainer<>("localstack/localstack:3.0")
.withEnv("SERVICES", "s3")
.withExposedPorts(4566);

@Test
@DisplayName("upload and validate a blob")
void uploadAndValidate() {
// Point your plugin at LocalStack
// Upload a file via the action service
// Assert it appears via the validation service
}
}

Run integration tests (requires Docker):

mvn verify

Phase 5: Use Your Plugin​

Option A β€” As a Maven dependency​

<dependency>
<groupId>io.github.ktestify</groupId>
<artifactId>ktestify-plugin-s3</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

Example Gherkin scenario​

Feature: S3 integration

Background:
Given S3 bucket
| resourceName | resourceAlias |
| test-bucket | blobs |

Scenario: Upload and validate
When S3 record is sent from file
| resourceAlias | file | recordId |
| blobs | payload.json | data.json |
Then expected S3 record from file
| resourceAlias | recordId | file | readTimeout |
| blobs | data.json | expected.json | 30 |

Checklist for Production Quality​

Before publishing your plugin, ensure it meets these criteria:

  • Plugin implements all methods of KtestifyPlugin interface
  • Configuration is loaded from ktestify.plugins.<id> HOCON subtree
  • initialize() validates required config and throws PluginException on 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 returns ConsumedRecord<V> correctly
  • reference.conf documents all configuration keys with sensible defaults
  • Logging uses SLF4J via @Slf4j annotation
  • 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 (mvn spotless:apply)

Reference Implementations​

Looking for real-world examples? Check out the first-party plugins:

PluginWhat it demonstrates
ktestify-plugin-azureblobAzure Blob Storage transport with Azurite integration tests
ktestify-plugin-notificationsNon-transport plugin: Teams/Slack/webhook notifications with its own SPI
ktestify-plugin-skeletonThe reference template the archetype is derived from

Resources​


Next Steps​

Once your plugin is complete and tested:

  1. Publish to Maven Central β€” follow Sonatype's guide
  2. Create a GitHub repository β€” use ktestify-plugin-* naming convention
  3. Document on docs.ktestify.xyz β€” add a page next to the Azure Blob example
  4. Announce in the ktestify community β€” open a discussion thread

Happy plugin building!