Back to blog
Tutorial

Frontend testing: minimum that works

By Flávio Emanuel · · 7 min read

I skipped testing for years. Thought it was for big places, agencies with 100 devs. Then I lost a client because the site broke and nobody caught it until they complained.

Started testing. But Cypress felt too heavy, Jest too slow. Manual testing took forever.

Then I found Vitest plus Playwright. Quick setup, tests ran fast, and crucially: less code to write.

Testing doesn’t need to be perfect. It needs to be safe. You sleep knowing your forms work.

Why solo devs skip testing

Time. Short deadlines, you focus on features. Testing gets pushed to later and never happens.

Habit gap. Bootcamps don’t teach it right. You learn manual testing instead.

False choice. “Either I test or I ship fast.” But tests save time later. One good test is worth 10 hours of debugging when you need to change code.

Starting sucks. Configuration, syntax, first time is annoying.

Answer: start small. Don’t test everything. Test what matters.

What to test, what not to test

DON’T test CSS pixel-perfect. The browser does that.

DON’T test libraries. Shadcn comes tested. Tailwind too.

DON’T test third-party integration tightly. Don’t call real APIs in tests. Mock them.

DO test forms. If forms break, users can’t sign up.

DO test navigation. If routes break, users get lost.

DO test data integration. If Supabase integration breaks, the site doesn’t work.

DO test logic. Calculations, validations, data transforms.

Vitest for unit tests

Vitest is Jest but fast. Much faster. Setup is minimal with Astro.

npm install -D vitest @testing-library/react happy-dom

vitest.config.ts:

import { getViteConfig } from 'astro/config'

export default getViteConfig({
  test: {
    globals: true,
    environment: 'happy-dom'
  }
})

Done. Vitest is running.

Simple validation test:

import { describe, it, expect } from 'vitest'
import { validateEmail } from './validate'

describe('validateEmail', () => {
  it('should accept valid email', () => {
    expect(validateEmail('user@example.com')).toBe(true)
  })

  it('should reject invalid email', () => {
    expect(validateEmail('invalid')).toBe(false)
  })

  it('should reject empty', () => {
    expect(validateEmail('')).toBe(false)
  })
})

Run npm run test. It’s testing.

Component test:

import { render, screen } from '@testing-library/react'
import { Button } from './Button'

describe('Button', () => {
  it('renders with text', () => {
    render(<Button>Click me</Button>)
    expect(screen.getByText('Click me')).toBeInTheDocument()
  })

  it('calls onClick when clicked', () => {
    const onClick = vi.fn()
    render(<Button onClick={onClick}>Click me</Button>)
    screen.getByText('Click me').click()
    expect(onClick).toHaveBeenCalled()
  })
})

Testing the component. If it breaks, the test fails.

Playwright for e2e

End-to-end is real testing. You open a browser, click around, test the flow.

npm install -D @playwright/test
npx playwright install

playwright.config.ts:

import { defineConfig } from '@playwright/test'

export default defineConfig({
  webServer: {
    command: 'npm run dev',
    port: 3000,
    reuseExistingServer: !process.env.CI,
  },
  testDir: './e2e',
  use: {
    baseURL: 'http://localhost:3000',
  },
})

Basic test:

import { test, expect } from '@playwright/test'

test('appointment form submission', async ({ page }) => {
  await page.goto('/appointment')

  await page.fill('input[name="name"]', 'John Doe')
  await page.fill('input[name="email"]', 'john@example.com')
  await page.fill('input[name="date"]', '2026-05-01')

  await page.click('button:has-text("Schedule")')

  await expect(page).toHaveURL('/confirmation')
  await expect(page.locator('h1')).toContainText('Success')
})

Ran it? Your form works.

Navigation test:

test('navigation between pages', async ({ page }) => {
  await page.goto('/')

  await page.click('a:has-text("Services")')
  await expect(page).toHaveURL('/services')

  await page.click('a:has-text("Contact")')
  await expect(page).toHaveURL('/contact')
})

Practical setup in 20 minutes per project

Don’t test everything. Test:

  1. Main form (contact, appointment)
  2. Navigation
  3. Critical integration (if pulling from Supabase, test it)

That’s 5-7 e2e tests. Takes 20 minutes to write.

Run before deploying. If something breaks, you see it.

package.json:

{
  "scripts": {
    "test": "vitest",
    "test:e2e": "playwright test",
    "test:ci": "vitest run && playwright test"
  }
}

Why this saves time

You finish a project. Send to client. They use it. Two months later you need to add a feature.

Without tests, you change code and break something invisible. You find out when they complain.

With tests, you run the suite. If something broke, you see it before they do.

One good test takes 15 minutes to write and saves 5 hours of debugging later.

Continuous integration

If you’re on GitHub, add testing to CI.

.github/workflows/test.yml:

name: Test

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
      - run: npm ci
      - run: npm run test:ci

Now every push runs tests. If something breaks, the PR tells you.

Reality

You don’t need 100% coverage. You need confidence to change code.

If you’re losing sleep worrying the site might break any time, start testing.

If you’re happy shipping without testing, either your project is super simple or you’re lucky. Luck runs out.

Read also: MVP from zero to deploy | Ship projects fast with AI | Supabase + React

  • Install Vitest and configure with Astro
  • Create validation tests (email, data)
  • Test critical components (Button, Form)
  • Install and configure Playwright
  • Write e2e tests for form submission
  • Test main navigation
  • Run tests before deploying

Testing isn’t luxury. It’s peace of mind to sleep.

Why you forget tests at first

You learn to code, nobody forces you to test. Bootcamp? Yes, testing lesson. But in practice, you only ship when feature is done. Testing gets pushed aside.

After you lose a client because the site broke, you change your mind.

But it has high cognitive cost. You’re tired from coding. Writing tests is more code. Brain says “later”.

Later never comes.

Answer: start now. Not with 100% coverage. Start with the 10% that matters.

How your workflow changes with tests

Before: write feature, test manually, deploy After: write feature, run test suite, see it works, deploy

Second flow takes 5 minutes more. But saves hours of debugging six months later when client asks for a change.

You deploy for your client every week. If 1 in 10 deploys breaks something invisible, you discover expensively. With tests, discover cheaply.

Scaling tests across multiple projects

Started with two e2e tests. Project grows. Add more routes, more forms. Soon have 20 tests.

Playwright handles it. Takes 45 seconds to run entire suite. Stays fast.

Now have three active projects. Each with 15-20 tests. Run everything in CI, PR doesn’t merge if tests fail. Peace.

Tests as documentation

A good test is living documentation. Someone who never saw your code reads your test and understands the flow.

test('user can schedule appointment', async ({ page }) => {
  // User goes to scheduling page
  await page.goto('/schedule')

  // Fills name
  await page.fill('input[name="name"]', 'Maria Silva')

  // Selects available date
  await page.click('button[data-date="2026-05-15"]')

  // Confirms appointment
  await page.click('text=Confirm Appointment')

  // Should redirect to confirmation page
  await expect(page).toHaveURL('/confirmation')
})

Test is a story of what the user does. Automatic documentation.

Important metrics

Coverage is a misleading metric. 80% coverage might mean you’re covering code that doesn’t matter.

Better metric: “what’s the chance a bug reaches production?” If it’s close to zero for critical flows, you’re good.

Another good indicator: “how long to debug when something breaks?” With tests, find it fast. Without tests, breaks silently for days.

When to stop testing

Don’t test UI library. Shadcn comes tested. Don’t test packages. Zod comes tested.

Test code you write. Your logic. Your integration.

Test critical routes. Contact form, payment flow, authentication.

Leave trivial tests behind. “Button renders” is trivial.

Test things that break silently. “User data comes empty” is silent. Test it.

Reality after eight months with tests

At first, feels like wasting time. 30 minutes per project in setup plus tests.

Month two: client asked for a change. Ran tests. Test for form broke. Found bug before client saw it.

Month eight: client asked to integrate new system. Changed lots of code. Tests ran. Nothing broke. Client happy.

Bugs reaching production? Dropped 95%.

Debug time? Dropped 80%.

Confidence in refactoring? Up 1000%.

Worth it big time.

Next step

Need a dev who truly delivers?

Whether it's a one-time project, team reinforcement, or a long-term partnership. Let's talk.

Chat on WhatsApp

I reply within 2 hours during business hours.