Email Testing in Node.js with Jest
When your Node.js app sends a verification email, a Jest or Vitest test should be able to confirm it actually went out — and read the code or link inside. Here’s a small, dependency-free way to do that against a disposable inbox.
The idea
Unlike browser e2e (Playwright/Cypress), here you’re testing your backenddirectly: call your “send verification email” code (or hit your API), then read the email that lands in a disposable inbox and assert on it. Node 18+ has fetch built in, so no client library is needed.
A tiny helper
const BASE = 'https://api.devmailr.app/api'
const headers = { Authorization: `Bearer ${process.env.DEVMAILR_API_KEY}` }
export async function createInbox(prefix) {
const res = await fetch(`${BASE}/mailboxes`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ prefix }),
})
return res.json()
}
export async function waitForEmail(mailboxId, query = '') {
const res = await fetch(`${BASE}/mailboxes/${mailboxId}/wait?timeout=60&${query}`, { headers })
if (res.status === 404) throw new Error('No matching email arrived in time')
return res.json()
}
export async function deleteInbox(mailboxId) {
await fetch(`${BASE}/mailboxes/${mailboxId}`, { method: 'DELETE', headers })
}The test (Jest or Vitest)
Same API in both. Create an inbox, trigger your sender, assert on the email, clean up:
import { createInbox, waitForEmail, deleteInbox } from './devmailr'
import { sendVerificationEmail } from '../src/email' // your app code
let inbox
beforeAll(async () => { inbox = await createInbox(`jest-${Date.now()}`) })
afterAll(async () => { await deleteInbox(inbox._id) })
test('sends a verification email with a 6-digit code', async () => {
await sendVerificationEmail(inbox.address)
const email = await waitForEmail(inbox._id, 'has_code=true')
expect(email.from).toContain('noreply@myapp.com')
expect(email.subject).toMatch(/verify/i)
expect(email.extractedCodes[0]).toMatch(/^\d{6}$/)
}, 65_000)Raise the timeout: /wait can block up to 60s, so bump the per-test timeout above it — the third arg 65_000 in Jest (as above), or { timeout: 65_000 } in Vitest. Otherwise the runner gives up before the email arrives.
Testing a reset link
test('password reset email contains a working link', async () => {
await sendPasswordReset(inbox.address)
const email = await waitForEmail(inbox._id, 'subject=' + encodeURIComponent('reset'))
const url = new URL(email.primaryLink)
expect(url.searchParams.get('token')).toBeTruthy()
}, 65_000)Tips
- Node 18+ has
fetchbuilt in — no axios or node-fetch needed. - One inbox per file/run; clean it up in
afterAll. - Always raise the test timeout above the wait timeout.
- Pair this with browser e2e — see Playwright and Cypress for full-UI flows.
Related reading
Try it in two minutes
Create a disposable prefix@devmailr.app inbox over the API, send mail to it from your app, and read it in your tests. Free plan, no card required.