Testing Plugins
xtp
CLI?The xtp
CLI is required to run tests & host simulations, so please see these instructions to be sure you have the latest CLI installed.
curl https://static.dylibso.com/cli/install.sh -s | bash
Once you have xtp
, you can now run unit tests that call your plugins
and assert various things about them, such as outputs for given inputs, plugin
state, or timing the performance of plugin function calls. To do this, we
provide test harness libraries to create these unit tests for you to run with
xtp
.
To begin testing your plugins, use any of the following frameworks to write tests:
Within each of these repositories, you will find detailed instructions on how to write, compile, and run tests. It's important to note, that while these libraries are available in JS/TS, Rust, Go, and Zig, you can use them to test plugins which were written in any of the Extism PDK languages.
Want another language supported? Reach out to us on the
#xtp
channel in the Extism Discord to let us know!
Testing a Plugin with Importsโ
When your plugins need to make calls to Imports ("host functions"), the obvious question introduced is "who is implementing the imports?". Since the host application isn't running the plugin, these import functions must be mocked.
To solve for this, we provide an optional --mock-host
argument to the xtp
CLI when you execute tests. With this argument, you pass a path on disk or URL to separate module that supplies these import functions to your plugin being tested.
Here's an end-to-end example, split into 3 different Wasm projects:
- a KV datastore (
mock
), exportingKvRead
andKvWriteAppend
functions (these act in place of real host functions and are imported by the plugin) - the plugin (
plugin
), which interacts with the KV datastore via host function imports - an XTP test plugin (
test
), which verifies the behavior of the plugin
all of the following code can be found in full here:
testing-xtp-plugins
- Mock Host Functions
- Plugin using Host Functions
- XTP Test Plugin
The mock
project is a simple key-value store that exports KvRead
and KvWriteAppend
functions. These functions are used by the plugin
project to read and write key-value pairs. When this project is compiled, it will produce a Wasm file that can be used as a host for the plugin
project, passed as the --mock-host
argument to the xtp
CLI.
package main
import (
"encoding/json"
// to simulate Host Functions, use the PDK to manage host/guest memory.
pdk "github.com/extism/go-pdk"
)
// this is our in-memory KV store (e.g. a mock database)
var kv map[string][]string = make(map[string][]string)
// This export will be made available to the plugin as an import function.
// The offset param is the location in memory to the 'key' string
// The return offset is the location in memory to the 'value' string
//
//go:export KvRead
func KvRead(offset uint64) uint64 {
// find the memory block that contains the key, read the bytes, and look up the
// corresponding value in the KV store
keyMem := pdk.FindMemory(offset)
k := string(keyMem.ReadBytes())
// if the entry is not found, return non-zero code
v, ok := kv[k]
if !ok {
v = make([]string, 0)
}
// allocate a new memory block for the value, storing the string in memory
valMem, _ := pdk.AllocateJSON(v)
// return the memory offset
return valMem.Offset()
}
// A struct to deserialize the JSON input behind the memory address passed to `KvWriteAppend`
// NOTE: you probably have these types defined somewhere else and can import them here
type WriteParams struct {
Key string `json:"key"`
Value string `json:"value"`
}
// A struct to serialize the return from `KvWriteAppend` to store in memory.
// Non-zero `Code` value indicates an error.
// NOTE: you probably have these types defined somewhere else and can import them here
type WriteReturns struct {
Message string `json:"message"`
Code int8 `json:"code"`
}
// This export will be made available to the plugin as an import function
// The offset param is the location in memory to the JSON-serialized 'WriteParams'
// The return offset is the location in memory to the JSON-serialized 'WriteReturns'
//
//go:export KvWriteAppend
func KvWriteAppend(offset uint64) uint64 {
// find the memory block that contains the WriteParams,
// read its bytes and deserialize into WriteParams
paramsMem := pdk.FindMemory(offset)
var params WriteParams
err := json.Unmarshal(paramsMem.ReadBytes(), ¶ms)
if err != nil {
mem, _ := pdk.AllocateJSON(WriteReturns{
Message: err.Error(),
Code: 2,
})
return mem.Offset()
}
// store the key-value pair in the KV store
kv[params.Key] = append(kv[params.Key], params.Value)
// return the WriteReturns offset for the caller to read
mem, _ := pdk.AllocateJSON(WriteReturns{
Message: "",
Code: 0,
})
return mem.Offset()
}
// for now, an empty main function is required by the compiler
func main() {}
The plugin
project is a plugin that interacts with the KV datastore via host function
imports. This plugin reads and writes key-value pairs and stores them in the plugin's state. We will
verify this behavior using the test
project in the next tab.
const std = @import("std");
const plugin_allocator = std.heap.wasm_allocator;
const schema = @import("schema.zig");
const Host = schema.Host;
/// takes a LogRequest input to do some inspection and
/// aggregate some stats to be returned
/// It takes LogRequest as input (the data provided by a log event)
/// And returns LogStats (an object indicating the log handling status)
pub fn handleLogEvent(input: schema.LogRequest) ![]const u8 {
// write the log into the KV store
const write = schema.WriteParams{ .key = input.source, .value = try input.timestamp.toRfc3339(plugin_allocator) };
const res = try Host.KvWriteAppend(write);
if (res.code != 0) {
return error.WriteAppendFailed;
}
// read the data from the KV for each of the source options
// aggregate the data into the log stats, where keys are sources and values
// are the count of each log entries
// e.g. { 'cli': 2, 'api': 100, ... }
var stats_map = std.StringHashMap(u32).init(plugin_allocator);
defer stats_map.deinit();
const sources = [4]schema.SourceSystem{ .webapp, .postgres, .api, .cli };
for (sources) |source| {
const key = @tagName(source);
const logs = try Host.KvRead(key);
try stats_map.put(key, @intCast(logs.len));
}
return try stringifyHashMap(plugin_allocator, stats_map);
}
fn stringifyHashMap(allocator: std.mem.Allocator, map: std.StringHashMap(u32)) ![]const u8 {
const T = u32; // Define the type of values stored in the map
const JsonArrayHashMap = std.json.ArrayHashMap(T);
var json_map = JsonArrayHashMap{
.map = try std.StringArrayHashMapUnmanaged(T).init(allocator, &[_][]const u8{}, &[_]T{}),
};
defer json_map.deinit(allocator);
// Iterate through the StringHashMap and add entries to the ArrayHashMap
var it = map.iterator();
while (it.next()) |entry| {
try json_map.map.put(allocator, entry.key_ptr.*, entry.value_ptr.*);
}
return try std.json.stringifyAlloc(allocator, json_map, .{});
}
The test
project is an XTP test plugin that verifies the behavior of the plugin
project. When we
run this test, we will pass the mock
project as the host, so that the plugin
project can interact
with the KV store we simulate in the mock
project.
import { Test } from "@dylibso/xtp-test";
// expected output format from the plugin call:
// {"api":0,"cli":0,"webapp":1,"postgres":0}
interface SourceStats {
api: number;
cli: number;
webapp: number;
postgres: number;
}
export function test() {
const mockInput = Test.mockInputString();
Test.group("Verify KV store integration", () => {
for (let i = 0; i < 4; i++) {
const output = Test.callString("handleLogEvent", mockInput);
const stats: SourceStats = JSON.parse(output);
Test.assertEqual(
`webapp source increments to ${i + 1} based on iteration`,
i + 1,
stats.webapp,
);
}
});
}
After compiling each of these Go projects to Wasm, you can run the test using the xtp
CLI,
and pass the --mock-host
argument to specify the host Wasm file, to stitch all the pieces together:
xtp plugin test plugin/zig-out/bin/plugin.wasm \
--with test/dist/plugin.wasm \
--mock-host mock/host.wasm \
--mock-input-file mock-input.json
You should see output like this:
๐จ Building integrated host mock + plugin test
๐งช Testing zig-out/bin/plugin.wasm (integrated host mock + plugin test)
๐ฆ Group: Verify KV store integration
PASS ...... webapp source increments to 1 based on iteration
PASS ...... webapp source increments to 2 based on iteration
PASS ...... webapp source increments to 3 based on iteration
PASS ...... webapp source increments to 4 based on iteration
4/4 tests passed (completed in 4.958ms)
all tests completed in 4.977ms
Mocking input data to plugin test callsโ
Configure your test with dynamic input provided by a xtp
CLI parameter or xtp.toml
file. Read runtime-provided input that mocks the actual input when a plugin is called:
Note: this is available in each of the JavaScript/TypeScript, Rust, Go, & Zig test harness libraries.
//go:export test
func test() int32 {
// use the MockInputBytes() function to read the input data provided by the test runner
// (there are variations of this function in other xtp-test libraries)
notEmpty := xtptest.CallString("count_vowels", xtptest.MockInputBytes())
xtptest.AssertNe("with mock, not empty", notEmpty, "")
// ...
}
Providing mock input dataโ
There are two ways to provide input data to the plugin test calls:
xtp
CLI, using--mock-input-data
or--mock-input-file
xtp.toml
file
Using the xtp
CLIโ
CLI supports args --mock-input-data
and --mock-input-file
to pass text or load a file.
For example:
xtp plugin test plugin.wasm --with test.wasm --mock-input-data "this is my mock input data"
# or a path to a file for --mock-input-file
Using xtp.toml
โ
xtp.toml
supports syntax such as:
# path or url locating the wasm plugin to test
bin = "https://raw.githubusercontent.com/extism/extism/main/wasm/code.wasm"
[[test]]
# label this test something recognizable to see in CLI output
name = "basic"
# build the test wasm module, is run before the test
build = "cd examples/countvowels && tinygo build -o test.wasm -target wasi test.go"
# the wasm module to use as the test
with = "examples/countvowels/test.wasm"
# provide mock input data to the plugin test call, returned to a 'MockInput' type of function call
mock_input = { data = "this is my mock input data" }
[[test]]
name = "basic - file input"
build = "cd examples/countvowels && tinygo build -o test.wasm -target wasi test.go"
with = "examples/countvowels/test.wasm"
# load mock input data from a file instead of inline
mock_input = { file = "examples/countvowels/test.go" }
(see examples used in
examples/countvowels
)
Overriding xtp.toml
locationโ
When running xtp plugin test
, if xtp.toml
is present in the current directory, it will be used to configure the test. The location of the file can be overridden using --path
:
xtp plugin test --path tests/countvowels
Usage in testsโ
The various XTP libraries provide convenient functions to dynamically read input from the host, mocked out by the supported options above.