Why "just submit it and check the inbox" is a bad pattern
You're building a contact form. To test the form submission, you fill it in with fake data and click submit. The notification email lands in your inbox. You delete it. Repeat 30 times during the dev cycle. By Friday afternoon, your inbox has 117 emails titled "Test test test" and you've missed two real customer messages buried in the noise.
The fix is a discipline: never test against the same destination as production. Use a separate access key during development, a separate notification email (or no email at all), and an end-to-end test that asserts on the HTTP response — not on whether an email arrived. The patterns below cover the four common test scenarios: local development, manual QA, automated end-to-end tests, and load testing.
Pattern 1: a dev access key with a sandbox destination
The cleanest pattern is two access keys: production (configured to email you) and dev (configured to email a Mailtrap inbox or to forward to a webhook bin). The form's code reads the key from an env var, so the key swaps automatically per environment.
<!-- Dev / staging form posts to a sandbox key -->
<form action="https://splitforms.com/api/submit" method="POST">
<input
type="hidden"
name="access_key"
value="${process.env.NEXT_PUBLIC_SPLITFORMS_DEV_KEY}"
>
<input name="name" required>
<input name="email" type="email" required>
<textarea name="message" required></textarea>
<button type="submit">Send</button>
</form>Inside the splitforms dashboard, configure the dev access key's notification email to point at a Mailtrap inbox (or any sandbox catch-all). Submissions land in Mailtrap, you can inspect them, and your real inbox stays clean. Webhooks on the dev key forward to a request-bin URL that captures payloads for end-to-end tests. See /docs for the access-key setup walkthrough.
Pattern 2: test mode that doesn't send email
splitforms has a per-form test mode toggle. Submissions land in the dashboard (so you can verify field encoding, the spam filter decision, the webhook payload), but no notification email goes out and the submission doesn't count against your monthly quota. Toggle it in the form settings page; submissions during the test window are tagged so you can filter them out of analytics.
The HTML doesn't change — the form still POSTs to https://splitforms.com/api/submitwith the same access key. The only difference is server-side: the splitforms backend skips the email step. The HTTP response is identical, so end-to-end tests that assert on the response don't need to know test mode is on:
# Submit a test form via curl
curl -X POST https://splitforms.com/api/submit \
-H "Accept: application/json" \
--data-urlencode "access_key=YOUR_KEY" \
--data-urlencode "name=Test User" \
--data-urlencode "email=test@example.com" \
--data-urlencode "message=Hello"
# Response (identical in test mode and production):
# {"success":true,"submission_id":"sub_xxxx","message":"Submission accepted"}When test mode is off and the email goes out, the JSON response is the same. That's deliberate — it means your tests don't need a special branch for the test environment.
Pattern 3: local request bin for development
For pure local-only testing where you don't want any external service involved, point the form's action at a local request-bin. Two good options:
- RequestBin (self-hosted). Run a Docker container locally on port 3001. The form posts to
http://localhost:3001/bin/abc, the bin captures the request, you inspect the payload in the web UI. - HTTPie one-liner.
http --pretty=format -p HhBb POST :3001— listens on port 3001, prints every incoming request to stdout. - The dev console approach. Open the browser's Network tab, submit the form, inspect the request payload in the Network tab without it ever needing to hit a backend. Add a
preventDefault()on submit so the form doesn't actually navigate.
<!-- Point the form at a local request bin -->
<form action="http://localhost:3001/bin/abc" method="POST">
<input name="name" required>
<input name="email" type="email" required>
<textarea name="message" required></textarea>
<button type="submit">Send</button>
</form>
<!-- Or intercept the submit and just log the FormData -->
<script>
document.querySelector('form').addEventListener('submit', (e) => {
e.preventDefault();
const data = Object.fromEntries(new FormData(e.target));
console.log('FORM SUBMIT', data);
});
</script>This pattern is fast to set up but only verifies the client-side payload — it doesn't exercise the real backend, so the spam filter and the access-key validation aren't tested. Use it during initial form-component development; switch to the dev-key pattern once the form is structurally complete.
Pattern 4: Mailtrap (or any sandbox SMTP)
When you want to test the email itself — subject line, reply-to header, formatting — point the dev access key at a Mailtrap inbox. Mailtrap captures emails in a fake inbox you can inspect via web UI, REST API or webhook. The same applies to other sandbox SMTPs: MailHog, Ethereal, your own catch-all SES configuration set.
Configure the destination in the splitforms dashboard:
- Sign up at mailtrap.io and copy the inbox address (looks like
abc123@inbox.mailtrap.io). - In the splitforms form settings, set the dev access key's notification email to that Mailtrap address.
- Submit a test form. Within seconds, the email appears in the Mailtrap inbox. Click into it to inspect headers, reply-to, body formatting.
Mailtrap's free tier covers most dev usage (500 emails/month). For CI runs that send hundreds of test submissions per build, prefer the test-mode pattern to skip the email step entirely.
Pattern 5: Playwright with stubbed network
For end-to-end tests, the right pattern is to stub the network call so the form never actually hits splitforms. Playwright's page.route() intercepts the POST and returns a fake response:
// tests/contact.spec.ts
import { test, expect } from '@playwright/test';
test('contact form submits and shows success state', async ({ page }) => {
// Intercept the splitforms POST and return a fake success response.
await page.route('https://splitforms.com/api/submit', async (route) => {
const request = route.request();
expect(request.method()).toBe('POST');
const body = request.postData() ?? '';
expect(body).toContain('access_key=');
expect(body).toContain('email=alice%40example.com');
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
submission_id: 'sub_test',
message: 'Submission accepted',
}),
});
});
await page.goto('http://localhost:3000/contact');
await page.fill('input[name="name"]', 'Alice');
await page.fill('input[name="email"]', 'alice@example.com');
await page.fill('textarea[name="message"]', 'Hello world');
await page.click('button[type="submit"]');
await expect(page.getByRole('status')).toHaveText(/thanks/i);
});
test('contact form shows error on submission failure', async ({ page }) => {
await page.route('https://splitforms.com/api/submit', (route) => {
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ success: false, message: 'Something went wrong' }),
});
});
await page.goto('http://localhost:3000/contact');
await page.fill('input[name="name"]', 'Alice');
await page.fill('input[name="email"]', 'alice@example.com');
await page.fill('textarea[name="message"]', 'Hello world');
await page.click('button[type="submit"]');
await expect(page.getByRole('alert')).toBeVisible();
});This is the right pattern for CI. It runs in seconds, doesn't depend on splitforms being reachable from the test runner, and verifies both happy-path and error-path UI behavior. The Cypress equivalent uses cy.intercept('POST', 'https://splitforms.com/api/submit', fixture).
Pattern 6: curl smoke test for production
When you want to verify production end-to-end (real key, real email destination), do it once with a deliberate test submission marked as such:
curl -X POST https://splitforms.com/api/submit \
-H "Accept: application/json" \
--data-urlencode "access_key=PROD_KEY" \
--data-urlencode "name=PROD SMOKE TEST" \
--data-urlencode "email=qa+formsmoke@yourdomain.com" \
--data-urlencode "message=production deploy verification at $(date -u +%Y-%m-%dT%H:%M:%SZ)"The name and message are obviously test data; the email uses a +formsmoke alias so the resulting notification can be auto-archived by an inbox rule. Run this once per deploy from CI to confirm the live form works end-to-end without needing a human to fill it in.
Watch out: CORS errors that only happen in production
The most common "works on localhost, breaks in prod" bug for forms is a CORS error caused by the access key's domain whitelist. splitforms (and most other backends) lock submissions to a configured list of origins. localhost:3000 is whitelisted by default; preview-deployment-abc.vercel.appisn't.
For deeper coverage, see the CORS error form submission fix guide— it's the most-likely follow-up debug after a test passes locally and fails on the preview URL.
Get a dev key
Sign up at /login for a free splitforms account. Generate two access keys — one for prod, one for dev — point each at a different notification email. The free tier covers 1,000 submissions/month, which is plenty even when test runs are loud. Pricing details at /pricing.
FAQ
Why shouldn't I just submit the form to test it?
Three reasons. (1) Repeated test submissions during development can mark your domain as a spam source. (2) Each test counts against your monthly submission quota. (3) Real notification emails to your inbox during a test run create noise — you can lose the actual customer submissions in a sea of `test test test`. The right pattern is a separate dev access key, a request-bin or test-mode endpoint, and an end-to-end test that asserts on the response, not on email arrival.
Can I disable email delivery on a form during development?
Yes — splitforms has a per-form test mode that accepts submissions, runs validation and returns the same response shape as production, but doesn't send the notification email or count against your quota. Toggle it in the form settings. Web3Forms doesn't have a test mode; the workaround there is a separate access key. Formspree has a deactivate switch but it returns an error instead of accepting test data.
What's the simplest local-only test?
Point the form's `action` at a local request-bin running on `localhost:3001` (the `webhook.cookies.dev` CLI tool or `pipedream/requestbin` work well). Submit the form. Inspect the captured payload in the bin's UI to verify field names, encoding and headers. Zero external services involved. The catch: CORS on `localhost` can fool you — see the CORS section below.
How do I test with Playwright or Cypress without spamming real emails?
Use a dev access key bound to a sandbox form, or stub the `fetch` to splitforms with a local mock server. The Playwright recipe in this post shows the route-interception pattern: capture the POST in-test, return a fake success response, and assert on what the form did with that response.
Can I use a real email address that I own for testing?
Yes, but use a dedicated alias (`testforms+dev@yourdomain.com`) and filter it into a folder, so you don't pollute the inbox you read every morning. Some teams set up a shared `formtests@` mailbox the whole team can grep when debugging. This pattern works fine for manual testing; for CI runs, use a request-bin or stubbed fetch instead.
Are there any privacy concerns with testing on production?
Yes — if you submit fake data through a production form, that data lands in the production dashboard and downstream systems (CRM, Slack, webhooks). On most teams that's annoying but harmless; on regulated workloads (healthcare, financial services), test data hitting prod can trip audit controls. Always run end-to-end tests against a separate test environment with its own access key.
What about testing spam filtering?
Use a dev environment, send submissions with the typical spam-bot fingerprint (no time delay, all fields filled, including the honeypot), and verify they're rejected. splitforms' test mode preserves the spam filter logic so you can verify both happy-path and spam-rejection flows in the same test suite.