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.shVerify the toolchain:
emcc --version
# emcc (Emscripten gcc/clang-like replacement + linker emulating GNU ld) 3.x.xFor a repeatable CI environment, pin a specific version:
./emsdk install 3.1.50
./emsdk activate 3.1.50Project structure for a testable Emscripten module:
mylib/
src/
mylib.cpp
mylib.h
tests/
test_unit.cpp
test_bindings.js
test_filesystem.js
CMakeLists.txt
MakefileA 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.wasmCompiling 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 callsatexithandlers before the process exits, required formain()-based test runners-s ENVIRONMENT=node— targets Node.js only, produces a smaller bundle--emrun— adds support foremrun'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=1Run in a headless browser (requires a browser binary on PATH):
emrun --browser chromium --headless tests/test_unit.htmlFor 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.jsTesting 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=nodeJavaScript 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.jsTesting 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.jsNODEFS 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.jsThe -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=nodeAsyncify 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.