Testing C/C++ WebAssembly with Emscripten: Embind, MEMFS, and Integration Testing

Testing C/C++ WebAssembly with Emscripten: Embind, MEMFS, and Integration Testing

WebAssembly gives C and C++ code a path to the browser and Node.js runtimes, but testing that path is rarely discussed in depth. Emscripten's toolchain ships with enough infrastructure to run unit tests, integration tests, and end-to-end module tests — you just need to know which knobs to turn. This guide covers the full arc: compiling test targets, running them under Node.js and browsers via emrun, testing JavaScript bindings exposed through Embind, exercising the virtual filesystem (MEMFS and NODEFS), handling async operations, and catching C++ exceptions on the JavaScript side.

Setting Up the Emscripten Test Environment

Before any test can run, you need a working Emscripten installation. The canonical approach is the Emscripten SDK:

git clone https://github.com/emscripten-core/emsdk.git
cd emsdk
./emsdk install latest
./emsdk activate latest
source ./emsdk_env.sh

Verify the toolchain:

emcc --version
# emcc (Emscripten gcc/clang-like replacement + linker emulating GNU ld) 3.x.x

For a repeatable CI environment, pin a specific version:

./emsdk install 3.1.50
./emsdk activate 3.1.50

Project structure for a testable Emscripten module:

mylib/
  src/
    mylib.cpp
    mylib.h
  tests/
    test_unit.cpp
    test_bindings.js
    test_filesystem.js
  CMakeLists.txt
  Makefile

A minimal Makefile for test targets:

CC = emcc
CXX = em++
CXXFLAGS = -O2 -std=c++17

# Unit test binary (runs under Node.js)
tests/test_unit.js: tests/test_unit.cpp src/mylib.cpp
    $(CXX) $(CXXFLAGS) $^ -o $@ \
      -s ENVIRONMENT=node \
      -s ASSERTIONS=1 \
      -s EXIT_RUNTIME=1

# Embind module for binding tests
mylib.js: src/mylib.cpp
    $(CXX) $(CXXFLAGS) $^ -o $@ \
      --bind \
      -s MODULARIZE=1 \
      -s EXPORT_NAME=MyLib \
      -s ALLOW_MEMORY_GROWTH=1 \
      -s ASSERTIONS=2

clean:
    rm -f tests/test_unit.js tests/test_unit.wasm mylib.js mylib.wasm

Compiling Test Targets

Emscripten compiles C/C++ test code the same way it compiles production code — with a few flags specific to testing.

Key flags for test builds:

  • -s ASSERTIONS=1 — enables runtime assertions (memory access checks, function signature mismatches)
  • -s ASSERTIONS=2 — adds stack traces to assertion failures
  • -s EXIT_RUNTIME=1 — flushes stdout/stderr and calls atexit handlers before the process exits, required for main()-based test runners
  • -s ENVIRONMENT=node — targets Node.js only, produces a smaller bundle
  • --emrun — adds support for emrun's result reporting protocol

A self-contained unit test in C++:

// tests/test_unit.cpp
#include <cassert>
#include <cstdio>
#include <cstring>
#include "../src/mylib.h"

// Simple hand-rolled test harness
static int tests_run = 0;
static int tests_failed = 0;

#define ASSERT_EQ(a, b) do { \
    tests_run++; \
    if ((a) != (b)) { \
        fprintf(stderr, "FAIL [%s:%d]: expected %s == %s\n", \
                __FILE__, __LINE__, #a, #b); \
        tests_failed++; \
    } \
} while(0)

#define ASSERT_STR_EQ(a, b) do { \
    tests_run++; \
    if (strcmp((a), (b)) != 0) { \
        fprintf(stderr, "FAIL [%s:%d]: \"%s\" != \"%s\"\n", \
                __FILE__, __LINE__, (a), (b)); \
        tests_failed++; \
    } \
} while(0)

void test_add() {
    ASSERT_EQ(mylib_add(2, 3), 5);
    ASSERT_EQ(mylib_add(-1, 1), 0);
    ASSERT_EQ(mylib_add(0, 0), 0);
}

void test_string_reverse() {
    char buf[64];
    strcpy(buf, "hello");
    mylib_reverse(buf);
    ASSERT_STR_EQ(buf, "olleh");
}

int main() {
    test_add();
    test_string_reverse();

    if (tests_failed == 0) {
        printf("All %d tests passed.\n", tests_run);
        return 0;
    } else {
        fprintf(stderr, "%d/%d tests FAILED.\n", tests_failed, tests_run);
        return 1;
    }
}

Compile and run under Node.js directly:

make tests/test_unit.js
node tests/test_unit.js
# All 5 tests passed.

The exit code propagates correctly — node exits with 1 if main() returns non-zero, which is all you need for CI integration.

Testing with emrun and Node.js

emrun is Emscripten's built-in test server and result collector. It starts a local HTTP server, loads the compiled HTML/JS in a browser, captures stdout/stderr, and exits with the process's return code.

Compile with --emrun support:

em++ -O2 tests/test_unit.cpp src/mylib.cpp -o tests/test_unit.html \
  --emrun \
  -s ASSERTIONS=1 \
  -s EXIT_RUNTIME=1

Run in a headless browser (requires a browser binary on PATH):

emrun --browser chromium --headless tests/test_unit.html

For CI, Node.js is simpler and faster. You can run the .js output directly without --emrun:

# In CI (GitHub Actions example)
- name: Install Emscripten
  run: |
    git clone https://github.com/emscripten-core/emsdk.git /opt/emsdk
    /opt/emsdk/emsdk install 3.1.50
    /opt/emsdk/emsdk activate 3.1.50
    echo "source /opt/emsdk/emsdk_env.sh" >> $GITHUB_ENV

- name: Build tests
  run: make tests/test_unit.js

- name: Run tests
  run: node tests/test_unit.js

Testing JS Bindings with Embind

Embind is Emscripten's system for exposing C++ classes, functions, and enums to JavaScript. When you use Embind, the binding declarations become part of the surface you need to test.

C++ side — exposing a class with Embind:

// src/mylib.cpp
#include <emscripten/bind.h>
#include <string>
#include <stdexcept>

class StringProcessor {
public:
    StringProcessor(const std::string& prefix) : prefix_(prefix) {}

    std::string process(const std::string& input) const {
        if (input.empty()) {
            throw std::invalid_argument("input cannot be empty");
        }
        return prefix_ + ": " + input;
    }

    int wordCount(const std::string& text) const {
        if (text.empty()) return 0;
        int count = 1;
        for (char c : text) {
            if (c == ' ') count++;
        }
        return count;
    }

    static std::string version() { return "1.0.0"; }

private:
    std::string prefix_;
};

EMSCRIPTEN_BINDINGS(mylib) {
    emscripten::class_<StringProcessor>("StringProcessor")
        .constructor<std::string>()
        .function("process", &StringProcessor::process)
        .function("wordCount", &StringProcessor::wordCount)
        .class_function("version", &StringProcessor::version);
}

Compile the Embind module:

em++ -O2 src/mylib.cpp -o mylib.js \
  --bind \
  -s MODULARIZE=1 \
  -s EXPORT_NAME=MyLib \
  -s ALLOW_MEMORY_GROWTH=1 \
  -s ASSERTIONS=2 \
  -s ENVIRONMENT=node

JavaScript binding tests (using Node.js built-in assert):

// tests/test_bindings.js
const assert = require('assert');
const MyLibFactory = require('../mylib.js');

async function runTests() {
    const MyLib = await MyLibFactory();
    let passed = 0;
    let failed = 0;

    function test(name, fn) {
        try {
            fn();
            console.log(`  PASS: ${name}`);
            passed++;
        } catch (err) {
            console.error(`  FAIL: ${name} — ${err.message}`);
            failed++;
        }
    }

    // Static method
    test('version() returns expected string', () => {
        assert.strictEqual(MyLib.StringProcessor.version(), '1.0.0');
    });

    // Instance creation and method call
    test('process() prepends prefix', () => {
        const sp = new MyLib.StringProcessor('LOG');
        assert.strictEqual(sp.process('hello world'), 'LOG: hello world');
        sp.delete(); // mandatory: frees C++ heap memory
    });

    // Word count
    test('wordCount() counts spaces correctly', () => {
        const sp = new MyLib.StringProcessor('X');
        assert.strictEqual(sp.wordCount('one two three'), 3);
        assert.strictEqual(sp.wordCount(''), 0);
        assert.strictEqual(sp.wordCount('single'), 1);
        sp.delete();
    });

    // Exception handling — C++ exception surfaces as JS exception
    test('process() throws on empty input', () => {
        const sp = new MyLib.StringProcessor('X');
        try {
            assert.throws(() => sp.process(''), /invalid_argument/);
        } finally {
            sp.delete();
        }
    });

    console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed`);
    process.exit(failed > 0 ? 1 : 0);
}

runTests().catch(err => {
    console.error('Test runner error:', err);
    process.exit(1);
});

Critical: every new MyLib.SomeClass() object must be .delete()'d explicitly. Embind objects live on the C++ heap and are not garbage collected by V8. Use try/finally blocks to guarantee cleanup even when tests fail.

Run:

node tests/test_bindings.js

Testing File System Operations with MEMFS and NODEFS

Emscripten provides virtual filesystems that let C code call fopen, fread, fwrite, and friends. MEMFS is an in-memory filesystem available by default. NODEFS mounts a real Node.js directory into the Emscripten virtual FS.

MEMFS Tests

MEMFS is always available. Test file I/O by writing and reading back:

// tests/test_filesystem.cpp
#include <cassert>
#include <cstdio>
#include <cstring>
#include <emscripten.h>

void test_memfs_roundtrip() {
    const char* path = "/tmp/test.txt";
    const char* content = "hello from C++";

    // Write
    FILE* f = fopen(path, "w");
    assert(f != nullptr);
    fputs(content, f);
    fclose(f);

    // Read back
    char buf[64] = {0};
    f = fopen(path, "r");
    assert(f != nullptr);
    fgets(buf, sizeof(buf), f);
    fclose(f);

    assert(strcmp(buf, content) == 0);
    printf("PASS: memfs_roundtrip\n");
}

void test_memfs_binary() {
    const char* path = "/tmp/binary.bin";
    unsigned char data[] = {0x00, 0xFF, 0x42, 0x01};

    FILE* f = fopen(path, "wb");
    assert(f != nullptr);
    fwrite(data, 1, sizeof(data), f);
    fclose(f);

    unsigned char readback[4] = {0};
    f = fopen(path, "rb");
    assert(f != nullptr);
    size_t n = fread(readback, 1, sizeof(readback), f);
    fclose(f);

    assert(n == 4);
    assert(memcmp(data, readback, 4) == 0);
    printf("PASS: memfs_binary\n");
}

int main() {
    test_memfs_roundtrip();
    test_memfs_binary();
    return 0;
}

Compile without any special FS flags — MEMFS is the default:

em++ -O2 tests/test_filesystem.cpp -o tests/test_filesystem.js \
  -s ENVIRONMENT=node \
  -s EXIT_RUNTIME=1 \
  -s ASSERTIONS=1

node tests/test_filesystem.js

NODEFS Tests

NODEFS maps a real host directory into the Wasm FS. This is useful when your C code must read files that already exist on disk — fixtures, binary assets, config files.

Enable NODEFS and mount a host path:

// tests/test_nodefs.cpp
#include <cassert>
#include <cstdio>
#include <emscripten.h>

// EM_ASM blocks let you call JS from C — used here to mount NODEFS
void mount_host_fixtures() {
    EM_ASM({
        FS.mkdir('/fixtures');
        FS.mount(NODEFS, { root: '../tests/fixtures' }, '/fixtures');
    });
}

void test_read_fixture_file() {
    mount_host_fixtures();

    FILE* f = fopen("/fixtures/sample.txt", "r");
    assert(f != nullptr);

    char buf[256] = {0};
    fgets(buf, sizeof(buf), f);
    fclose(f);

    // Verify expected content from the fixture
    assert(strstr(buf, "expected content") != nullptr);
    printf("PASS: nodefs_read_fixture\n");
}

int main() {
    test_read_fixture_file();
    return 0;
}

Compile with NODEFS:

em++ -O2 tests/test_nodefs.cpp -o tests/test_nodefs.js \
  -s ENVIRONMENT=node \
  -s EXIT_RUNTIME=1 \
  -s FORCE_FILESYSTEM=1 \
  -lnodefs.js

node tests/test_nodefs.js

The -lnodefs.js flag links the NODEFS library. Without -s FORCE_FILESYSTEM=1, Emscripten may strip FS support when it detects no explicit FS calls at compile time.

Testing Async Operations

Wasm execution is synchronous by default, but Emscripten provides several async mechanisms: emscripten_async_call, Asyncify, and JavaScript Promise interop via Embind.

Asyncify for Blocking C Code

Asyncify rewrites the Wasm binary to allow "blocking" calls that yield to the JavaScript event loop. Test it by calling an async operation from C:

// src/async_ops.cpp
#include <emscripten.h>
#include <emscripten/bind.h>
#include <string>

// Simulated async fetch using EM_ASM_INT
int fetch_length(const std::string& url) {
    return EM_ASM_INT({
        // In a real case this would use Asyncify + XHR
        // Here we return a deterministic value for testing
        return 42;
    });
}

EMSCRIPTEN_BINDINGS(async_ops) {
    emscripten::function("fetchLength", &fetch_length);
}

JavaScript test for Asyncify-based modules:

// tests/test_async.js
const MyLibFactory = require('../mylib.js');

async function runTests() {
    const MyLib = await MyLibFactory();
    let passed = 0;
    let failed = 0;

    async function test(name, fn) {
        try {
            await fn();
            console.log(`  PASS: ${name}`);
            passed++;
        } catch (err) {
            console.error(`  FAIL: ${name} — ${err.message}`);
            failed++;
        }
    }

    // Test a C++ function that returns a Promise (Asyncify)
    await test('fetchLength returns expected value', async () => {
        const result = MyLib.fetchLength('https://example.com');
        // Asyncify-wrapped calls may return Promises
        const value = await Promise.resolve(result);
        if (value !== 42) throw new Error(`Expected 42, got ${value}`);
    });

    // Test module-level async initialization
    await test('module initializes correctly', async () => {
        if (typeof MyLib.fetchLength !== 'function') {
            throw new Error('fetchLength not exported');
        }
    });

    console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed`);
    process.exit(failed > 0 ? 1 : 0);
}

runTests();

Compile with Asyncify:

em++ -O2 src/async_ops.cpp -o mylib_async.js \
  --bind \
  -s MODULARIZE=1 \
  -s EXPORT_NAME=MyLib \
  -s ASYNCIFY=1 \
  -s ENVIRONMENT=node

Asyncify increases binary size significantly. For testing only, -O0 with Asyncify is acceptable.

Catching C++ Exceptions in JS Tests

By default, Emscripten does not propagate C++ exceptions to JavaScript — they terminate the program. Enable exception propagation with -s EXCEPTION_CATCHING_ALLOWED or --enable-exception-handling.

em++ -O2 src/mylib.cpp -o mylib.js \
  --bind \
  -s MODULARIZE=1 \
  -s EXPORT_NAME=MyLib \
  -s ASSERTIONS=2 \
  -s ENVIRONMENT=node \
  -fexceptions \
  -s EXCEPTION_CATCHING_ALLOWED="['__cxa_throw']"

With exceptions enabled, C++ throw surfaces as a JavaScript exception. Test error paths explicitly:

// tests/test_exceptions.js
const assert = require('assert');
const MyLibFactory = require('../mylib.js');

async function runTests() {
    const MyLib = await MyLibFactory();

    // Test std::invalid_argument propagation
    const sp = new MyLib.StringProcessor('X');
    try {
        assert.throws(
            () => sp.process(''),
            (err) => {
                // Emscripten wraps C++ exceptions; check the message
                return err.message.includes('invalid_argument') ||
                       err instanceof MyLib.BindingError ||
                       typeof err === 'number'; // pre-exception-handling: numeric error code
            },
            'Expected C++ exception to propagate as JS exception'
        );
        console.log('PASS: C++ exception caught in JS');
    } finally {
        sp.delete();
    }

    // Test that non-throwing paths still work after an exception
    const sp2 = new MyLib.StringProcessor('Y');
    try {
        const result = sp2.process('valid input');
        assert.strictEqual(result, 'Y: valid input');
        console.log('PASS: Normal call works after exception test');
    } finally {
        sp2.delete();
    }

    process.exit(0);
}

runTests().catch(err => {
    console.error('Uncaught:', err);
    process.exit(1);
});

For legacy Emscripten without -fexceptions, exceptions become numbers (the C++ exception pointer). Catch them as typeof err === 'number' and use Module.getExceptionMessage(err) if available to extract the message.

End-to-End Testing Compiled Modules

Integration tests verify that the compiled Wasm module behaves correctly as a dependency in a real Node.js application, not just in isolation.

Structure a full module test that exercises the public API:

// tests/test_e2e.js
const assert = require('assert');
const MyLibFactory = require('../mylib.js');

async function main() {
    const MyLib = await MyLibFactory();

    const results = { passed: 0, failed: 0 };

    function check(name, condition, detail = '') {
        if (condition) {
            console.log(`PASS: ${name}`);
            results.passed++;
        } else {
            console.error(`FAIL: ${name}${detail ? ' — ' + detail : ''}`);
            results.failed++;
        }
    }

    // --- StringProcessor end-to-end ---
    {
        const proc = new MyLib.StringProcessor('AUDIT');

        const out1 = proc.process('user logged in');
        check('process produces correct output', out1 === 'AUDIT: user logged in', out1);

        const wc = proc.wordCount('one two three four');
        check('wordCount on 4-word string', wc === 4, `got ${wc}`);

        const wc0 = proc.wordCount('');
        check('wordCount on empty string', wc0 === 0, `got ${wc0}`);

        check('static version is a string', typeof MyLib.StringProcessor.version() === 'string');

        proc.delete();
    }

    // --- Multiple instances don't interfere ---
    {
        const a = new MyLib.StringProcessor('A');
        const b = new MyLib.StringProcessor('B');

        check('instance A processes independently', a.process('x') === 'A: x');
        check('instance B processes independently', b.process('x') === 'B: x');

        a.delete();
        b.delete();
    }

    // --- Memory: creating and deleting many objects ---
    {
        let leaked = false;
        for (let i = 0; i < 1000; i++) {
            const sp = new MyLib.StringProcessor(`run${i}`);
            sp.process('test');
            sp.delete();
        }
        check('1000 object create/delete cycles complete', true);
    }

    console.log(`\nE2E: ${results.passed} passed, ${results.failed} failed`);
    process.exit(results.failed > 0 ? 1 : 0);
}

main().catch(err => {
    console.error(err);
    process.exit(1);
});

Combine all tests in a single CI step:

#!/bin/bash
set -e
source /opt/emsdk/emsdk_env.sh

echo "Building..."
make all

echo "Unit tests..."
node tests/test_unit.js

echo "Binding tests..."
node tests/test_bindings.js

echo "Filesystem tests..."
node tests/test_filesystem.js

echo "Exception tests..."
node tests/test_exceptions.js

echo "E2E tests..."
node tests/test_e2e.js

echo "All test suites passed."

Key Takeaways

Compile flags matter more than in native testing. -s ASSERTIONS=2, -s EXIT_RUNTIME=1, and -s ENVIRONMENT=node together give you meaningful error output, correct process exit codes, and a lean build without browser glue.

Every Embind object must be .delete()'d. V8 does not collect C++ heap objects. Missing .delete() calls cause memory leaks that silently accumulate across tests. Use try/finally in every test that creates an Embind instance.

MEMFS is always available; NODEFS requires -lnodefs.js and -s FORCE_FILESYSTEM=1. Use MEMFS for self-contained tests and NODEFS when you need to read real fixture files from the host filesystem.

C++ exceptions require explicit opt-in. Compile with -fexceptions and -s EXCEPTION_CATCHING_ALLOWED to propagate exceptions into JavaScript. Without this, exceptions terminate the Wasm instance silently.

Asyncify doubles build time and binary size. Use it only when the code you're testing genuinely blocks waiting for I/O. For pure computation, synchronous Wasm is faster to compile and simpler to test.

Test the .js output directly with Node.js in CI. emrun is useful for browser testing but adds latency and browser dependency. For server-side and CI testing, node yourmodule.js with proper exit codes is all you need.

Pin the Emscripten version. The ABI between compiled modules and the Emscripten runtime is version-dependent. A version mismatch between your module and the runtime it loads into will produce silent failures or crashes with no useful error message. Lock emsdk install X.Y.Z in CI.

Read more

Start now free