Nostr Agent Onboarding · start here · index · source:
nostr-dev/docs/patterns/testing.md· snapshot 2026-10-10Paths such as
~/Documents/…,~/Production Environment/…,repos/…and services onlocalhostrefer 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/:
social-basic— three people with profiles, relay lists, notes, a reply, follows, a long-form post, a reaction. The default "some events that look real".adversarial— mallory impersonating alice, p-tagging into her mentions, requesting deletion of someone else's event, squatting adtag, far-future and ancient timestamps, the degenerate empty event. Every one is validly signed — which is exactly the class of attack signatures do not stop, and the class most clients forget to handle.
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:
- Event class collapse. Replaceable (10000–19999, 0, 3) keeps one per
pubkey; addressable (30000–39999) keeps one per
(pubkey, kind, d); regular keeps everything. Seed two of each and assert what survives. The local GRASP relay gets this wrong for kind 30617 (known-issues.md#2) — if your fake relay also gets it wrong, you will never find out. - Signature verification you never actually do. Feed a tampered event.
nostrtest'sSeedFilerejects one and names the line; make sure your code does too. - Relay disagreement. Two relays, different subsets, one lying. Your client should converge, not crash or duplicate.
- Silent truncation. Issue a REQ without a
limitagainst a real relay; a server-side default may cut it. An in-memory store will not reproduce this — altitude 3 exists for exactly this. - Reconnection. Kill the relay mid-subscription and bring it back. Does the client resubscribe, and does it re-request from the right point?
- Signer login. Do not roll your own test — the
Signer Login Tester
exercises NIP-07, NIP-46 bunker and nostrconnect, and NIP-55, and logs every
wire message. Read
signer-login.mdfirst. - Timestamps you trusted.
created_atis author-controlled. Theadversarialfixture has both ends.
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
- [ ] The suite passes twice in a row with byte-identical event ids
- [ ] It passes with no network (
nostr-testenvonly; nothing reaches a public relay) - [ ] It passes in parallel (
go test -p 4, or your runner's equivalent) — no fixed ports, no shared relay - [ ] Nothing in it writes to
ws://localhost:8081 - [ ] There is at least one negative case: a forged signature, an impersonation, or a malformed event
- [ ] Replaceable and addressable collapse is asserted, not assumed
- [ ] If there is a UI: a headless run asserting zero console errors
- [ ] The fixtures are committed with their recipes, so they can be rebuilt
Related
testing/README.md— the tool referencego-client-baseline.md—fiatjaf.com/nostr, andkhatrufor building the relay under testsigner-login.md— do not test login by handknown-issues.md— the local stack's quirks, several of which only show up under test~/Production Environment/TEPPv3/TESTING.md— the most thorough test charter on this machine; read it for how to run an adversarial campaign