Plugin System
KTestify includes a powerful plugin system that allows you to extend the framework with new transports and capabilities, all discovered and loaded automatically at runtime without hard-coding dependencies. Plugins follow a Service Provider Interface (SPI) pattern using Java's ServiceLoader for seamless integration.
Want to build your own plugin? Jump straight to Creating a Plugin.
How It Works
KTestify's plugin system has two key loading phases:
Phase 1: Classpath Plugins
Plugins bundled as Maven dependencies are discovered via ServiceLoader on startup. This works because the project's build system (Maven Shade) automatically merges all META-INF/services descriptors from bundled JARs into the final artifact.
Phase 2: External Directory Plugins
Third-party plugins can be placed as .jar files in a directory configured by ktestify.plugins.dir (default: /workspace/plugins). Each JAR is loaded independently via a dedicated URLClassLoader and its plugins are discovered automatically, without conflicts with classpath plugins.
This two-phase approach lets you:
- Ship plugins with your test framework as dependencies
- Drop third-party plugins at runtime without recompilation
- Keep plugin JAR versions independent
Lifecycle
Every plugin goes through a predictable lifecycle:
- Discovery, The plugin system scans for implementations of
KtestifyPlugin(first classpath, then external JARs) - Initialization,
initialize(PluginContext)is called exactly once before any Cucumber scenario runs - Scenario Execution, Steps from the plugin's glue package are available for use in Gherkin
- Shutdown,
shutdown()is called once on JVM shutdown to release resources
If any plugin's initialize() method throws an exception, the entire test run is aborted with a PluginException.
Core Types
KtestifyPlugin interface
The contract that every plugin must implement:
package io.github.ktestify.plugin;
public interface KtestifyPlugin {
/**
* Unique, stable plugin identifier (e.g. "azure-blob", "ibm-mq").
* Use kebab-case, as this ID appears in HOCON config paths and logs.
*/
String getId();
/**
* Plugin version string (e.g. "1.0.0").
* Used for informational logging and diagnostics only.
*/
String getVersion();
/**
* Display name of the plugin author or team.
* Shown in the startup banner next to the plugin ID and version.
* Default: "unknown"
*/
default String getAuthorName() { return "unknown"; }
/**
* Contact email of the plugin author or team.
* Displayed alongside the author name in startup logs.
* Default: empty string
*/
default String getAuthorEmail() { return ""; }
/**
* Fully-qualified Java package containing this plugin's Cucumber steps.
* Example: "io.github.ktestify.azureblob.steps"
*
* The ktestify runtime injects this as a --glue argument to Cucumber,
* so step definitions are discovered automatically.
* Return null or empty string if the plugin has no steps.
*/
String getGluePackage();
/**
* Initialize the plugin exactly once at startup.
* Called after config is loaded but before any Cucumber scenario runs.
*
* Use this to:
* - Validate plugin-specific configuration
* - Create shared clients or connections
* - Initialize thread pools or singleton services
*
* Throw PluginException to abort the test run.
*/
void initialize(PluginContext context);
/**
* Shut down the plugin exactly once on JVM exit.
* Called after all Cucumber scenarios have finished.
*
* Use this to release resources (connections, threads, etc.).
* Do NOT throw exceptions, log and swallow instead.
*/
void shutdown();
}
PluginContext interface
Passed to initialize(), provides controlled access to framework configuration:
package io.github.ktestify.plugin;
public interface PluginContext {
/**
* Returns the fully loaded KtestifyConfig singleton.
*
* Call getRaw() on the result to navigate to your plugin's
* ktestify.plugins.<id> subtree, or access shared settings
* like ktestify.framework.directories.assets.
*/
KtestifyConfig getConfig();
}
Example - reading plugin config:
@Override
public void initialize(PluginContext context) {
Config fullConfig = context.getConfig().getRaw();
// Access your plugin's subtree
if (fullConfig.hasPath("ktestify.plugins.my-plugin")) {
Config pluginCfg = fullConfig.getConfig("ktestify.plugins.my-plugin");
String endpoint = pluginCfg.getString("endpoint");
Duration timeout = pluginCfg.getDuration("timeout");
}
}
PluginRegistry class
The central registry that discovers, loads, and manages all plugins:
package io.github.ktestify.plugin;
public final class PluginRegistry {
/**
* Discovers and initializes all plugins (classpath + external).
* Call this once at startup before Cucumber runs.
*/
public static PluginRegistry load(PluginContext ctx);
/**
* Get all loaded plugins.
*/
public List<KtestifyPlugin> getPlugins();
/**
* Get the Cucumber glue packages from all plugins.
* Add each as --glue <package> when running Cucumber.
*/
public List<String> getGluePackages();
/**
* Shut down all plugins in reverse initialization order.
*/
public void shutdown();
}
Plugin Configuration
Plugins read their configuration from the HOCON file under ktestify.plugins.<id>. This convention keeps plugin settings isolated and organized:
ktestify {
plugins {
# Azure Blob Storage plugin
azure-blob {
connection-string = ${?AZURE_STORAGE_CONNECTION_STRING}
timeout = 30s
auto-create-containers = true
}
# IBM MQ plugin (hypothetical)
ibm-mq {
host = "localhost"
port = 1414
channel = "DEV.ADMIN.SVRCONN"
queue-manager = "QM1"
}
}
}
Configuration resolution order:
- System properties (e.g.
-Dktestify.plugins.my-plugin.timeout=60s) - Environment variables (recommended for secrets, e.g.
KTESTIFY_PLUGINS_MY_PLUGIN_ENDPOINT) application.confin classpath- Plugin's
reference.conf(defaults)
Available plugins
| Plugin | Artifact | Status | Docs |
|---|---|---|---|
| Azure Blob Storage | ktestify-plugin-azureblob | 🚧 In progress | Azure Blob → |
| S3 | , | 💡 Planned | , |
| IBM MQ | , | 💡 Planned | , |
| SFTP | , | 💡 Planned | , |
Example Plugin Architecture
Here is how a typical plugin is structured:
ktestify-plugin-example/
├── src/main/java/io/github/ktestify/example/
│ ├── ExamplePlugin.java ← implements KtestifyPlugin
│ ├── config/
│ │ └── ExampleConfig.java ← reads plugin HOCON config
│ ├── transport/
│ │ ├── ExampleRecordFetcher.java ← implements RecordFetcher<String>
│ │ └── ExampleClient.java ← wraps external SDK
│ ├── orchestration/
│ │ └── ExampleConsumer.java ← orchestrates fetch → match
│ └── steps/
│ ├── BackgroundSteps.java ← @Given steps
│ ├── ActionSteps.java ← @When steps
│ └── ValidationSteps.java ← @Then steps
├── src/main/resources/
│ ├── META-INF/services/
│ │ └── io.github.ktestify.plugin.KtestifyPlugin
│ ├── reference.conf ← default config
│ └── log4j2.properties
├── src/test/java/io/github/ktestify/example/
│ └── ...test files...
├── pom.xml
└── README.md
Most plugins follow the three-layer separation defined by ktestify-core:
- Transport,
ExampleRecordFetcherimplementsRecordFetcher<String>, fetches records from the external system - Orchestration,
ExampleConsumerwires fetch → match → result - Assertion, uses standard
RecordMatcherimplementations from ktestify-core
Startup Log Example
When ktestify starts with plugins, you'll see a banner like this:
╔══ KTestify Plugin System ═════════════════════════════════════
║ [classpath] Discovered plugin: azure-blob v1.0, author: John Doe <john@example.com>
║ Plugin 'azure-blob' initialized successfully.
║ [external] Scanning '/workspace/plugins', 2 JAR(s) found.
║ [external] Discovered plugin: s3 v2.1, author: Jane Smith <jane@example.com>
║ Plugin 's3' initialized successfully.
║ 3 plugin(s) active: [azure-blob@1.0, s3@2.1, ibm-mq@1.5]
╚═══════════════════════════════════════════════════════════════
Best Practices
- Use kebab-case plugin IDs, e.g.
azure-blob, notazureBloborAZURE_BLOB - Namespace your config, always use
ktestify.plugins.<id>to avoid conflicts - Validate in
initialize(), fail fast withPluginExceptionif critical config is missing - Release resources in
shutdown(), close connections, stop threads, etc. - Log appropriately, use
@Slf4j/ SLF4J for diagnostics - Document config keys, provide a
reference.confwith all config keys and defaults - Register via SPI, declare your plugin class in
META-INF/services/io.github.ktestify.plugin.KtestifyPlugin - Keep dependencies minimal, avoid bloating the classpath with transitive deps