DEV Community

Dinesh_gowtham
Dinesh_gowtham

Posted on

Testing Claude Prompt Outputs with Node.js Built‑in test Runner: A Simple, Real‑World Guide

The new node:test runner finally gives you a native way to unit‑test LLM prompts without pulling in heavy frameworks. See how a few lines of code can verify Claude’s JSON responses and keep your AI‑driven features reliable.

Why Test LLM Prompts at All

Large language models (LLMs) like Claude are amazing, but they are still probabilistic—the same prompt can produce slightly different wording each time. When your application depends on a structured answer (e.g., a JSON payload with name and age), a single unexpected token can break downstream code, cause UI glitches, or even raise security flags.

In plain English: Testing LLM prompts is like checking a recipe before you serve it. You want to be sure the dish always has the right ingredients, not just that it “usually” tastes good.

Why write tests for prompts?

  1. Guard against regressions – if you tweak the prompt wording or change model version, a failing test tells you instantly that the JSON shape changed.
  2. Document expectations – the test file becomes a living contract that new teammates can read to understand what the model should return.
  3. Speed up CI – a fast, zero‑dependency test runs in milliseconds, so your CI pipeline stays lightweight.

The built‑in node:test module lets you do all of this without pulling Jest, Mocha, or any other test library. It ships with Node 20+, integrates with the node --test CLI, and works with the standard assert module.

Getting Started with node:test

Before you write a prompt test, you need a minimal test environment. node:test provides two main exports:

  • test – a function that defines an individual test case.
  • assert – a collection of assertion helpers (e.g., assert.deepStrictEqual).

Both are available from the core node:test module, so there’s nothing to install.

// test-setup.ts
import { test } from 'node:test';          // pulls in the test runner
import * as assert from 'node:assert';    // standard assertion library

// A tiny helper that prints a friendly message when the test file loads.
// This runs once, before any test cases.
console.log('🧪 Starting LLM prompt tests...');
Enter fullscreen mode Exit fullscreen mode

Running the tests

You can execute all *.test.ts (or *.test.js) files with:

node --test
Enter fullscreen mode Exit fullscreen mode

If you want the runner to watch for file changes and re‑run automatically, add --watch:

node --test --watch
Enter fullscreen mode Exit fullscreen mode

And for basic code‑coverage, enable the experimental flag:

node --test --experimental-coverage
Enter fullscreen mode Exit fullscreen mode

Tip: The node:test runner prints a concise summary (✓ for passes, ✕ for failures) that is easy to read in CI logs.

Gotcha #1 – Returning async promises

node:test treats any unhandled promise rejection as a test failure. However, if you write an async test but forget to return the promise, the runner thinks the test finished synchronously and will pass even if the async code later throws.

// ❌ Bad – test appears to pass, but the async work is ignored
test('bad async test', async () => {
  // missing "return" means the runner does not wait for this promise
  someAsyncFunctionThatFails();
});

// ✅ Good – returning the promise lets the runner see the rejection
test('good async test', () => {
  return someAsyncFunctionThatFails(); // runner waits and reports failure
});
Enter fullscreen mode Exit fullscreen mode

Always either return the promise or mark the callback as async and await inside it. The pattern shown in the next sections follows the safe async + await style.

Calling Claude via Bedrock from a Test

Amazon Bedrock is the managed service that hosts Claude 3.0 Haiku. The official SDK package you’ll need is @aws-sdk/client-bedrock-runtime. Below is a minimal, fully commented test that calls Claude and asserts the JSON shape.

// claude-prompt.test.ts
import { test, describe, snapshot } from 'node:test';
import * as assert from 'node:assert';
import {
  BedrockRuntimeClient,
  InvokeModelCommand,
} from '@aws-sdk/client-bedrock-runtime';

// ---------------------------------------------------------------------------
// 1️⃣  Create a Bedrock client.
//    • The client reads AWS credentials from the environment (e.g., ~/.aws/credentials)
//    • Region should match where your Bedrock instance lives.
// ---------------------------------------------------------------------------
const client = new BedrockRuntimeClient({ region: 'us-east-1' });

// ---------------------------------------------------------------------------
// 2️⃣  Build the request payload.
//    • modelId identifies Claude 3.0 Haiku.
//    • The prompt asks Claude to output strict JSON.
//    • We set `accept: 'application/json'` to request a JSON‑only response.
// ---------------------------------------------------------------------------
function buildClaudeRequest(): InvokeModelCommand {
  const body = {
    // Claude expects a "prompt" field for classic completion APIs.
    prompt: `You are a helpful assistant. Return a JSON object with exactly two keys:
    - name: a first name string
    - age: a whole number between 1 and 120

    Do NOT add any extra text or formatting.`, // <-- the prompt we are testing
    temperature: 0, // low temperature makes output deterministic
    maxTokens: 100,
    // The model will output a raw JSON string.
  };

  return new InvokeModelCommand({
    modelId: 'anthropic.claude-3-haiku-20240307-v1:0', // Claude 3.0 Haiku identifier
    contentType: 'application/json',                // tell Bedrock we send JSON
    accept: 'application/json',                     // ask for JSON back
    body: Buffer.from(JSON.stringify(body)),        // SDK expects a Buffer
  });
}

// ---------------------------------------------------------------------------
// 3️⃣  The test itself.
//    • We use `async` and `await` so the runner knows when the promise resolves.
//    • After receiving the response we parse the JSON string.
//    • `t.snapshot` records the exact JSON so future runs can compare.
// ---------------------------------------------------------------------------
test('Claude returns valid JSON with name and age', async (t) => {
  // Send the request to Bedrock
  const command = buildClaudeRequest();
  const response = await client.send(command);

  // Bedrock returns a Uint8Array; convert to string then JSON.
  const responseStr = Buffer.from(response.body as Uint8Array).toString('utf-8');

  // Parse the JSON – if Claude added extra text, JSON.parse will throw.
  const parsed = JSON.parse(responseStr);

  // Basic shape assertion: we expect exactly two keys.
  assert.strictEqual(Object.keys(parsed).length, 2, 'Response must have exactly two fields');
  assert.ok(typeof parsed.name === 'string', 'name must be a string');
  assert.ok(Number.isInteger(parsed.age), 'age must be an integer');

  // Snapshot the whole object – future runs compare against this snapshot.
  // The snapshot lives in a hidden __snapshots__ folder next to this file.
  t.snapshot(parsed);
});
Enter fullscreen mode Exit fullscreen mode

Why we set temperature: 0

LLMs use a temperature setting to control randomness. A temperature of 0 forces the model to pick the highest‑probability token each step, which makes the output far more repeatable—crucial for snapshot testing.

Key takeaway: A low temperature reduces nondeterminism, turning a “maybe‑maybe” model into a “mostly‑sure” one, which is what you want when you compare snapshots.

Gotcha #2 – Streaming responses are nondeterministic

Bedrock can stream responses as Server‑Sent Events (SSE). While streaming reduces latency, each chunk may arrive at slightly different times, and the model may insert tiny whitespace variations between chunks. If you enable streaming in a test, the snapshot can change on every run, causing false failures.

Solution: Disable streaming for unit tests (the default for InvokeModelCommand) or post‑process the streamed text to a canonical form (e.g., trim whitespace, sort keys). The example above uses the non‑streaming mode, which gives a single JSON string.

Gotcha #3 – Bedrock token limits per minute

Bedrock throttles on a per‑minute token quota, not per request. When you run many tests in parallel, you may hit the limit unexpectedly, leading to ThrottlingException errors. A simple mitigation is to serialize tests that hit the same model, or add a small delay between calls.

// Simple delay helper (ms)
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

test('Claude JSON test with throttling guard', async (t) => {
  await wait(200); // pause 200 ms to stay under the token ceiling
  // … rest of test same as before …
});
Enter fullscreen mode Exit fullscreen mode

Snapshot Testing LLM Output

Snapshot testing stores a “golden copy” of a value (here, the JSON object) and automatically compares future runs against that copy. If the model’s output drifts, the test fails, prompting you to examine whether the change is intentional.

How t.snapshot works

  • When you run the test for the first time, node:test creates a hidden __snapshots__ directory next to the test file.
  • Inside, a file named claude-prompt.test.ts.snap holds a serialized version of the snapshot.
  • Subsequent runs read that file and compare the current value with the stored one.

If you intentionally change the prompt and expect a different JSON shape, you can update the snapshot with the --update-snapshots flag:

node --test --update-snapshots
Enter fullscreen mode Exit fullscreen mode

Normalizing snapshots

Even with temperature 0, minor formatting differences (extra spaces, newlines) can cause mismatches. A quick way to normalize is to stringify with JSON.stringify using a stable key order.

// Inside the test, after parsing:
const normalized = JSON.stringify(parsed, Object.keys(parsed).sort(), 2);
t.snapshot(normalized);
Enter fullscreen mode Exit fullscreen mode

Now the snapshot file contains a prettified, consistently ordered JSON string, making diffs easy to read.

Tip: Keep snapshots small. If your LLM returns a huge document, snapshot testing becomes noisy. Focus on the minimal structure you need for your feature.

Running Tests in Watch Mode with Coverage

During development you want rapid feedback. node:test supports both watch mode and experimental coverage collection out of the box.

Watch mode

node --test --watch
Enter fullscreen mode Exit fullscreen mode

The runner watches every *.test.* file. When you save changes to claude-prompt.test.ts, it automatically re‑executes the test and prints the new result. No extra configuration needed.

Coverage

Coverage tells you which lines of your test file (and any helper modules) actually executed. While you rarely need 100 % coverage for prompt tests, seeing that your request‑building function is exercised can be reassuring.

node --test --experimental-coverage
Enter fullscreen mode Exit fullscreen mode

After the run finishes, Node prints a summary like:

Coverage Summary:
  lines: 95% (19/20)
  functions: 100% (2/2)
Enter fullscreen mode Exit fullscreen mode

If you want a more detailed HTML report, pipe the JSON output to c8 (a tiny coverage tool) or use the built‑in node:coverage API, but that’s optional.

Gotcha #4 – Cross‑region latency and flaky timing

When you run tests against Bedrock in a region far from your CI runner, network latency can add several hundred milliseconds. In watch mode, a long‑running test feels sluggish. The workaround is to choose the nearest region for your test environment or mock the Bedrock client locally (e.g., with aws-sdk-client-mock). The article’s focus stays on the real service, but keep the latency issue in mind if tests feel slow.

The Takeaway

In plain English: Using Node’s built‑in test runner you can write fast, zero‑dependency checks that guarantee your Claude prompts keep delivering the JSON your app expects.

  • Node’s native node:test gives you a lightweight test harness without extra packages.
  • Async handling matters: always return or await promises so failures are detected.
  • Set temperature: 0 to make Claude’s output deterministic for snapshot comparison.
  • Disable streaming (or normalize streamed text) to avoid nondeterministic snapshots.
  • Watch mode + coverage let you iterate quickly and see how much of your prompt‑building code is exercised.
  • Be aware of Bedrock limits: token‑per‑minute quotas, streaming SSE parsing, and cross‑region latency can cause flaky runs; add small delays or serialize tests if needed.

Now you have a concrete, real‑world example that you can copy into your own repo, run with a single node --test command, and extend as your AI features grow. Happy testing!


Transparency notice

This article was written with the help of an AI system — Groq (GPT OSS 120B).

Published: 2026-10-29 · Primary focus: NodeJSTesting

All code blocks are intended to be correct and runnable, but please verify them
against the Node.js docs before using in production.

Find an error? Drop a comment — corrections are always welcome.

Top comments (0)