> **Nostr Agent Onboarding** · [start here](https://npub1d70emggs6jzun5lhvqnfqsd9reqmaarn2qjf6q3r02gryyl4v8sqjn44xe.nsite.lol/start.md) · [index](https://npub1d70emggs6jzun5lhvqnfqsd9reqmaarn2qjf6q3r02gryyl4v8sqjn44xe.nsite.lol/llms.txt) · 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:

```bash
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:

```go
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:

```bash
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:

```json
{
  "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"}
  ]
}
```

```bash
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 a `d` tag,
  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`](https://npub1d70emggs6jzun5lhvqnfqsd9reqmaarn2qjf6q3r02gryyl4v8sqjn44xe.nsite.lol/docs/design/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`'s `SeedFile` rejects 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 `limit` against 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](https://npub138kdqjl645xz6rvuw92m2v4us0shhpxezgqqz9zrrj2jdstakvus86qzyh.nsite.lol/)
  exercises NIP-07, NIP-46 bunker and nostrconnect, and NIP-55, and logs every
  wire message. Read [`signer-login.md`](https://npub1d70emggs6jzun5lhvqnfqsd9reqmaarn2qjf6q3r02gryyl4v8sqjn44xe.nsite.lol/docs/patterns/signer-login.md) first.
- **Timestamps you trusted.** `created_at` is author-controlled. The
  `adversarial` fixture 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-testenv` only; 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 reference
- [`go-client-baseline.md`](https://npub1d70emggs6jzun5lhvqnfqsd9reqmaarn2qjf6q3r02gryyl4v8sqjn44xe.nsite.lol/docs/patterns/go-client-baseline.md) — `fiatjaf.com/nostr`, and `khatru` for building the relay under test
- [`signer-login.md`](https://npub1d70emggs6jzun5lhvqnfqsd9reqmaarn2qjf6q3r02gryyl4v8sqjn44xe.nsite.lol/docs/patterns/signer-login.md) — do not test login by hand
- [`known-issues.md`](https://npub1d70emggs6jzun5lhvqnfqsd9reqmaarn2qjf6q3r02gryyl4v8sqjn44xe.nsite.lol/docs/design/known-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
