
E2E tests with Playwright — the quality turnaround
Context
Dogwalk was born as an experiment — a pet walking platform that, like every side project, started with “I’ll just make the basics work.” And it worked. For a while.
The problem is that side projects have a cruel lifecycle: first it’s fun because everything is new. Then comes the phase of “small bugs.” And then, without you noticing, every new deploy becomes a gamble. You change a line of CSS and discover you broke the scheduling flow. You swap an icon and the tutor’s dashboard disappears.
This is the story of how Dogwalk stopped gambling and started testing.
The beginning: no tests, no safety net
In the beginning, there were no tests at all. Zero. Not unit, not integration, certainly not E2E. The flow was:
- Code the feature
- Run
pnpm devand test manually in the browser - If it seemed to work, commit and deploy
It worked fine for a few weeks — the app was small, I could keep everything in my head. But it reached a point where touching one thing would break something completely different.
I remember one specific day: I made a change to the landing page layout (just CSS!) and, as collateral, the login modal stopped opening. I found out because a friend tested it and said “I tried to log in and nothing happens.” Terrible feeling.
The first attempt: unit tests
The first step was obvious: unit tests. I added Vitest, configured basic coverage, and started testing hooks, services, and isolated components.
pnpm add -D vitest @testing-library/react
Unit tests are great for pure logic — useProximity with Haversine, useHeatAlert, date formatting functions. They run in milliseconds and give fast feedback.
But there was a fundamental problem: unit tests don’t test what the user sees.
They test whether a function returns the right value, but not whether the button is on the screen, whether the modal opens, whether navigation works. The user doesn’t care about a calculateDistance() function — they want to click “Schedule” and see the walk confirmed.
The turnaround: Playwright enters the scene
That’s when I decided: I need tests that navigate through the app like a real user.
The choice was Playwright. Why?
- Native multi-browser support (Chromium, Firefox, WebKit) without plugins
- Clean API with
page.goto(),page.getByText(),page.getByRole() addInitScript()— perfect for session mocking (spoiler: this was crucial)- Rich HTML reports with traces and videos
- CI-friendly with
--no-sandboxandfullyParallel
// playwright.config.js — the configuration that changed everything
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testMatch: ['tests/e2e/**/*.spec.{js,ts}'],
timeout: 30_000,
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,
use: {
baseURL: process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:5173',
headless: true,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
lang: 'en-US',
timezoneId: 'America/Sao_Paulo',
},
projects: [
{ name: 'chromium', use: { browserName: 'chromium', viewport: { width: 1280, height: 800 } } },
{ name: 'firefox', use: { browserName: 'firefox', viewport: { width: 1280, height: 800 } } },
{ name: 'mobile-chrome', use: { browserName: 'chromium', ...devices['Pixel 5'] } },
],
})
The big challenge: authentication in E2E
The biggest obstacle for E2E tests in an SPA is authentication. Dogwalk has login with Turnstile (Cloudflare CAPTCHA), JWT, refresh token, and ProtectedRoute with role verification.
In a headless browser, Turnstile doesn’t render. Classic solutions:
- Inject token in localStorage — works initially, but
supabase-jscallsonAuthStateChangewhich detects an invalid token and logs out - Login via REST — good, but the token expires in 1h and you need to manage refresh
- Mock API calls — AuthContext listens to
onAuthStateChange, which dies with expired token
The solution that worked was an elegant hybrid: window.__E2E_SESSION.
window.__E2E_SESSION — the pattern that saved the tests
The idea is simple: in AuthContext, before any real authentication logic, check if window.__E2E_SESSION exists:
// AuthContext — native session mock support
if (window.__E2E_SESSION) {
setUser(window.__E2E_SESSION.user)
setSession(window.__E2E_SESSION)
setLoading(false)
return // zero auth calls
}
In tests, this becomes:
import { test } from '@playwright/test'
import { setupTutorSession } from './helpers/auth.js'
test('tutor dashboard loads', async ({ page }) => {
await setupTutorSession(page)
await page.goto('/tutor/dashboard')
await expect(page.getByText('My Pets')).toBeVisible()
})
The setupTutorSession does three things:
- Injects
window.__E2E_SESSIONwith mock data viaaddInitScript - Sets
localStoragewithdw_access_tokenanddw_user - Intercepts FastAPI calls via
page.route()and returns controlled data
The result? Zero seconds waiting for authentication. ProtectedRoute resolves instantly. No Turnstile, no 401, no refresh token.
// helpers/auth.js — realistic mock data
const MOCK_PETS = [
{ id: 'mock-pet-001', name: 'Rex', species: 'dog', breed: 'Labrador', age: 3, weight: 28.5, emoji: '' },
{ id: 'mock-pet-002', name: 'Luna', species: 'dog', breed: 'Golden Retriever', age: 2, weight: 22.0, emoji: '' },
]
const MOCK_WALKERS = [
{ id: 'mock-walker-001', name: 'Maria Walker', rating: 4.8, review_count: 42, price_per_walk: 35 },
{ id: 'mock-walker-002', name: 'Carlos PetLover', rating: 4.5, review_count: 28, price_per_walk: 25 },
]
Two passes in CI — the JSON reporter problem
Playwright has an annoying bug: the JSON reporter only writes the file after ALL tests finish. If the process dies (timeout, OOM), you lose the result.
The solution was to run twice in CI:
# Pass 1: human output (list reporter)
npx playwright test --project=chromium --timeout=30000 || true
# Pass 2: JSON report (pipe to file)
npx playwright test --project=chromium --timeout=30000 --reporter=json 2>/dev/null > /tmp/e2e-results.json
# Parse and notify
python3 scripts/track-flaky.py /tmp/e2e-results.json
bash scripts/notify-e2e.sh
The first pass runs with list reporter so we can see results in real time in the CI log. The second pass runs with json reporter redirected to a file — if it dies, we still have the first pass output.
This also feeds the flaky tracking system:
# track-flaky.py — CSV history of flaky tests
def record(results_json_path: str):
data = json.load(open(results_json_path))
stats = data.get("stats", {})
passed = stats.get("expected", 0)
failed = stats.get("unexpected", 0)
total = stats.get("total", 0)
with open("test-results/flaky-history.csv", "a") as f:
writer = csv.writer(f)
writer.writerow([datetime.now(), passed, failed, total])
The CI trinity: E2E tests before deploy
Dogwalk’s deploy has a CI/CD pipeline with quality as a gate. The flow is:
- Lint — ESLint checks code standards
- Unit tests — Vitest runs full suite
- Build — Vite build with env vars
- E2E tests ( here!) — Preview server + Playwright
- Deploy — Only if ALL steps pass
- Health check — Multi-route curl post-deploy
# Excerpt from deploy.yml
- name: Run E2E tests
if: github.ref_name == 'master'
env:
PLAYWRIGHT_TEST_URL: http://localhost:4173
run: pnpm test:e2e:ci
- name: Health Check — Multi-Route
if: github.ref_name == 'master'
run: |
for ROUTE in "/" "/login" "/plans"; do
CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://seu.pet${ROUTE}")
echo "${ROUTE} → $CODE"
done
This means: every push to master runs E2E against the exact build that will go live. If it breaks, no deploy.
The pipeline was also optimized with Playwright browser caching — the first build installs the browsers (~300MB), subsequent ones use cache:
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-\${{ hashFiles('pnpm-lock.yaml') }}
Current coverage: what each test covers
Today the E2E tests cover 7 critical areas:
| Test | Lines | What it covers |
|---|---|---|
full-user-flow.spec.js |
170 | Full flow: landing → mock login → dashboard → scheduling |
e2e-complete-flow.spec.js |
155 | 6 compact steps: landing, login modal, tutor dash, schedule, walker dash, route guard |
auth-flow.spec.js |
~80 | Login, logout, protected routes, invalid session |
booking-flow.spec.js |
~60 | Scheduling with calendar, pet selection, confirmation |
dashboard-flow.spec.js |
~70 | Tutor and walker dashboards with mock data |
payment-chat-map.spec.js |
~80 | Stripe integration, chat, tracking map |
visual.spec.js |
43 | Rendering, mobile viewport, essential elements |
security.spec.js |
~50 | Protected routes without token |
onboarding-flow.spec.js |
~40 | First access flow |
signup-edge-cases.spec.js |
~50 | Registration validations |
Total: ~13 spec files, ~800+ lines of tests.
Lessons from the trenches
After weeks of writing and maintaining these tests, some lessons stood out:
1. API mocks are better than testing against production
At first I tried testing against a real backend. Result: flaky tests due to external dependencies, inconsistent data, slowness. With mock data in addInitScript, the tests are deterministic and fast.
2. waitForTimeout is a necessary evil (but controlled)
Playwright has waitForLoadState('networkidle') that works in most cases, but in Dogwalk the SPA makes async calls that never fully “idle.” The solution was waitForTimeout(2000-3000) combined with waitForSelector for specific elements.
Less elegant, but more reliable.
3. Cookie banner is the silent enemy of E2E
Dogwalk’s cookie banner has pointer-events: none on the container so it doesn’t block clicks, but some overlays (like the SlowConnectionBanner) still intercepted interactions. We created a helper that scans and removes all fixed overlays:
export async function dismissCookieBanner(page) {
await page.evaluate(() => {
document.querySelectorAll('[style*="position: sticky"], [style*="position:fixed"]').forEach(el => {
if (el.style.top === '0px') el.style.display = 'none'
})
})
}
4. Session setup is the bottleneck — solve it first
The biggest test setup time was login. With window.__E2E_SESSION, setup dropped from ~20s to ~100ms. This transformed the DX — tests that used to take 2 minutes to set up now run in seconds.
5. Test mobile first
Samuel (yes, me) accesses Dogwalk more from his phone. The lesson: always test mobile before desktop. The Playwright project has mobile-chrome as the third project, and the preference is to write the test with 375x844 viewport before 1280x800.
Practical results
| Metric | Before | After |
|---|---|---|
| Deploy confidence | Low (manual testing) | High (CI blocks on failure) |
| Feedback time | Days (user reported) | Minutes (CI notifies) |
| Undetected regressions | Constant | Rare |
| E2E test setup | ~20s (UI login) | ~100ms (mock session) |
| Total E2E duration | ~3min | ~45s (parallel) |
| Test maintenance | N/A | ~10% of development time |
Next steps
The E2E tests are a solid foundation, but there’s still room to grow:
- GitHub Actions E2E matrix: run chromium + firefox + mobile in parallel
- Visual regression testing: capture screenshots and compare with baseline
- Dedicated API E2E: test backend directly without mock (in addition to mock tests)
- Walker flow coverage: currently we have more tutor tests than walker tests
- Real payment tests: use Stripe test mode in an isolated environment
In the end, the biggest change wasn’t technical — it was mental. Before, deploy was anxiety. Now, it’s a process. CI says: “go ahead, I tested it.” And when it says it’s green, I trust it.
The journey is documented as it happens. This post is a record of a phase where Dogwalk stopped being a prototype and started behaving like a real product.