Tutorial - grafana/xk6 GitHub Wiki
This guide explains a step-by-step process for creating a k6 extension using a hypothetical scenario.
Prerequisites
The tutorial assumes the reader has a GitHub account. Having a GitHub account simplifies the process of developing k6 extensions, which the tutorial will cover. GitHub Codespaces provides a streamlined development experience for k6 extensions, reducing the need for local setup.
If you don't have a GitHub account, please follow Creating an account on GitHub.
This document assumes prior knowledge from the k6 Extension Development Quick Start Guide to avoid repetition and potential inconsistencies. Certain steps will be referenced instead of being fully detailed here.
Hypothetical scenario
A REST API under test uses ascii85 encoding, a feature not natively supported by k6. To address this, ascii85 functionality can be implemented in two ways: as a JavaScript/TypeScript module or as a Go-based k6 extension. This guide will focus on the latter approach, developing it as a k6 extension in Go.
Step by Step
Create a GitHub repository
Begin by creating a GitHub repository using the grafana/xk6-example template repository (as explained in the Quick Start Guide). Name the repository xk6-example-ascii85
.
Open in Codespace (or VS Code)
Create a Codespaces and open it or clone the repository and open it using VS Code (as explained in the Quick Start Guide).
Remove example code (optional)
The xk6-example-ascii85
repository includes integration examples between Go and the k6 JavaScript runtime. While these materials are helpful for study, they are not necessary for the current task and should be removed. The provided .starter.patch
file can be used with the git apply
command to delete these files.
git apply .starter.patch
rm .starter.patch
API declaration
This step is optional but recommended. It is a good practice to document the API of the k6 extension before implementing it. Create a TypeScript declaration file index.d.ts
that documents the API to be implemented.
/**
* **Example ascii85 encoding for k6**
*
* @module example_ascii85
*/
export as namespace example_ascii85;
/**
* ascii85encode returns the ASCII85 encoding of src.
*
* @param src The input to encode.
*/
export declare function encode(src: ArrayBuffer): string;
/**
* ascii85decode returns the decoded bytes represented by the string str.
*
* @param str The string to decode.
*/
export declare function decode(str: string): ArrayBuffer;
The implementation
The encode()
function's implementation is straightforward, as the k6 runtime handles all type conversions. The Go standard ascii85
package provides the ASCII85 encoding implementation, requiring only parameter passing for its use. Add the following function to the module.go
file, the ascii85
package import will be added automatically by the IDE.
func (*module) encode(data []byte) string {
dst := make([]byte, ascii85.MaxEncodedLen(len(data)))
n := ascii85.Encode(dst, data)
return string(dst[:n])
}
The decode()
function should return an ArrayBuffer
. To accomplish this, type conversion using the JavaScript runtime is necessary. The sobek.ArrayBuffer
go struct corresponds to the JavaScript ArrayBuffer
, so an instance of it must be returned. Refer to the sobek.Runtime#ExportTo() documentation for mapping details.
func (m *module) decode(str string) (sobek.ArrayBuffer, error) {
dst := make([]byte, len(str))
n, _, err := ascii85.Decode(dst, []byte(str), true)
if err != nil {
return sobek.ArrayBuffer{}, err
}
return m.vu.Runtime().NewArrayBuffer(dst[:n]), nil
}
To make the encode()
and decode()
functions usable within the JavaScript runtime, ensure they are exported. Add them to the exported symbols in the module.go
file.
func (m *module) Exports() modules.Exports {
return modules.Exports{
Named: map[string]any{
"encode": m.encode,
"decode": m.decode,
},
}
}
The Go implementation of the extension is complete.
Build a custom k6
To use the xk6-example-ascii85
extension, a custom k6 build must be created using the xk6 build
subcommand.
xk6 build --with github.com/user/xk6-example-ascii85=.
Substitute user
with your GitHub username.
This command creates a custom k6 executable in the current folder.
Usage
To showcase the extension's functionality, create a JavaScript file named script.js
.
import { encode } from "k6/x/example_ascii85";
export default function () {
console.log(encode(new Uint8Array([72, 101, 108, 108, 111, 33]).buffer)) // 87cURD]o
}
The script outputs 87cURD]o
to the console. This string is the ascii85 encoded representation of Hello!
./k6 run script.js
Additional steps
Create a smoke test
For initial verification before comprehensive integration tests, create a basic smoke test in test/smoke.test.js
.
import { encode, decode } from "k6/x/example_ascii85";
import { check } from "k6"
export const options = {
thresholds: {
checks: ["rate==1"],
},
}
export default function () {
const bytes = new Uint8Array([72, 101, 108, 108, 111, 33]).buffer;
check(encode(bytes), {
encoded: (str) => str == "87cURD]o",
reverse: (str) => equal(bytes, decode(str)),
});
}
const equal = (a, b) => new Uint8Array(a).toString() === new Uint8Array(b).toString()
This test ensures the correctness of ascii85 encoding and decoding. It uses a fixed Hello!
string as a test case for both encoding and decoding processes.
Create Go module tests
Go tests offer the quickest method for verifying extension implementations. Standard unit testing practices apply. For a module-level integration test example, refer to the module_test.go
file. This setup facilitates comprehensive integration testing between the Go implementation and the JavaScript runtime.
package example_ascii85
import (
_ "embed"
"testing"
"github.com/stretchr/testify/require"
"go.k6.io/k6/js/modulestest"
)
func Test_module(t *testing.T) { //nolint:tparallel
t.Parallel()
runtime := modulestest.NewRuntime(t)
err := runtime.SetupModuleSystem(map[string]any{importPath: new(rootModule)}, nil, nil)
require.NoError(t, err)
_, err = runtime.RunOnEventLoop(`let mod = require("` + importPath + `")`)
require.NoError(t, err)
tests := []struct {
name string
check string
}{
// Add your test cases here
// Example: {name: "myFunc()", check: `mod.myFunc() == expectedValue`},
{
name: "encode()",
check: `mod.encode(new Uint8Array([72, 101, 108, 108, 111, 33]).buffer) == "87cURD]o"`,
},
}
for _, tt := range tests { //nolint:paralleltest
t.Run(tt.name, func(t *testing.T) {
got, err := runtime.RunOnEventLoop(tt.check)
require.NoError(t, err)
require.True(t, got.ToBoolean())
})
}
}
The provided test code creates an extension instance and integrates it into the JavaScript runtime, accessible as mod
. The JavaScript code defining the test is then executed within the JavaScript runtime's event loop.
Generate API documentation
You can generate HTML API documentation from the index.d.ts
API declaration file using TypeDoc. To do this, run the following command that creates the extension API documentation from the index.d.ts
file and saves it in the build/docs
directory.
bun x typedoc --out build/docs
Security and vulnerability
Ensure the Go source code of your k6 extension is checked for security vulnerabilities using the gosec tool. Like any Go project, security scanning is crucial for your extension's codebase.
gosec -quiet ./...
Generally, extensions rely on external Go module dependencies. It is advisable to use the govulncheck tool to identify potential vulnerabilities within these dependencies.
govulncheck ./...
Security and vulnerability checks are a requirement for registering the extension in the k6 Extension Registry.
Static analysis
Analyzing the Go source code of your k6 extension statically can proactively identify subtle bugs. golangci-lint is a popular static code analysis tool that even can be used without configuration.
golangci-lint run ./...
Additional information
The complete Go source code (module.go
) for the extension implementation is provided for reference.
// Package example_ascii85 contains the xk6-example-ascii85 extension.
package example_ascii85
import (
"encoding/ascii85"
"github.com/grafana/sobek"
"go.k6.io/k6/js/modules"
)
type rootModule struct{}
func (*rootModule) NewModuleInstance(vu modules.VU) modules.Instance {
return &module{vu}
}
type module struct {
vu modules.VU
}
func (m *module) Exports() modules.Exports {
return modules.Exports{
Named: map[string]any{
"encode": m.encode,
"decode": m.decode,
},
}
}
func (*module) encode(data []byte) string {
dst := make([]byte, ascii85.MaxEncodedLen(len(data)))
n := ascii85.Encode(dst, data)
return string(dst[:n])
}
func (m *module) decode(str string) (sobek.ArrayBuffer, error) {
dst := make([]byte, len(str))
n, _, err := ascii85.Decode(dst, []byte(str), true)
if err != nil {
return sobek.ArrayBuffer{}, err
}
return m.vu.Runtime().NewArrayBuffer(dst[:n]), nil
}
var _ modules.Module = (*rootModule)(nil)
In addition, register.go
contains the registration of the extension with the k6 runtime.
package example_ascii85
import "go.k6.io/k6/js/modules"
const importPath = "k6/x/example_ascii85"
func init() {
modules.Register(importPath, new(rootModule))
}
Reference to JavaScript runtime
In the k6 runtime, each VU (data structure representing a virtual user) has a dedicated JavaScript runtime instance, which can be accessed with the Runtime()
function.
m.vu.Runtime()
Further reading
- k6 go API documentation: https://pkg.go.dev/go.k6.io/k6
- k6 JavaScript engine documentation: https://pkg.go.dev/github.com/grafana/sobek
- Grafana k6 documentation - Extensions
- xk6 - k6 extension development toolbox: https://github.com/grafana/xk6