Verified against Fedify CLI docs (fedify-dev/fedify docs/cli.md); activitypub.academy /api/v2/instance (Mastodon 4.1.18); Mastodon API docs (methods/search); examples/first-server and examples/verify-requests in this repository on 2026-10-15.
Testing Your Implementation
Building an ActivityPub implementation requires thorough testing to ensure compatibility with the broader fediverse. This guide covers testing tools, validation strategies, and test suites available for verifying your implementation.
Testing Levels
Testing Tools
There is no official, maintained W3C conformance test suite for ActivityPub. In practice, implementers test against real servers using these tools:
Fedify CLI
The Fedify CLI works against any ActivityPub server, not just Fedify-based ones:
# Install
npm install -g @fedify/cli
# Fetch an actor or object (add -a / --authorized-fetch to sign the request,
# for servers that refuse unsigned fetches)
fedify lookup @alice@your-server.example
# Check WebFinger and NodeInfo
fedify webfinger @alice@your-server.example
fedify nodeinfo your-server.example
# Run an ephemeral inbox server that shows what is delivered to it
fedify inbox
# Expose a local dev server over a public HTTPS URL
fedify tunnel 3000
browser.pub
browser.pub lets you browse any actor, object or collection as a federated server sees it — useful for checking how your JSON renders to others.
ActivityPub Academy
ActivityPub Academy gives you a temporary account on a Mastodon instance that shows the ActivityPub traffic it sends and receives. It runs an old Mastodon version (4.1.18 as of October 2026), so it won't show behaviour added since, such as RFC 9421 signatures or quote posts.
This site's tools
The Activity Linter, Actor Inspector and Signature Inspector run in your browser and check JSON and signatures against the specs and Mastodon's rules.
Unit Testing
Test your handlers as plain functions with the network injected, so tests are fast and deterministic. The tested tutorial server does exactly this: its app.test.js drives the HTTP handler with a fake fetchJson and deliver, using Node's built-in test runner.
import { test } from 'node:test';
import assert from 'node:assert/strict';
test('Follow is accepted and the follower stored', async () => {
const delivered = [];
const app = createApp({
cfg, store,
fetchJson: async (url) => ({ json: remoteActors[url] }), // no network
deliver: async (inbox, activity) => { delivered.push({ inbox, activity }); return 202; },
});
// ...sign a Follow with a test key, POST it to the handler, then:
assert.equal(delivered[0].activity.type, 'Accept');
});
Parsing JSON-LD Shapes
Remote servers differ in how they shape the same data. Test that your code accepts each form:
- a property as a single value and as an array (
"to": "…"vs"to": ["…"]) - a reference as a URL string and as an embedded object (
"actor": "…"vs"actor": { "id": "…" }) - the public address as
https://www.w3.org/ns/activitystreams#Public,as:PublicorPublic(Mastodon accepts all three) - extension terms under their real IRIs: Mastodon's
sensitiveisas:sensitive, not atoot:term (context_helper.rb)
Testing HTTP Signatures
Testing that a valid signature passes is the easy half. The bugs that matter are the requests your verifier accepts when it shouldn't. At minimum, test that it rejects:
- an unsigned request
- a signature that covers only some headers (e.g. just
date) — it must cover the method and full URL,host,dateand the digest - a POST whose signature doesn't cover the digest, or whose body was swapped
- a body that was re-serialised (the digest is over the raw bytes, not
JSON.stringify(req.body)) - a signature replayed against a different query string
- an activity whose
actoris not the owner of the signing key - a key document claiming an owner whose actor document doesn't list that key
- a stale or future-dated signature (Mastodon's window is roughly 65 minutes back, 1 hour forward)
- a
keyIdthat resolves to a private or loopback address
The tested verification module behind Verifying incoming requests has a node --test case for each of these, plus the RFC 9421 Ed25519 test vector. Use it as a checklist, or run your own verifier against the same cases.
Integration Testing Against a Real Server
Mocks only tell you that your code agrees with your own idea of the protocol. The tutorial server in this repository is tested against a real GoToSocial 0.22.2 in Docker, in CI (examples/first-server/integration):
compose.yamlstarts GoToSocial (imagesuperseriousbusiness/gotosocial:0.22.2) and the server under test on a private network.federate.mjsuses GoToSocial's client API to look up the test server's account, follow it, wait for theAccept, publish a post on the test server and check that it reaches the GoToSocial user's home timeline.run.shcreates a throwaway CA and certificates so both sides speak HTTPS, then runs the above; the workflow runs it on changes and weekly.
GoToSocial is a single container with SQLite, which makes it practical in CI. Mastodon needs PostgreSQL, Redis, web and Sidekiq processes and a TLS proxy; budget for a much larger setup if you need it.
Two things such a setup needs that production must never have: your server must be allowed to fetch and deliver to private addresses (the containers share a private network), and it must trust the throwaway CA. Keep both behind explicit test-only settings, as the tutorial does with ALLOW_PRIVATE_ADDRESSES.
Testing WebFinger
import { test } from 'node:test';
import assert from 'node:assert/strict';
test('WebFinger returns a JRD with a self link that loops back', async () => {
const res = await fetch(
'https://your-server.example/.well-known/webfinger?resource=acct:alice@your-server.example'
);
assert.equal(res.status, 200);
assert.match(res.headers.get('content-type'), /application\/jrd\+json/);
assert.equal(res.headers.get('access-control-allow-origin'), '*');
const jrd = await res.json();
assert.equal(jrd.subject, 'acct:alice@your-server.example');
const self = jrd.links.find((l) => l.rel === 'self');
assert.equal(self.type, 'application/activity+json');
const actor = await (await fetch(self.href, { headers: { Accept: 'application/activity+json' } })).json();
assert.equal(actor.id, self.href); // Mastodon requires this exact match
});
test('WebFinger returns 404 for unknown users and other domains', async () => {
for (const r of ['acct:nobody@your-server.example', 'acct:alice@other.example']) {
const res = await fetch(`https://your-server.example/.well-known/webfinger?resource=${encodeURIComponent(r)}`);
assert.equal(res.status, 404);
}
});
End-to-End Testing
Searching From a Mastodon Account
Ask a Mastodon server to resolve your account, the way a user's search does. resolve=true needs a user access token with the read:search scope (Mastodon API: search):
const res = await fetch(
`https://mastodon.example/api/v2/search?q=${encodeURIComponent('@alice@your-server.example')}&type=accounts&resolve=true`,
{ headers: { Authorization: `Bearer ${token}` } }
);
const { accounts } = await res.json();
// accounts[0].url should be your actor's profile URL
Use a server you run or have permission to test against, not a large public instance.
Validation Checklist
Actor Validation
- Served with
Content-Type: application/activity+json -
@contextincludeshttps://www.w3.org/ns/activitystreams -
idis exactly the URL it is served from - Has
inbox,outboxandpreferredUsername - Publishes a public key (
publicKey, or FEP-521aassertionMethod) - Returns HTML for browsers if you have a profile page (optional), with
Vary: Accept
Inbox Validation
- Accepts POST requests
- Verifies HTTP Signatures over the raw body
- Returns 202 Accepted for valid activities
- Returns 401 for invalid signatures
- Rejects activities whose
actorisn't the signer - Applies origin rules before storing objects
Delivery Validation
- Signs every POST
- Delivers to followers' shared inboxes when available, once per server
- Never delivers to the sending actor itself
- Retries network errors and 5xx; treats most 4xx as final (see Delivery at Scale)
WebFinger Validation
- Responds at
/.well-known/webfinger - Returns
application/jrd+json - Sends
Access-Control-Allow-Origin - Contains a
selflink with an ActivityPub type whosehrefis the actorid - Handles both
acct:and URL resources
Debugging Federation Issues
Request Logging
app.use((req, res, next) => {
if (req.path.endsWith('/inbox')) {
console.log('Federation request:', {
method: req.method,
path: req.originalUrl,
signature: req.headers.signature,
signatureInput: req.headers['signature-input'],
digest: req.headers.digest || req.headers['content-digest'],
contentType: req.headers['content-type']
});
}
next();
});
When Mastodon rejects one of your requests, the reason is in the response body; see Troubleshooting by Symptom.
Testing Locally with Tunnels
Federation needs a public HTTPS hostname. A tunnel gives your machine one:
fedify tunnel 3000
# or: ngrok http 3000
Common Test Scenarios
| Scenario | What to Test |
|---|---|
| User follows remote user | WebFinger → Fetch Actor → Send Follow → Receive Accept |
| User receives post | Verify signature → Store in inbox → Display in timeline |
| User creates post | Create activity → Deliver to followers → Verify delivery |
| User mentions remote user | Resolve mention → Include in activity → Deliver to inbox |
| User deletes post | Send Delete → Verify tombstone → Remote removal |
Next Steps
- Mastodon Compatibility - Mastodon-specific requirements
- Scaling and Performance - Production optimization
- WebFinger Implementation - User discovery