E2E Testing with Email Verification Guide
This guide covers best practices for end-to-end testing with email verification in a containerized environment.
For the flow-by-flow status of the browser suite, see the E2E Coverage Map.
Overview
Our e2e testing setup uses:
- Playwright for browser automation
- MailHog for email capture and testing
- Docker Compose with testing profiles
- Real backend services (not mocked) for authentic testing
Quick Start
# Run e2e tests with environment setup and fresh seeded data
cd frontend && vp run e2e
# Run the offline queue/reconnect coverage with environment setup
cd frontend && vp run e2e e2e/offline-mode.spec.ts
# Run a specific test with environment setup
cd frontend && vp run e2e e2e/profile.spec.ts
# Run the helper-profile coverage with environment setup
cd frontend && vp run e2e e2e/helper-profile-creation.spec.ts
# Run tests in headed mode with environment setup
cd frontend && vp run e2e --headed
# Run headless against an already prepared stack
cd frontend && vp run e2e:direct
# Run Playwright UI mode after services are already running
cd frontend && vp run e2e:ui
# Open the most recent HTML report
cd frontend && vp run e2e:report
# Run a single test in headed mode against an already prepared stack
cd frontend && vp run e2e:direct --headed e2e/profile.spec.tsvp run e2e:ui is the most convenient "visual mode" for this repo. If vp is not on your shell PATH yet, run bun run e2e:ui from frontend/ instead. It opens Playwright's interactive UI, and because it uses SKIP_E2E_SETUP=true, it expects the Docker app stack and MailHog to already be running.
If you want the full backend/test-data setup first, run cd frontend && vp run e2e once, then start vp run e2e:ui in another terminal.
Important when debugging E2E against http://localhost:8000: this repo serves the SPA from the backend container, so frontend source edits do not automatically show up in Playwright. After frontend changes that affect the bundled app, rebuild the frontend and then rebuild the backend image:
cd frontend
bun run build
cd ..
docker compose up -d --build backendUse plain docker compose restart backend only for backend-only PHP changes. If you skip --build after frontend changes, Playwright can end up testing stale bundled assets from the existing container image.
If Playwright reports missing browser executables, install them once:
cd frontend
vp exec playwright install chromiumOffline Queue Coverage
The offline queue Playwright coverage in frontend/e2e/offline-mode.spec.ts is intentionally stricter than a simple "toast appeared" check. It verifies that offline pet creation, editing, and deletion survive reconnect and reload against the Docker-served app at http://localhost:8000.
That matters because persisted React Query mutations can be restored in an in-between state during hydration. If you ever touch the offline mutation plumbing again, rerun this spec after rebuilding the frontend bundle and backend image:
cd frontend
vp run e2e e2e/offline-mode.spec.tsRunner Behavior (Current)
The E2E runner script (frontend/scripts/e2e-test.sh) now explicitly ensures required services are running before tests:
docker compose up -d db backenddocker compose --profile e2e up -d mailhog
This avoids the old "port in use => skip startup" trap where backend could be up but MailHog was down.
The wrapper script forwards extra arguments directly to playwright test. That means flags like --headed and file filters work, but custom non-Playwright flags should not be assumed unless the script explicitly implements them.
Playwright now defaults to 1 worker in this repo because the suite still leans on a small set of seeded accounts and a few write-heavy flows. You can override that locally with PLAYWRIGHT_WORKERS, but the single-worker default is the safest baseline when you want reproducible results:
cd frontend
PLAYWRIGHT_WORKERS=2 vp run e2eStable E2E Users
The seeded E2E environment provides a few stable accounts that are useful for multi-user flows:
user1@catarchy.space / passwordpassword-reset@catarchy.space / passwordinvitee@catarchy.space / passwordtelegram_5612904335@telegram.meo-mai-moi.local / password
Use the dedicated password-reset user for reset-link scenarios so that changing a password in one spec does not invalidate the shared main test user for the rest of the suite.
The Telegram placeholder user is especially useful for testing the account email-setting journey.
profile.spec.ts mutates both of these and never restores them. It renames user1@catarchy.space (seeded as "Support 🐱") to Support Cat <timestamp>, and it rewrites the Telegram placeholder's email to a throwaway address, so that account can no longer log in. With SKIP_E2E_SETUP=true nothing reseeds between runs, so the Telegram spec fails at login on every run after the first, and any spec asserting on user1's display name breaks depending on run order. Restore both with:
docker compose exec -T backend php artisan db:seed --class=UserSeeder --forceThe seeder recreates the placeholder by email rather than repairing the mutated row. When writing a spec, assert on relationship roles or other structure rather than on a seeded user's name; e2e/placement-lifecycle.spec.ts has the pattern.
If you change backend seeders or other backend-only E2E setup code, rebuild the backend container before rerunning Playwright:
docker compose up -d --build backendArchitecture
Email Testing Flow
- E2E seeder configures MailHog as active email provider in database
- MailHog captures emails sent by Laravel during registration
- Test waits for email using MailHog API polling
- Verification URL extracted from email content
- Browser visits URL to complete verification
- Test verifies user can access protected routes
Database-Driven Email Configuration
The app uses EmailConfiguration model to manage email settings, which override .env values. For e2e testing:
E2EEmailConfigurationSeedercreates and activates MailHog SMTP configE2ETestingSeederorchestrates all necessary seeders for complete test environmentemail:verify-configcommand helps debug email configuration issues
Service Dependencies
# docker-compose.yml profiles
services:
mailhog:
profiles: [testing, e2e] # Only runs with --profile e2e
ports:
- '1025:1025' # SMTP server
- '8025:8025' # Web UI for debuggingFor Docker-based E2E email delivery, use the Compose service hostname from inside the backend container.
- Correct inside Docker:
mailhog:1025 - Incorrect inside Docker:
127.0.0.1:1025orlocalhost:1025
localhost inside the backend container points back to that same container, not to MailHog.
Best Practices
1. Ensure sign-in helpers wait for redirects
Always ensure sign-in helpers such as login() wait for the authentication redirect to complete:
// Good: The sign-in helper waits for the redirect.
export async function login(page: Page, email: string, password: string) {
await gotoApp(page, '/login')
// ... sign-in form filling ...
await page.locator('form').getByRole('button', { name: 'Sign in', exact: true }).click()
// Wait for successful sign-in and redirect to home.
await expect(page).toHaveURL(/^https?:\/\/[^/]+\/?(\?.*)?$/, {
timeout: 10000,
})
}
// Bad: The sign-in helper does not wait for the redirect.
export async function login(page: Page, email: string, password: string) {
// ... sign-in form filling and submit ...
// Missing: No wait for redirect completion!
}Why this matters: Tests can falsely look signed in when only a URL changed. Assert an authenticated UI signal after sign-in too, such as the user-menu trigger.
2. Use Real Email Services in Tests
✅ Good: Test with actual email sending
// Wait for real email from MailHog
const email = await mailhog.waitForEmail(user.email, {
timeout: 15000,
subject: 'Verify',
})❌ Avoid: Mocking email verification entirely
// This doesn't test the real flow
await page.route('**/verify-email', () => ({ status: 200 }))2. Use Real Email Services in Tests
✅ Good: Test with actual email sending
// Wait for real email from MailHog
const email = await mailhog.waitForEmail(user.email, {
timeout: 15000,
subject: 'Verify',
})❌ Avoid: Mocking email verification entirely
// This doesn't test the real flow
await page.route('**/verify-email', () => ({ status: 200 }))3. Clean State Between Tests
test.beforeEach(async () => {
mailhog = new MailHogClient()
await mailhog.clearMessages() // Clear previous emails
})4. Use Dedicated Test Database
# E2E tests use separate database
DB_DATABASE=meo_mai_moi_testing6. Handle Timing Issues
// Wait for email with reasonable timeout
const email = await mailhog.waitForEmail(userEmail, {
timeout: 15000, // 15 seconds
interval: 1000, // Check every second
subject: 'Verify Email',
})8. Test Edge Cases
test('handles invalid verification link', async ({ page }) => {
await page.goto('/email/verify/999/invalid-signature')
await expect(page.getByText(/invalid|expired/i)).toBeVisible()
})10. Handle Complex Form Interactions
For forms with dynamic components like city selection that allow creating new entries:
// City selection with creation
test('creates pet with new city', async ({ page }) => {
await login(page, TEST_USER.email, TEST_USER.password)
await page.goto('/pets/create')
// Fill basic pet info
await page.getByLabel('Name').fill('Test Pet')
// Handle city selection - create new city if needed
await page.getByText('Select city').click()
await page.getByPlaceholder('Search cities...').fill('New Test City')
await page.getByText('Create: "New Test City"').click()
await page.getByRole('button', { name: 'Create Pet' }).click()
await expect(page).toHaveURL(/^https?:\/\/[^/]+\/?$/)
})Note: Components like CitySelect may have complex interactions involving dropdowns, search, and creation. Test these thoroughly.
12. Test Form Validation
Validate that required fields show appropriate error messages:
test('validates required fields', async ({ page }) => {
await login(page, TEST_USER.email, TEST_USER.password)
await page.goto('/pets/create')
// Submit empty form
await page.locator('form button[type="submit"]').click()
// Check for validation errors
await expect(page.locator('text=/required|invalid/i').first()).toBeVisible()
})Note: keep validation expectations aligned with current business rules. Some fields that used to be required in older flows may now be optional.
14. Prefer Structural Selectors for i18n-Safe E2E
Text-only selectors are fragile when locale changes (EN/RU/UK/VI) or copy is updated. Prefer stable roles/attributes where possible:
form button[type="submit"]- specific role targets (
role="combobox",role="option") - dedicated data attributes when available
Use visible text as fallback, not as the only selector strategy.
13. Clean Up Debug Artifacts
Remove debug files generated during development:
# Remove debug screenshots and files
rm frontend/debug-*.png
# Update .gitignore to prevent future commits
echo "debug-*.png" >> frontend/.gitignoreTip: Add debug-*.png to .gitignore to prevent accidentally committing debug screenshots.
Environment Configuration
Backend (.env.e2e)
# E2E Testing Environment
APP_ENV=testing
DB_DATABASE=meo_mai_moi_testing
# Email settings (fallback - database config takes precedence)
MAIL_MAILER=smtp
MAIL_HOST=localhost
MAIL_PORT=1025
MAIL_FROM_ADDRESS=test@meomaimoi.local
# Enable email verification
EMAIL_VERIFICATION_REQUIRED=true
# Use sync queue for immediate processing
QUEUE_CONNECTION=syncNote: Email configuration is managed via the EmailConfiguration model in the database. The E2E seeder automatically configures MailHog as the active email provider.
Frontend Environment Files
.env (Base configuration):
PLAYWRIGHT_BASE_URL=http://localhost:8000.env.e2e (E2E-specific additions):
MAILHOG_API_URL=http://localhost:8025/api/v2The Playwright config loads these files in order: .env.e2e.local → .env.e2e → .env.local → .env, so e2e-specific settings override base settings.
MailHog API Usage
Basic Operations
const mailhog = new MailHogClient()
// Get all messages
const messages = await mailhog.getMessages()
// Get messages for specific email
const userMessages = await mailhog.getMessagesForEmail('user@example.com')
// Wait for email with polling
const email = await mailhog.waitForEmail('user@example.com', {
timeout: 10000,
subject: 'Welcome',
})
// Extract verification URL
const verificationUrl = mailhog.extractVerificationUrl(email)Advanced Patterns
// Test email content
test('verification email contains correct content', async () => {
const email = await mailhog.waitForEmail(TEST_USER.email)
expect(email.Content.Headers['Subject'][0]).toContain('Verify')
expect(email.Content.Body).toContain(TEST_USER.name)
expect(email.Content.Body).toContain('verify')
})
// Test multiple emails
test('sends welcome email after verification', async () => {
// Register and verify...
const emails = await mailhog.getMessagesForEmail(TEST_USER.email)
expect(emails).toHaveLength(2) // Verification + Welcome
const welcomeEmail = emails.find((e) => e.Content.Headers['Subject'][0].includes('Welcome'))
expect(welcomeEmail).toBeTruthy()
})Debugging
View Emails in Browser
# Start the app and MailHog without running Playwright
docker compose up -d db backend
docker compose --profile e2e up -d mailhog
# Then open the MailHog UI at http://localhost:8025Check Email Configuration
# Verify email config status
docker compose exec backend php artisan email:verify-config --env=e2e
# Test email connection
docker compose exec backend php artisan email:verify-config --test --env=e2e
# Manually setup MailHog config
docker compose exec backend php artisan db:seed --class=E2EEmailConfigurationSeeder --env=e2eCheck Service Status
# Check if services are running
docker compose --profile e2e ps
# View logs
docker compose logs mailhog
docker compose logs backendDebug Test Failures
# Run with UI mode for debugging (recommended)
vp run e2e:ui
# Run with visible browser against an already prepared stack
vp run e2e:direct --headed
# Run with debug mode (pauses at each step)
vp run e2e:direct --debug
# Run specific test file with visible browser
vp run e2e:direct --headed e2e/auth.spec.ts
# Enable slow motion for easier observation (uncomment in playwright.config.ts)
# launchOptions: { slowMo: 500 }Common Issues & Solutions
1. Sign-in helper does not wait for redirects
Problem: Sign-in helpers do not wait for authentication redirects, causing subsequent navigation to fail.
Solutions:
- Always wait for URL changes after sign-in:
await expect(page).toHaveURL(/^https?:\/\/[^/]+\/?/) - Ensure
login()returns only after successful authentication - Test the helper independently to verify that it completes
2. Authentication Redirect Loops
Problem: Tests fail because /register redirects to /login
Solutions:
- This was fixed in the AuthContext to exclude public pages from unauthorized redirects
- Ensure
AuthContext.tsxhas proper public path handling - Check that
loadUser()is not called on public pages
3. Email Not Received
Problem: Test times out waiting for email
Solutions:
- Check MailHog is running:
curl http://localhost:8025/api/v2/messages - Verify Laravel mail config points to MailHog
- Check queue is set to
syncfor immediate processing - Increase timeout in test
4. Verification URL Not Found
Problem: Cannot extract verification URL from email
Solutions:
- Check email content format matches expected pattern
- Update regex patterns in
extractVerificationUrl() - Log email body for debugging:
console.log(email.Content.Body)
5. Database State Issues
Problem: Tests fail due to existing data
Solutions:
- Use
RefreshDatabasein Laravel tests - Run
migrate:fresh --seedbefore e2e tests - Use unique email addresses per test run
6. Service Startup Race Conditions
Problem: Tests start before services are ready
Solutions:
- Add health checks to docker-compose.yml
- Use
waitUntilin Playwright navigation - Add explicit waits for service availability
7. Global Setup Health Check Issues
Problem: global-setup.ts checks for /api/health endpoint that may not exist
Solutions:
// ✅ Good: Check actual web server response
execSync('curl -f -I http://localhost:8000 >/dev/null 2>&1', { stdio: 'pipe' })
// ❌ Bad: Assume /api/health exists
execSync('curl -f http://localhost:8000/api/health >/dev/null 2>&1', {
stdio: 'pipe',
})Note: Use curl -I (HEAD request) to check if the web server is responding, rather than assuming specific API endpoints exist.
Real-World Example: Pet Creation Test
Here's a complete example from our pet creation test that demonstrates multiple best practices:
import { test, expect } from '@playwright/test'
import { login } from './utils/app'
import { MailHogClient } from './utils/mailhog'
const TEST_USER = { email: 'user1@catarchy.space', password: 'password' }
test.describe('Pet Creation', () => {
let mailhog: MailHogClient
test.beforeEach(async () => {
mailhog = new MailHogClient()
await mailhog.clearMessages() // Clean state between tests
})
test('allows authenticated user to create a new pet', async ({ page }) => {
// Sign in and wait for the redirect.
await login(page, TEST_USER.email, TEST_USER.password)
// Navigate to protected route
await page.goto('/pets/create')
await expect(page.locator('#root')).toBeVisible()
// Fill form with complex interactions
await page.getByLabel('Name').fill('Test Pet')
await page.getByLabel('Birthday Precision').selectOption('day')
await page.locator('#birthday').fill('2020-06-15')
// Handle dynamic city creation
await page.getByText('Select city').click()
await page.getByPlaceholder('Search cities...').fill('Test City')
await page.getByText('Create: "Test City"').click()
// Submit and verify success
await page.getByRole('button', { name: 'Create Pet' }).click()
await expect(page).toHaveURL(/^https?:\/\/[^/]+\/?$/, { timeout: 10000 })
await expect(page.getByText('Test Pet')).toBeVisible()
})
})This example shows:
- Sign-in helper waits for the redirect
- Complex form interactions (city selection)
- State cleanup between tests
- Realistic timeouts and expectations
- Complete user flow testing
Integration with CI/CD
GitHub Actions Example
name: E2E Tests
on: [push, pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Start services
run: docker compose --profile e2e up -d
- name: Wait for services
run: |
timeout 60 bash -c 'until curl -f http://localhost:8025/api/v2/messages; do sleep 2; done'
- name: Setup test database
run: docker compose exec backend php artisan migrate:fresh --seed --env=e2e
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install Playwright
run: cd frontend && vp install && vp exec playwright install
- name: Run E2E tests
run: cd frontend && vp run e2e
- name: Upload test results
uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: frontend/playwright-report/Performance Tips
- Parallel Test Execution: Use
fullyParallel: truein Playwright config - Selective Service Startup: Use Docker profiles to only start needed services
- Database Optimization: Use transactions where possible instead of full refreshes
- Email Cleanup: Clear MailHog messages between tests to avoid memory buildup
- Timeout Tuning: Set appropriate timeouts based on your service startup times
Security Considerations
- Test Data Isolation: Use separate test database and email addresses
- Credential Management: Use environment variables for test credentials
- Network Security: MailHog should only be accessible in test environments
- Data Cleanup: Ensure test data doesn't persist in production-like environments