name: tdd description: Test-Driven Development workflow using Vitest + @devvit/test. Use when writing any new feature, fixing bugs, or refactoring existing code.
Test-Driven Development (TDD)
All code must follow the Coding Principles in AGENTS.md (functional, minimal, readable, modular).
The cycle: Red → Green → Refactor
- Red — Write a failing test that describes the desired behavior
- Green — Write the minimal code to make the test pass
- Refactor — Clean up while keeping tests green
Never write implementation code without a failing test first.
Test runner: Vitest + @devvit/test
bun run test # run all tests once
bun run test:watch # watch mode (dev only)
@devvit/test provides a miniature Devvit backend per test — in-memory Redis, Reddit API mocks, Scheduler, Realtime, Media, Notifications, and per-test isolation. No manual mock setup needed.
File placement
src/
├── server/
│ ├── __tests__/ # Server + Devvit integration tests
│ │ ├── post.test.ts
│ │ ├── index.test.ts
│ │ └── redis-integration.test.ts
│ ├── lib/
│ │ └── __tests__/ # Helper/utility tests
│ └── routes/
│ └── __tests__/ # Route-specific tests
└── shared/
└── __tests__/ # Pure function tests
Test files: *.test.ts — colocated in __tests__/ directories next to the code they test.
Imports
import { createDevvitTest } from '@devvit/test/server/vitest'
import { redis } from '@devvit/redis'
import { reddit } from '@devvit/reddit'
import { realtime } from '@devvit/web/server'
import { expect, vi } from 'vitest'
Important: In test files, use @devvit/redis and @devvit/reddit for Redis and Reddit imports. The test harness resolves these correctly. For other capabilities, use @devvit/web/server.
Test setup with @devvit/test
Each test gets an isolated Devvit world — fresh Redis, fresh context, no state bleed:
const test = createDevvitTest()
test('stores and retrieves data', async () => {
await redis.set('key', 'value')
const result = await redis.get('key')
expect(result).toBe('value')
})
test('isolated — previous test state is gone', async () => {
const result = await redis.get('key')
expect(result).toBeUndefined()
})
Configuration
const test = createDevvitTest({
username: 'testuser',
userId: 't2_testuser',
subredditName: 'testsub',
subredditId: 't5_testsub',
settings: { 'my-setting': 'value' },
})
Test fixtures and mocks
Each test receives Devvit-specific fixtures as arguments:
test('uses fixtures', async ({ mocks, userId, subredditName }) => {
// userId = 't2_testuser' (from config)
// subredditName = 'testsub' (from config)
// mocks = object with helpers for inspecting mock state
})
Testing Hono routes
Use app.request() — no server startup needed:
import { app } from '../index'
const test = createDevvitTest()
test('POST /api/my-route returns success', async () => {
const res = await app.request('/api/my-route', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: 'value' }),
})
expect(res.status).toBe(200)
})
Testing Redis patterns
In-memory Redis supports strings, hashes, sorted sets, counters, and transactions:
test('leaderboard sorted set', async () => {
await redis.zAdd('leaderboard', { member: 'alice', score: 100 })
await redis.zAdd('leaderboard', { member: 'bob', score: 200 })
const top = await redis.zRange('leaderboard', 0, 1, { by: 'rank', reverse: true })
expect(top.map((e) => e.member)).toEqual(['bob', 'alice'])
})
test('commits redis transactions', async () => {
await redis.set('txn', '0')
const txn = await redis.watch('txn')
await txn.multi()
await txn.incrBy('txn', 4)
await txn.incrBy('txn', 1)
const results = await txn.exec()
expect(results).toStrictEqual([4, 5])
expect(await redis.get('txn')).toBe('5')
})
Mocking Reddit API
Use vi.spyOn for Reddit API calls:
test('submits a post', async ({ subredditName }) => {
vi.spyOn(reddit, 'submitCustomPost').mockResolvedValue({ id: 't3_abc' } as never)
const result = await myFunction()
expect(reddit.submitCustomPost).toHaveBeenCalledWith({
subredditName, title: 'My Post', entry: 'default',
})
})
Seeding test data
test('fetches a user', async ({ mocks }) => {
mocks.reddit.users.addUser({ id: 't2_bob', name: 'bob' })
const user = await reddit.getUserByUsername('bob')
expect(user.id).toBe('t2_bob')
})
Testing Realtime
import { realtime } from '@devvit/web/server'
test('emits realtime events', async ({ mocks }) => {
await realtime.send('scores', { latest: 42 })
const messages = mocks.realtime.getSentMessagesForChannel('scores')
expect(messages).toHaveLength(1)
expect(messages[0].data?.msg).toStrictEqual({ latest: 42 })
})
Testing Media uploads
import { media } from '@devvit/media'
test('uploads media assets', async ({ mocks }) => {
const response = await media.upload({
url: 'https://example.com/image.png',
type: 'image',
})
expect(response.mediaId).toBe('media-1')
expect(mocks.media.uploads).toHaveLength(1)
})
Testing Notifications
import { notifications } from '@devvit/notifications'
test('sends push notifications', async ({ mocks, userId }) => {
await notifications.optInCurrentUser()
await notifications.enqueue({
title: 'Hello',
body: 'World',
recipients: [{ userId }],
})
const sent = mocks.notifications.getSentNotifications()
expect(sent).toHaveLength(1)
expect(sent[0].title).toBe('Hello')
})
Notification features are gated beta behavior. Tests should verify the app uses Devvit notification APIs as the source of truth (optInCurrentUser, optOutCurrentUser, isOptedIn, listOptedInUsers, enqueue) and does not maintain custom device-token or Redis opt-in systems.
Testing HTTP (blocked by default)
HTTP requests throw by default in tests. Mock globalThis.fetch to test code that makes HTTP calls:
test('mocks external HTTP', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ data: 'test' }), { status: 200 })
)
const res = await fetch('https://api.example.com/data')
expect(res.status).toBe(200)
vi.restoreAllMocks()
})
Capability support matrix
| Capability | Status | Notes |
|---|---|---|
| Redis | ✅ Supported | Per-test isolation; transactions supported |
| Scheduler | ✅ Supported | Jobs listed immediately; time does not advance |
| Settings | ✅ Supported | Per-test isolation; configurable defaults |
| Realtime | ✅ Supported | In-memory recording of sent/received messages |
| Media | ✅ Supported | In-memory uploads with synthetic IDs/URLs |
| Notifications | ✅ Supported | In-memory recording |
| HTTP | ✅ Blocked by default | Mock fetch to allow |
| Reddit API | ⚠️ Partial | Helpful errors for unimplemented methods |
| Payments | ❌ Not yet | — |
What to test
| Layer | What to test | Mocking approach |
|---|---|---|
shared/ |
Pure logic, validators, parsers | None needed |
server/lib/ |
Business logic, Redis patterns | In-memory Redis (automatic) |
server/routes/ |
HTTP status, response shape, errors | app.request() + vi.spyOn |
client/*.ts |
Extracted logic (not .svelte) | Standard Vitest mocks |
Compliance tests to add
When the feature touches the behavior below, write tests for the rule, not just the happy path:
| Feature | Required tests |
|---|---|
| User score comments | Uses runAs: 'USER', replies to stored sticky comment, rejects missing post/user context |
| User-created posts | Requires explicit endpoint action, includes userGeneratedContent, validates UGC shape/size |
| Subscribe action | Separate endpoint/action, does not gate gameplay, handles failure without blocking replay |
| Logged-out play | Core game/read endpoints work without context.userId; save/share/progression prompts require login |
| Streaks | Server-verified completion only, UTC content date, new user streak 0, leap-year/year-boundary cases |
| Media uploads | Rejects invalid body, unbounded data URLs, unsupported media type, and non-allowlisted remote URLs |
| Deletion triggers | Removes stored post/comment/account content and identity references while keeping allowed metadata |
| Scheduler batches | Persists cursor, schedules next job only when more work remains, respects batch size |
| Payments | Fulfill grants promised entitlement; refund revokes reversible entitlement; rejected orders return reason |
| Notifications | Uses Devvit opt-in APIs only, batches recipients, handles enqueue failures without aborting all users |
TDD rules
- Write the test first
- One assertion per behavior
- Descriptive test names —
'returns 400 when guess exceeds max length' - Test behavior, not internals
- Use @devvit/test isolation — no beforeEach cleanup needed
bun run testmust pass before committing
Checklist before finishing
- Tests written BEFORE implementation code
- All edge cases covered (undefined, empty, invalid input)
- Error paths tested (throws, rejects, error responses)
- Test names describe behavior, not implementation
- HTTP calls mocked with
vi.spyOn(globalThis, 'fetch') -
bun run testpasses with zero failures