Testing Go Compiled to WebAssembly: syscall/js, wasip1, and wasm_exec

Testing Go Compiled to WebAssembly: syscall/js, wasip1, and wasm_exec

Go has had WebAssembly support since Go 1.11 via GOARCH=wasm GOOS=js, and since Go 1.21 via the WASI target GOOS=wasip1. Testing Go WASM code requires a different test runner than the standard go test command — either the wasm_exec.js Node.js harness for browser-targeted WASM, or a WASI runtime like Wasmtime or wazero for wasip1 targets. This guide covers both approaches with real examples.

Key Takeaways

GOOS=wasip1 go test compiles and runs tests against a WASI runtime. This is the simplest path for pure-logic Go code — no browser needed, works in CI with minimal setup.

Browser WASM tests need wasm_exec.js. Google ships this JS harness with every Go installation. It provides the Go runtime environment in Node.js or a browser.

syscall/js is the bridge to the DOM. To test DOM-touching code, you must run under Node.js with a DOM library (jsdom) or in a real browser with Playwright.

Build with -exec flag to use a custom test runner. go test -exec wasmtime GOOS=wasip1 ./... delegates execution to any WASI runtime.

Use build tags to separate WASM tests from host tests. //go:build js && wasm guards code that only makes sense in a browser WASM context.

Go's Two WASM Targets

Go supports two distinct WebAssembly targets, and they require different testing strategies:

GOARCH=wasm GOOS=js — the original browser target. Provides the syscall/js package for calling JavaScript from Go. Tests run using the wasm_exec.js harness. Best for browser applications.

GOOS=wasip1 — the WASI target, available since Go 1.21. Compiles to standard WebAssembly with WASI system interface calls. Tests run using any WASI runtime (Wasmtime, wazero, WasmEdge). Best for server-side, CLI tools, and plugin systems.

Most projects use one or the other. Understanding which target you're building for determines how you write and run tests.

The wasip1 target is the easiest to test because it integrates directly with go test:

# Install a WASI runtime (Wasmtime)
curl https://wasmtime.dev/install.sh -sSf <span class="hljs-pipe">| bash

<span class="hljs-comment"># Run tests on wasip1 target
GOOS=wasip1 GOARCH=wasm go <span class="hljs-built_in">test ./...
<span class="hljs-comment"># This compiles tests to WASM and runs them with wasmtime automatically

Here's a pure-logic Go package that tests cleanly under wasip1:

// calculator/calculator.go
package calculator

import "errors"

type Calculator struct {
    history []float64
}

func New() *Calculator {
    return &Calculator{}
}

func (c *Calculator) Add(a, b float64) float64 {
    result := a + b
    c.history = append(c.history, result)
    return result
}

func (c *Calculator) Divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    result := a / b
    c.history = append(c.history, result)
    return result, nil
}

func (c *Calculator) History() []float64 {
    return append([]float64{}, c.history...)
}

func (c *Calculator) Reset() {
    c.history = nil
}
// calculator/calculator_test.go
package calculator

import (
    "math"
    "testing"
)

func TestAdd(t *testing.T) {
    c := New()

    tests := []struct {
        name     string
        a, b     float64
        expected float64
    }{
        {"positive numbers", 2.0, 3.0, 5.0},
        {"negative numbers", -5.0, 3.0, -2.0},
        {"floats", 1.5, 2.5, 4.0},
        {"zero", 0.0, 0.0, 0.0},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result := c.Add(tt.a, tt.b)
            if result != tt.expected {
                t.Errorf("Add(%v, %v) = %v, want %v", tt.a, tt.b, result, tt.expected)
            }
        })
    }
}

func TestDivide(t *testing.T) {
    c := New()

    t.Run("valid division", func(t *testing.T) {
        result, err := c.Divide(10.0, 4.0)
        if err != nil {
            t.Fatalf("unexpected error: %v", err)
        }
        if result != 2.5 {
            t.Errorf("Divide(10, 4) = %v, want 2.5", result)
        }
    })

    t.Run("division by zero", func(t *testing.T) {
        _, err := c.Divide(1.0, 0.0)
        if err == nil {
            t.Error("expected error for division by zero, got nil")
        }
    })

    t.Run("floating point precision", func(t *testing.T) {
        result, err := c.Divide(1.0, 3.0)
        if err != nil {
            t.Fatal(err)
        }
        // Use tolerance for floating point comparison
        expected := 1.0 / 3.0
        if math.Abs(result-expected) > 1e-10 {
            t.Errorf("Divide(1, 3) = %v, want approximately %v", result, expected)
        }
    })
}

func TestHistory(t *testing.T) {
    c := New()
    c.Add(1, 2)
    c.Add(3, 4)
    _, _ = c.Divide(10, 2)

    history := c.History()
    if len(history) != 3 {
        t.Fatalf("expected 3 history entries, got %d", len(history))
    }

    expected := []float64{3.0, 7.0, 5.0}
    for i, v := range expected {
        if history[i] != v {
            t.Errorf("history[%d] = %v, want %v", i, history[i], v)
        }
    }

    // Verify history is a copy, not a reference
    history[0] = 999
    if c.History()[0] == 999 {
        t.Error("History() should return a copy, not a reference to internal slice")
    }
}

func TestReset(t *testing.T) {
    c := New()
    c.Add(1, 2)
    c.Reset()

    if len(c.History()) != 0 {
        t.Error("History should be empty after Reset()")
    }
}

Run these tests under wasip1:

GOOS=wasip1 GOARCH=wasm go test -v ./calculator/
<span class="hljs-comment"># Output:
<span class="hljs-comment"># --- PASS: TestAdd (0.00s)
<span class="hljs-comment">#     --- PASS: TestAdd/positive_numbers (0.00s)
<span class="hljs-comment">#     --- PASS: TestAdd/negative_numbers (0.00s)
<span class="hljs-comment">#     --- PASS: TestAdd/floats (0.00s)
<span class="hljs-comment">#     --- PASS: TestAdd/zero (0.00s)
<span class="hljs-comment"># --- PASS: TestDivide (0.00s)
<span class="hljs-comment"># --- PASS: TestHistory (0.00s)
<span class="hljs-comment"># --- PASS: TestReset (0.00s)
<span class="hljs-comment"># PASS

Using the -exec Flag with a Custom WASI Runtime

The go test -exec flag lets you specify which binary runs the compiled test WASM:

# Use Wasmtime explicitly
GOOS=wasip1 GOARCH=wasm go <span class="hljs-built_in">test -<span class="hljs-built_in">exec wasmtime ./...

<span class="hljs-comment"># Use wazero (pure Go WASI runtime, no external install needed)
go install github.com/tetratelabs/wazero/cmd/wazero@latest
GOOS=wasip1 GOARCH=wasm go <span class="hljs-built_in">test -<span class="hljs-built_in">exec wazero ./...

<span class="hljs-comment"># Pass WASI runtime flags (e.g., grant directory access)
GOOS=wasip1 GOARCH=wasm go <span class="hljs-built_in">test -<span class="hljs-built_in">exec <span class="hljs-string">"wasmtime --dir=." ./...

Testing Browser WASM with wasm_exec.js

For browser-targeted Go WASM (GOOS=js GOARCH=wasm), you need the wasm_exec.js harness:

# Find wasm_exec.js (ships with Go)
GOROOT=$(go <span class="hljs-built_in">env GOROOT)
<span class="hljs-built_in">ls <span class="hljs-variable">$GOROOT/misc/wasm/
<span class="hljs-comment"># wasm_exec.js  wasm_exec_node.js  wasm_exec_tinygo.js

Create a Node.js test runner:

// test_runner.js
const fs = require('fs');
const path = require('path');

// Load the Go WASM runtime
require(path.join(process.env.GOROOT || '/usr/local/go', 'misc/wasm/wasm_exec_node.js'));

async function runWasmTests(wasmPath) {
    const go = new Go();
    const wasmBuffer = fs.readFileSync(wasmPath);
    const { instance } = await WebAssembly.instantiate(wasmBuffer, go.importObject);

    // Set up test result capture
    let passed = 0;
    let failed = 0;
    const originalLog = console.log;
    console.log = (...args) => {
        const msg = args.join(' ');
        if (msg.includes('--- PASS')) passed++;
        if (msg.includes('--- FAIL')) failed++;
        originalLog(...args);
    };

    await go.run(instance);

    console.log = originalLog;
    console.log(`\nResults: ${passed} passed, ${failed} failed`);

    if (failed > 0) process.exit(1);
}

runWasmTests(process.argv[2]).catch(err => {
    console.error(err);
    process.exit(1);
});

Build and run tests:

# Compile tests to WASM
GOOS=js GOARCH=wasm go <span class="hljs-built_in">test -c -o tests.wasm ./calculator/

<span class="hljs-comment"># Run with Node.js
GOROOT=$(go <span class="hljs-built_in">env GOROOT) node test_runner.js tests.wasm

Testing syscall/js DOM Interactions

Code that uses syscall/js to manipulate the DOM needs a DOM environment. Use jsdom in Node.js:

// dom/dom.go
//go:build js && wasm

package dom

import "syscall/js"

// AppendText appends a text node to the element with the given ID
func AppendText(elementID, text string) bool {
    doc := js.Global().Get("document")
    el := doc.Call("getElementById", elementID)
    if el.IsNull() || el.IsUndefined() {
        return false
    }
    textNode := doc.Call("createTextNode", text)
    el.Call("appendChild", textNode)
    return true
}

// GetElementText returns the textContent of an element
func GetElementText(elementID string) (string, bool) {
    doc := js.Global().Get("document")
    el := doc.Call("getElementById", elementID)
    if el.IsNull() || el.IsUndefined() {
        return "", false
    }
    return el.Get("textContent").String(), true
}

// SetAttribute sets an attribute on an element
func SetAttribute(elementID, attr, value string) bool {
    doc := js.Global().Get("document")
    el := doc.Call("getElementById", elementID)
    if el.IsNull() || el.IsUndefined() {
        return false
    }
    el.Call("setAttribute", attr, value)
    return true
}
// dom/dom_test.go
//go:build js && wasm

package dom

import (
    "syscall/js"
    "testing"
)

// setupDOM creates test DOM elements using JS eval
func setupDOM(html string) {
    js.Global().Get("document").Get("body").Set("innerHTML", html)
}

func TestAppendText(t *testing.T) {
    setupDOM(`<div id="target"></div>`)

    ok := AppendText("target", "hello")
    if !ok {
        t.Fatal("AppendText returned false for existing element")
    }

    text, found := GetElementText("target")
    if !found {
        t.Fatal("element not found after AppendText")
    }
    if text != "hello" {
        t.Errorf("element text = %q, want %q", text, "hello")
    }
}

func TestAppendTextMissingElement(t *testing.T) {
    ok := AppendText("does-not-exist", "hello")
    if ok {
        t.Error("AppendText should return false for missing element")
    }
}

func TestSetAttribute(t *testing.T) {
    setupDOM(`<button id="btn">Click me</button>`)

    ok := SetAttribute("btn", "disabled", "true")
    if !ok {
        t.Fatal("SetAttribute returned false")
    }

    // Verify the attribute was set
    doc := js.Global().Get("document")
    btn := doc.Call("getElementById", "btn")
    attr := btn.Call("getAttribute", "disabled").String()
    if attr != "true" {
        t.Errorf("attribute disabled = %q, want %q", attr, "true")
    }
}

Benchmarking Go WASM vs Native

Benchmarks let you measure the performance overhead of the WASM target:

// calculator/benchmark_test.go
package calculator

import "testing"

func BenchmarkAdd(b *testing.B) {
    c := New()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        c.Add(float64(i), float64(i+1))
    }
}

func BenchmarkDivide(b *testing.B) {
    c := New()
    b.ResetTimer()
    for i := 1; i <= b.N; i++ {
        _, _ = c.Divide(float64(i*100), float64(i))
    }
}

Run the same benchmarks on both targets and compare:

# Native benchmark
go <span class="hljs-built_in">test -bench=. -benchmem ./calculator/
<span class="hljs-comment"># BenchmarkAdd-8      500000000   2.31 ns/op   0 B/op   0 allocs/op
<span class="hljs-comment"># BenchmarkDivide-8   200000000   7.85 ns/op   8 B/op   1 allocs/op

<span class="hljs-comment"># WASM benchmark (wasip1)
GOOS=wasip1 GOARCH=wasm go <span class="hljs-built_in">test -bench=. -benchmem ./calculator/
<span class="hljs-comment"># BenchmarkAdd-1      100000000   12.4 ns/op   0 B/op   0 allocs/op
<span class="hljs-comment"># BenchmarkDivide-1    50000000   24.1 ns/op   8 B/op   1 allocs/op

Typical overhead is 2-5x for pure computation — acceptable for most use cases.

CI Integration

# .github/workflows/go-wasm-test.yml
name: Go WASM Tests

on: [push, pull_request]

jobs:
  test-wasip1:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'

      - name: Install Wasmtime
        run: |
          curl https://wasmtime.dev/install.sh -sSf | bash
          echo "$HOME/.wasmtime/bin" >> $GITHUB_PATH

      - name: Run tests (wasip1)
        run: GOOS=wasip1 GOARCH=wasm go test -v ./...

  test-js-wasm:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'
      - uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Compile tests to WASM
        run: GOOS=js GOARCH=wasm go test -c -o tests.wasm ./dom/

      - name: Run tests with Node.js harness
        run: |
          cp $(go env GOROOT)/misc/wasm/wasm_exec_node.js .
          GOROOT=$(go env GOROOT) node test_runner.js tests.wasm

End-to-End Testing with HelpMeTest

Go WASM tests — whether using wasip1 with Wasmtime or the wasm_exec.js harness — verify that your Go logic works correctly in a WebAssembly context. But they don't test the user-facing layer: how your WASM module behaves when embedded in a running web application, interacting with real HTML, real user events, and real network conditions.

HelpMeTest handles that final layer. Write scenarios in plain English — "load the app, trigger the Go-powered calculation, verify the result appears correctly" — and HelpMeTest runs them in a real browser against your deployed application. No Playwright setup, no browser driver configuration.

Combine GOOS=wasip1 go test for fast unit-level WASM verification with HelpMeTest for continuous end-to-end coverage. When both are green, your Go WASM application is working at every level of the stack.

Read more

ScyllaDB Testing Guide: Cassandra Driver Compatibility, Shard-per-Core Testing & Performance Regression

ScyllaDB Testing Guide: Cassandra Driver Compatibility, Shard-per-Core Testing & Performance Regression

ScyllaDB delivers Cassandra-compatible APIs with a rewritten Seastar-based engine that achieves dramatically higher throughput. Testing ScyllaDB applications requires validating both Cassandra compatibility and ScyllaDB-specific behaviors like shard-per-core data distribution. This guide covers both angles. ScyllaDB Testing Landscape ScyllaDB is a drop-in replacement for Cassandra at the API level—which means

By HelpMeTest