
Dogwalk matures — Playwright tests as the quality backbone
Context
Dogwalk was born as an MVP — minimum, viable, and without tests. It’s part of the game. When you’re validating an idea, testing is a luxury. But when the marketplace starts moving real money (via Stripe Connect), when tutors trust their pets’ data, when walkers depend on the platform for their schedule — then testing becomes a necessity, not a luxury.
This post covers the journey from 0 → 853 tests, what I learned along the way, and how Playwright became the quality backbone of Dogwalk.
Phase 1: The chaos without tests
In the beginning it was just React + Vite, rapid prototyping. Zero tests. Zero types. Zero guarantees.
The flow was: write a feature, open localhost:5173, click manually, see if it broke. It worked while the app had 3 pages. When it passed 10 pages + authentication + Stripe + scheduling, it no longer cut it.
// Real example: the first test I wrote — validate the app renders without crash
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import App from './App';
describe('App', () => {
it('renders without crashing', () => {
render(<App />);
expect(screen.getByText(/dogwalk/i)).toBeDefined();
});
});
Naive? Yes. But it was the first step.
Phase 2: Vitest on critical components
I started with the core: authentication components, scheduling forms, price calculations (Stripe commission + walker fee).
// Commission calculation test — money can't be wrong
describe('CommissionCalculator', () => {
it('calculates 15% platform fee correctly', () => {
const result = calculateCommission(100.00);
expect(result.platformFee).toBe(15.00);
expect(result.walkerEarns).toBe(85.00);
expect(result.total).toBe(100.00);
});
it('handles Stripe Connect fee + platform fee stacking', () => {
const result = calculateWithStripe(85.00, 'connect');
// Stripe Connect: 2.9% + R$0.49
expect(result.stripeFee).toBeCloseTo(2.96, 1);
expect(result.walkerNet).toBeCloseTo(82.04, 1);
});
it('throws on negative values', () => {
expect(() => calculateCommission(-50)).toThrow();
});
});
This phase was productive — 342 Vitest tests covering:
- UI components (rendering, interaction, loading/empty/error states)
- Custom hooks (useAuth, useStripe, useSchedule)
- Utilities (formatting, calculation, validation)
- State stores (Zustand)
Execution time: 12s. Fast enough to run before each commit.
Phase 3: Playwright E2E — the game changer
Unit tests guarantee the component works. But they don’t guarantee the entire flow runs. An authentication error that only appears when the user logs in, schedules a walk, and tries to pay — no unit test catches that.
That’s where I turned to Playwright.
// E2E test: full booking flow
import { test, expect } from '@playwright/test';
test('tutor books full walk: login → search → schedule → pay', async ({ page }) => {
// Login
await page.goto('/login');
await page.fill('[data-testid="email"]', 'tutor@teste.com');
await page.fill('[data-testid="password"]', 'senha_teste');
await page.click('[data-testid="login-btn"]');
await expect(page.locator('[data-testid="dashboard"]')).toBeVisible();
// Search walker
await page.goto('/search');
await page.fill('[data-testid="search-input"]', 'Pinheiros');
await page.click('[data-testid="search-btn"]');
await page.locator('[data-testid="walker-card"]').first().click();
// Schedule
await page.fill('[data-testid="date-input"]', '2026-03-15');
await page.fill('[data-testid="time-input"]', '14:00');
await page.click('[data-testid="schedule-btn"]');
await expect(page.locator('[data-testid="booking-confirmed"]')).toBeVisible();
// Payment
await page.click('[data-testid="pay-btn"]');
await page.fill('[data-testid="card-number"]', '4242424242424242');
await page.fill('[data-testid="card-expiry"]', '12/28');
await page.fill('[data-testid="card-cvc"]', '123');
await page.click('[data-testid="confirm-payment"]');
await expect(page.locator('[data-testid="payment-success"]')).toBeVisible();
});
This single test already replaced 10 minutes of manual testing.
What the E2E tests cover (511 tests):
| Group | Tests | What it tests |
|---|---|---|
| Auth | 42 | Login, registration, OAuth, refresh, logout, expired session |
| Dashboard | 38 | Tutor/walker cards, metrics, health checks |
| Search | 55 | Search, filters, geolocation, empty states |
| Scheduling | 67 | Schedule, conflict, cancellation, re-scheduling |
| Payment | 73 | Stripe Connect, credit card, PIX, split payments, refund |
| Profile | 41 | Tutor editing, walker editing, photo, address |
| Admin | 29 | Admin panel, users, transactions, reports |
| Notifications | 23 | Push, email, in-app, preferences |
| Mobile | 143 | Responsive 360px, touch targets, bottom nav, swipes |
Real metrics
| Metric | Without tests | With Vitest | With Playwright |
|---|---|---|---|
| Execution time | N/A | 12s | 4min 23s |
| Estimated coverage | 0% | ~45% | ~85% flows |
| Bugs in production | ~8/month | ~3/month | 0-1/month |
| Regression per release | 100% | ~40% | ~5% |
| Deploy confidence | Low | Medium | High |
| CI duration | 30s | 45s | 6min 12s |
The cost in CI time (6 minutes vs 30 seconds) is real. But the gain in confidence is worth it — especially in flows involving money.
Lessons from the trenches
1. Data-testid saves your life
At first I tried selecting by text, CSS class, placeholder. Everything broke at the slightest refactor. Once I standardized data-testid on every interactive element, the tests became resilient.
// Before: broke if the text changed
<button className="btn-primary">Schedule walk</button>
// After: never breaks
<button data-testid="schedule-btn">Schedule walk</button>
2. Slow E2E tests don’t get run
If the test takes 6 minutes, the developer doesn’t run it locally. Solution: separate into smoke tests (1 minute, run always) and full suite (6 minutes, only on CI and before release).
# Smoke — fast, runs locally
npx playwright test --grep @smoke
# Full — complete, CI only
npx playwright test
3. Stripe in tests is a whole separate problem
Stripe Connect requires a real card to test split payments. Solution: use stripe-cli with webhook forwarding + 4242 card in test mode. The trick is to never test in production.
# Forward Stripe test webhooks to local machine
stripe listen --forward-to localhost:5173/api/stripe/webhook
4. Mobile-first testing
Dogwalk has more mobile users than desktop. 143 mobile tests vs 368 desktop. I used test.use({ viewport: { width: 375, height: 812 } }) as default and only override when testing desktop.
5. Screenshots on every failure
Playwright takes automatic screenshots on failure. But I also added trace: 'on-first-retry' for flaky tests — the trace shows every action, network request, and console error.
// playwright.config.ts
export default defineConfig({
use: {
screenshot: 'only-on-failure',
trace: 'on-first-retry',
video: 'retain-on-failure',
},
});
The cold numbers
| Metric | Before | After |
|---|---|---|
| Total tests | 0 | 853 |
| Vitest tests | 0 | 342 |
| Playwright tests | 0 | 511 |
| Bugs in production | ~8/month | 0-1/month |
| CI duration | 30s | 6min |
| Critical flows coverage | 0% | 100% |
| Deploy confidence | “praying” | “ship it” |
What’s next
853 tests is a good number, but there are still gaps:
- Performance tests — Lighthouse CI to measure bundle impact
- Stripe Connect tests — chargeback and dispute scenarios
- Accessibility tests — axe-core integrated with Playwright
- Visual tests — snapshot comparison to detect layout regression
But for now, 853 tests, 0 critical bugs in the last 30 days. I’m satisfied.