Markdown source for agents: https://npub1d70emggs6jzun5lhvqnfqsd9reqmaarn2qjf6q3r02gryyl4v8sqjn44xe.nsite.lol/docs/patterns/testing.md

Nostr Agent Onboarding · start here · index · source: nostr-dev/docs/patterns/testing.md · snapshot 2026-10-10

Paths such as ~/Documents/…, ~/Production Environment/…, repos/… and services on localhost refer to the author's workstation and are not available to you — read them as worked examples of a setup you can recreate.

Pattern: testing a Nostr project

Read before writing the first test. Nostr projects fail in ways ordinary test suites do not cover: an event id that changes between runs, a relay that silently truncates a query, a replaceable event that collapses differently on a real relay than in your fake, a signature you never actually verified.

Tooling: testing/. Written 2026-09-21, distilled from the harnesses in TEPPv3, Facebook-Museum, Idle StarFighter, CFR and Merged Mechs.


0. Quickstart

Any language — a throwaway relay, seeded, for the duration of one command:

export PATH="$HOME/Documents/nostr-dev/testing/bin:$PATH"

nostr-testenv run --seed fixtures/social-basic.jsonl -- pytest      # or npm test, or anything
# your test reads $NOSTR_TEST_RELAY (ws://127.0.0.1:<random>) and $NOSTR_TEST_RELAY_HTTP

Go — the same relay, in-process:

r := nostrtest.StartRelay(t, nostrtest.SeededFrom("testdata/world.jsonl"))
ev := nostrtest.Sign(t, "alice", 1, "hello", nil, nostrtest.At(0))
r.MustPublish(t, ev)
got := r.MustQuery(t, nostr.Filter{Kinds: []nostr.Kind{1}})

Identities are named and deterministic — alice is the same key on every machine, forever:

nostr-testkeys alice --sec        # 6b4f9320…  always
eval "$(nostr-testkeys --env alice bob)"   # $ALICE_SEC, $ALICE_PUB, $ALICE_NPUB, …

1. Which relay — the house rule

This is the single most useful fact in this document, and it is not guessable.

Relay What it is Use it for
nostr-testenv / nostrtest (random port) in-memory, empty at start, gone at exit your tests. Isolated, seedable, parallel-safe
ws://localhost:8080 the shared disposable nostr-rs-relay, persistent SQLite cross-session work, manual poking. ~10 msg/s — pace writes at ~110 ms. Contaminated by definition: it holds other people's runs
ws://localhost:8081 the GRASP relay — shared infrastructure read-only for tests. It holds real kind 30617 repo announcements. It also rejects most non-NIP-34 kinds ("event does not relate to a stored repository"), which people spend an hour debugging as a bug in their own code
ws://localhost:3334 the khatru scaffold, on demand experimenting with relay policy
public relays real the last mile only, and never in an automated suite

nostr-testenv refuses port 8081 outright. If your test needs the GRASP relay, it needs it for reading, and you should say so in a comment.

Never point an automated suite at a public relay. Signed events are forever, most relays rate-limit, several reject fresh keys outright (nos.lol and nostr.mom both refuse a first kind 1 from an unknown pubkey — see relays-reject-new-keys in the workspace memory), and a green suite that depends on someone else's uptime is not green.


2. The four altitudes

Pick the lowest one that can fail the way you care about.

1. Pure unit. Your event construction, parsing, validation. No relay, no network. Fast, and where most of your assertions belong. Assert on finding classes and shapes, never on error-message wording.

2. In-process relay — nostrtest.StartRelay / nostr-testenv. A real khatru speaking the real protocol over a real websocket, in your test process. This is the workhorse, and the altitude most projects skip straight past. It catches: wrong filters, replaceable/addressable collapse, subscription lifecycle, EOSE handling, reconnection, AUTH.

3. A real standalone relay — ws://localhost:8080, opt-in behind a flag (-local-relays, RUN_INTEGRATION=1). Catches what an in-memory store does not model: a server-side default limit silently truncating a REQ you issued without one, real persistence, real rate limiting. TEPPv3's tests/integration/localrelay_test.go is the reference for how to gate this.

4. Browser. Playwright, for anything with a UI or wasm. Assert zero console errors and zero unexpected network requests — the second one is how Facebook-Museum proves its "nothing leaves the machine" claim, and it is a much stronger assertion than any unit test of the same code.


3. Determinism, and why it is not optional

Three things make a Nostr test non-reproducible. All three are avoidable.

Keys. A randomly generated key means: no golden file, no re-seeding a relay idempotently (the same "event" gets a new id every run, so the relay accumulates duplicates), and no reading a six-week-old failure log. Use named identities:

sec = sha256("nostr-dev/testing/v1\n" + name)

nostr-testkeys alice and nostrtest.Key("alice") implement that same line, and nostrtest's own test suite asserts they agree — so a fixture built by the shell tool is signed by the same alice your Go test asserts on.

These keys are public. The derivation is written down, so anyone can compute every one of them. Never sign anything real with one. That an adversary can forge them is the point: it is how you write the negative cases.

The cast, and what each is conventionally for, so a stranger can read your test: alice bob carol dave ordinary participants · eve passive observer · mallory the active adversary · trent trusted third party · victor verifier · relay-owner · guardian / subject (TEPP roles) · stranger an unrelated pubkey.

Time. nostr.Now() in a test changes the event id every run. Pin it: BaseTime is 1700000000, and At(60) reads better than a literal. Fixtures default each event to base + index, so ordering is defined and a replaceable event has a defined winner.

Ports. Never hard-code one. Two tests on 7777 is a flake you will chase for an afternoon. StartRelay and nostr-testenv up both take a free port and tell you which.


4. Fixtures

A fixture is a JSONL file, one signed event per line — the same shape nak req emits, so anything can consume it. Build them from a recipe that names its authors instead of holding keys:

{
  "base_time": 1700000000,
  "events": [
    {"as": "alice", "kind": 0, "content": {"name": "Alice"}},
    {"as": "bob",   "kind": 1, "content": "re: @{alice}", "tags": [["p", "@alice"]]},
    {"as": "alice", "kind": 30023, "d": "post-1", "content": "long form"}
  ]
}
nostr-fixture build world.recipe.json > world.jsonl
nostr-fixture inspect world.jsonl        # kinds, authors BY NAME, tags, content

In a tag value, @alice becomes her pubkey hex. In content, only the explicit @{alice} expands — prose is full of @handles and must survive. Same recipe, same bytes, every time.

Ready-made, in testing/fixtures/:


5. What to test that you would not think to test

Nostr-specific failure modes, each of which has bitten a project on this machine:


6. Gotchas, with evidence

Gotcha Why
nak reads stdin. Every subprocess.run([nak, …]) needs stdin=DEVNULL or it hangs until timeout but you cannot pass both stdin= and input= — set it only when nothing is piped
khatru's UseEventstore starts the NIP-40 expiration manager which deletes your past-dated fixture behind your back. nostrtest wires the same hooks without it; if you build your own relay, do the same
ws://localhost:8080 caps at ~10 msg/s pace seeding at ~110 ms, or seed the store directly
Headless Chromium returns null for a WebGL2 context launch with --enable-unsafe-swiftshader --use-gl=angle --use-angle=swiftshader --disable-gpu-sandbox (Idle StarFighter's tools/smoke.py)
Headless Chromium dies with "Target crashed" usually the 7.5 G tmpfs /tmp is full of agent scratch. df -h /tmp first
A repo path containing a space breaks unquoted go invocations R="/home/kabouter/Production Environment/Thing"; go -C "$R" test ./... — quote everywhere
go test -race faulting inside fiatjaf.com/nostr's JSON writer TEPPv3 hit this on an older pin and used -race -gcflags=all=-d=checkptr=0. Not reproduced on the current library version — try plain -race first, and only reach for the flag if it faults
Seeding bypasses your relay's accept path which is right for arranging a world and wrong for testing acceptance. Seed arranges; MustPublish goes over the wire

7. Definition of done