Skip to content

Testing and debugging

A native addon has two test boundaries:

  • Rust tests verify logic that does not need a live JavaScript engine.
  • JavaScript integration tests load the .node library into Node.js and verify conversion, exceptions, promises, garbage collection, and environment lifecycle behavior.

Use both. A Rust test cannot prove that a generated binding accepts the intended JavaScript value, and a JavaScript-only suite makes ordinary Rust logic slower and harder to isolate.

Test pure Rust logic with Cargo

Keep algorithms and operating-system integration independent from NAPI-RS values where possible:

src/core.rs
rust
pub fn normalize_count(value: i32) -> Result<u32, &'static str> {
  value.try_into().map_err(|_| "count must not be negative")
}

#[cfg(test)]
mod tests {
  use super::*;

  #[test]
  fn rejects_negative_counts() {
    assert_eq!(normalize_count(-1), Err("count must not be negative"));
  }
}

The exported function can be a thin conversion layer:

src/lib.rs
rust
mod core;

use napi::{Error, Result, Status};
use napi_derive::napi;

#[napi]
pub fn normalize_count(value: i32) -> Result<u32> {
  core::normalize_count(value)
    .map_err(|reason| Error::new(Status::InvalidArg, reason))
}

Run these tests normally:

sh
cargo test

Test exported pure functions with noop

Native registration normally refers to symbols supplied by the Node process. If your test binary cannot link those symbols, enable the noop feature for both napi and napi-derive in a test-only crate feature:

Cargo.toml
toml
[features]
test-noop = ["napi/noop", "napi-derive/noop"]
sh
cargo test --features test-noop

In noop mode, #[napi] does not generate the JavaScript registration layer, so an exported function made only of ordinary Rust values can be called by a Rust test. It does not create a fake JavaScript engine. Code involving Env, Function, Object, JavaScript references, promises, or conversion through napi_value still belongs in a Node integration test.

For compile-time macro diagnostics, use trybuild tests and commit their .stderr snapshots separately from runtime tests.

Test the generated binding in Node.js

Build a debug addon and import the generated loader:

package.json
json
{
  "scripts": {
    "build:debug": "napi build --platform",
    "test": "node --test"
  }
}
test/add.test.cjs
js
const assert = require('node:assert/strict')
const test = require('node:test')

const addon = require('../index.js')

test('native add', () => {
  assert.equal(addon.add(20, 22), 42)
})

test('invalid input throws synchronously', () => {
  assert.throws(() => addon.normalizeCount(-1), /must not be negative/)
})
sh
npm run build:debug
npm test

Test through the same loader your users import. Requiring a file from target/debug bypasses platform selection and can hide packaging defects.

At minimum, integration tests should cover:

  • argument and return-value conversion, including null and omitted values;
  • synchronous errors and rejected promises;
  • generated TypeScript with tsc --noEmit;
  • one clean process start and exit;
  • every Node version and target you advertise as tested.

Test workers and environment teardown

Every worker_threads worker has its own Node-API environment. Load the addon inside the worker instead of passing native classes or JavaScript handles from another isolate:

test/worker.cjs
js
const { parentPort } = require('node:worker_threads')
const { add } = require('../index.js')

parentPort.postMessage(add(2, 3))
test/worker.test.cjs
js
const assert = require('node:assert/strict')
const { join } = require('node:path')
const test = require('node:test')
const { Worker } = require('node:worker_threads')

test('loads in a worker isolate', async () => {
  const worker = new Worker(join(__dirname, 'worker.cjs'))
  const value = await new Promise((resolve, reject) => {
    worker.once('message', resolve)
    worker.once('error', reject)
  })

  assert.equal(value, 5)
  await worker.terminate()
})

Add a separate stress test when the addon owns background work or JavaScript references:

  1. Start many workers and require the addon concurrently.
  2. Exercise the async API and await normal completion.
  3. Ask the worker to stop, cancel owned work, and wait for acknowledgements.
  4. Terminate the worker only after the graceful path succeeds.
  5. Put abrupt worker.terminate() races in a dedicated test so a product limitation is not mistaken for ordinary shutdown behavior.

Abrupt termination with native async work still has open runtime-specific failure reports, especially in Bun (napi-rs#2938). Treat cancellation and worker shutdown as part of the API contract; a documentation change cannot make an in-flight operating-system call cancellable.

Test process exit

A strong ThreadsafeFunction, open handle, or background worker can keep Node alive. Test exit behavior in a child process so the main test runner cannot hide the leak:

test/exit.test.cjs
js
const assert = require('node:assert/strict')
const { spawn } = require('node:child_process')
const { join } = require('node:path')
const test = require('node:test')

test('process exits after async work', async () => {
  const child = spawn(process.execPath, [join(__dirname, 'exit-repro.cjs')])

  let timer
  const code = await Promise.race([
    new Promise((resolve, reject) => {
      child.once('exit', resolve)
      child.once('error', reject)
    }),
    new Promise((_, reject) => {
      timer = setTimeout(() => {
        child.kill()
        reject(new Error('child did not exit'))
      }, 5_000)
    }),
  ]).finally(() => clearTimeout(timer))

  assert.equal(code, 0)
})

If a ThreadsafeFunction should not keep the event loop alive, build it in weak mode. See Async and concurrency for the lifecycle tradeoff.

Test garbage collection and leaks

Garbage collection is nondeterministic. A useful regression test creates a WeakRef, drops all strong JavaScript references, requests GC repeatedly, and uses a deadline:

test/leak.cjs
js
const { NativeResource } = require('../index.js')

let resource = new NativeResource()
const weak = new WeakRef(resource)
resource = undefined

const deadline = Date.now() + 10_000
const interval = setInterval(() => {
  global.gc()
  if (weak.deref() === undefined) {
    clearInterval(interval)
    process.exit(0)
  }
  if (Date.now() > deadline) {
    console.error('NativeResource was not collected before the deadline')
    process.exit(1)
  }
}, 50)
sh
node --expose-gc test/leak.cjs

Do not assert collection immediately after one global.gc() call. Also run longer stress jobs under platform memory tools when the addon owns allocations:

  • AddressSanitizer or LeakSanitizer for Rust/C/C++ memory errors;
  • Instruments on macOS;
  • Valgrind on supported Linux configurations;
  • Application Verifier or WinDbg on Windows.

Keep sanitizer builds separate from ordinary release artifacts.

Start with a useful diagnostic run

Before attaching a debugger, reproduce the problem with a debug addon and full CLI/Rust diagnostics:

sh
DEBUG='napi:*' RUST_BACKTRACE=full napi build --platform --verbose
DEBUG='napi:*' RUST_BACKTRACE=full node ./repro.cjs

Do not pass --release or --strip. Confirm which Node executable and binding are involved:

sh
node -p "process.execPath"
node -p "process.platform + ' ' + process.arch"
file ./*.node

Use NAPI_RS_NATIVE_LIBRARY_PATH=/absolute/path/to/addon.node to make a generated loader try one exact local binary. This is a diagnostic override, not a packaging configuration.

Debug with VS Code and CodeLLDB

Install the CodeLLDB extension and create a build task:

.vscode/tasks.json
json
{
  "version": "2.0.0",
  "tasks": [
    {
      "label": "napi build debug",
      "type": "shell",
      "command": "napi build --platform",
      "problemMatcher": ["$rustc"]
    }
  ]
}

Then launch Node, not the .node library. Replace program with the absolute path printed by node -p "process.execPath" if CodeLLDB does not resolve node from PATH:

.vscode/launch.json
json
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Debug NAPI-RS in Node",
      "type": "lldb",
      "request": "launch",
      "program": "node",
      "args": ["${workspaceFolder}/repro.cjs"],
      "cwd": "${workspaceFolder}",
      "sourceLanguages": ["rust"],
      "env": {
        "RUST_BACKTRACE": "full",
        "DEBUG": "napi:*"
      },
      "preLaunchTask": "napi build debug"
    }
  ]
}

Set breakpoints in Rust before the JavaScript first imports the addon. A breakpoint may appear unbound until Node loads the .node image.

This setup is known to work on macOS, Linux, and WSL. Native Windows debugging with cppvsdbg remains an open documentation and tooling gap (napi-rs#2830); do not assume a cppvsdbg configuration is supported merely because it launches Node. CodeLLDB on Windows or WSL is currently the more reproducible starting point.

Debug with CLion or another native debugger

The same process model applies in every native debugger:

  1. Build with napi build --platform.
  2. Create a Native Application configuration.
  3. Set the executable to the exact Node executable.
  4. Set repro.cjs as the program argument and the package as the working directory.
  5. Add the build command as a before-launch task.

Alternatively, start node --inspect-brk repro.cjs, attach the native debugger to that Node PID, set Rust breakpoints, and then continue JavaScript execution. The JavaScript inspector and native debugger can be attached to the same process.

Command-line equivalents are useful for crash backtraces:

sh
# macOS or Linux with LLDB
lldb -- node ./repro.cjs
# at the LLDB prompt
run
thread backtrace all
sh
# Linux with GDB
gdb --args node ./repro.cjs
# at the GDB prompt
run
thread apply all bt

When breakpoints do not bind

Check these in order:

  1. The build omitted --release and --strip.
  2. The loader selected the binary you just built, not a platform package in node_modules.
  3. process.execPath is the executable launched by the debugger.
  4. The Rust source belongs to the exact Cargo target used for the .node file.
  5. The breakpoint is reached only after require() or import loads the addon.
  6. On macOS, the binary and Node process have matching architectures.

For loader failures, symbol errors, libc mismatches, and stale TypeScript, use the troubleshooting decision tree.