Skip to content

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 ​

bash
# 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.ts

vp 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:

bash
cd frontend
bun run build
cd ..
docker compose up -d --build backend

Use 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:

bash
cd frontend
vp exec playwright install chromium

Offline 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:

bash
cd frontend
vp run e2e e2e/offline-mode.spec.ts

Runner 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 backend
  • docker 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:

bash
cd frontend
PLAYWRIGHT_WORKERS=2 vp run e2e

Stable E2E Users ​

The seeded E2E environment provides a few stable accounts that are useful for multi-user flows:

  • user1@catarchy.space / password
  • password-reset@catarchy.space / password
  • invitee@catarchy.space / password
  • telegram_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:

bash
docker compose exec -T backend php artisan db:seed --class=UserSeeder --force

The 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:

bash
docker compose up -d --build backend

Architecture ​

Email Testing Flow ​

  1. E2E seeder configures MailHog as active email provider in database
  2. MailHog captures emails sent by Laravel during registration
  3. Test waits for email using MailHog API polling
  4. Verification URL extracted from email content
  5. Browser visits URL to complete verification
  6. 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:

  • E2EEmailConfigurationSeeder creates and activates MailHog SMTP config
  • E2ETestingSeeder orchestrates all necessary seeders for complete test environment
  • email:verify-config command helps debug email configuration issues

Service Dependencies ​

yaml
# docker-compose.yml profiles
services:
  mailhog:
    profiles: [testing, e2e] # Only runs with --profile e2e
    ports:
      - '1025:1025' # SMTP server
      - '8025:8025' # Web UI for debugging

For 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:1025 or localhost: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:

typescript
// 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

typescript
// Wait for real email from MailHog
const email = await mailhog.waitForEmail(user.email, {
  timeout: 15000,
  subject: 'Verify',
})

❌ Avoid: Mocking email verification entirely

typescript
// 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

typescript
// Wait for real email from MailHog
const email = await mailhog.waitForEmail(user.email, {
  timeout: 15000,
  subject: 'Verify',
})

❌ Avoid: Mocking email verification entirely

typescript
// This doesn't test the real flow
await page.route('**/verify-email', () => ({ status: 200 }))

3. Clean State Between Tests ​

typescript
test.beforeEach(async () => {
  mailhog = new MailHogClient()
  await mailhog.clearMessages() // Clear previous emails
})

4. Use Dedicated Test Database ​

bash
# E2E tests use separate database
DB_DATABASE=meo_mai_moi_testing

6. Handle Timing Issues ​

typescript
// 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 ​

typescript
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:

typescript
// 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:

typescript
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:

bash
# Remove debug screenshots and files
rm frontend/debug-*.png

# Update .gitignore to prevent future commits
echo "debug-*.png" >> frontend/.gitignore

Tip: Add debug-*.png to .gitignore to prevent accidentally committing debug screenshots.

Environment Configuration ​

Backend (.env.e2e) ​

env
# 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=sync

Note: 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):

env
PLAYWRIGHT_BASE_URL=http://localhost:8000

.env.e2e (E2E-specific additions):

env
MAILHOG_API_URL=http://localhost:8025/api/v2

The 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 ​

typescript
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 ​

typescript
// 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 ​

bash
# 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:8025

Check Email Configuration ​

bash
# 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=e2e

Check Service Status ​

bash
# Check if services are running
docker compose --profile e2e ps

# View logs
docker compose logs mailhog
docker compose logs backend

Debug Test Failures ​

bash
# 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.tsx has 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 sync for 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 RefreshDatabase in Laravel tests
  • Run migrate:fresh --seed before 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 waitUntil in 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:

typescript
// ✅ 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:

typescript
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 ​

yaml
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 ​

  1. Parallel Test Execution: Use fullyParallel: true in Playwright config
  2. Selective Service Startup: Use Docker profiles to only start needed services
  3. Database Optimization: Use transactions where possible instead of full refreshes
  4. Email Cleanup: Clear MailHog messages between tests to avoid memory buildup
  5. Timeout Tuning: Set appropriate timeouts based on your service startup times

Security Considerations ​

  1. Test Data Isolation: Use separate test database and email addresses
  2. Credential Management: Use environment variables for test credentials
  3. Network Security: MailHog should only be accessible in test environments
  4. Data Cleanup: Ensure test data doesn't persist in production-like environments