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.
Testing with GOOS=wasip1 (Recommended Starting Point)
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 automaticallyHere'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"># PASSUsing 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.jsCreate 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.wasmTesting 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/opTypical 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.wasmEnd-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.