Structuring Your Tests

Organize tests for maintainability and clarity

9 min readNode.js

Structuring Your Tests

In the previous tutorial, we conquered navigation and waiting. Now let's talk about something that'll save your sanity as your test suite grows.

You've written a few tests. They work. But soon you'll have 50, then 200. Without good organization, you'll spend more time finding and fixing tests than writing them. Let's fix that.

The Anatomy of a Test

Every Playwright test follows this structure:

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

test('descriptive name of what this tests', async ({ page }) => {
  // Arrange - set up the test
  await page.goto('/login');

  // Act - do the thing
  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('password123');
  await page.getByRole('button', { name: 'Sign in' }).click();

  // Assert - verify it worked
  await expect(page).toHaveURL('/dashboard');
  await expect(page.getByText('Welcome back')).toBeVisible();
});

The Arrange-Act-Assert pattern keeps tests readable. Sometimes the arrange step is implicit (just visiting a page).

Grouping Tests with describe

Use test.describe() to group related tests:

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

test.describe('Login page', () => {
  test('successful login redirects to dashboard', async ({ page }) => {
    await page.goto('/login');
    await page.getByLabel('Email').fill('user@example.com');
    await page.getByLabel('Password').fill('password123');
    await page.getByRole('button', { name: 'Sign in' }).click();
    
    await expect(page).toHaveURL('/dashboard');
  });

  test('invalid credentials shows error', async ({ page }) => {
    await page.goto('/login');
    await page.getByLabel('Email').fill('user@example.com');
    await page.getByLabel('Password').fill('wrongpassword');
    await page.getByRole('button', { name: 'Sign in' }).click();
    
    await expect(page.getByText('Invalid credentials')).toBeVisible();
  });

  test('empty form shows validation errors', async ({ page }) => {
    await page.goto('/login');
    await page.getByRole('button', { name: 'Sign in' }).click();
    
    await expect(page.getByText('Email is required')).toBeVisible();
    await expect(page.getByText('Password is required')).toBeVisible();
  });
});

Nested describe Blocks

For sub-categories:

test.describe('User settings', () => {
  test.describe('Profile tab', () => {
    test('can update display name', async ({ page }) => { /* ... */ });
    test('can update avatar', async ({ page }) => { /* ... */ });
  });

  test.describe('Security tab', () => {
    test('can change password', async ({ page }) => { /* ... */ });
    test('can enable 2FA', async ({ page }) => { /* ... */ });
  });
});

Setup and Teardown Hooks

beforeEach — Run Before Each Test

Perfect for common setup:

test.describe('Dashboard', () => {
  test.beforeEach(async ({ page }) => {
    // Every test in this describe starts logged in
    await page.goto('/login');
    await page.getByLabel('Email').fill('user@example.com');
    await page.getByLabel('Password').fill('password123');
    await page.getByRole('button', { name: 'Sign in' }).click();
    await expect(page).toHaveURL('/dashboard');
  });

  test('shows welcome message', async ({ page }) => {
    // Already logged in from beforeEach
    await expect(page.getByText('Welcome back')).toBeVisible();
  });

  test('displays user stats', async ({ page }) => {
    // Already logged in from beforeEach
    await expect(page.getByTestId('stats-widget')).toBeVisible();
  });
});

afterEach — Run After Each Test

Useful for cleanup:

test.describe('File uploads', () => {
  test.afterEach(async ({ page }) => {
    // Clean up uploaded files after each test
    await page.goto('/settings/files');
    await page.getByRole('button', { name: 'Delete all test files' }).click();
  });

  test('can upload image', async ({ page }) => { /* ... */ });
  test('can upload document', async ({ page }) => { /* ... */ });
});

beforeAll & afterAll — Run Once Per Group

For expensive setup that can be shared:

test.describe('Reports', () => {
  test.beforeAll(async ({ browser }) => {
    // Create test data once before all tests
    const page = await browser.newPage();
    await page.goto('/admin/seed-test-data');
    await page.getByRole('button', { name: 'Create 100 orders' }).click();
    await page.waitForSelector('text=Done');
    await page.close();
  });

  test.afterAll(async ({ browser }) => {
    // Clean up test data after all tests
    const page = await browser.newPage();
    await page.goto('/admin/cleanup');
    await page.getByRole('button', { name: 'Delete test orders' }).click();
    await page.close();
  });

  test('monthly report shows correct totals', async ({ page }) => { /* ... */ });
  test('can export to CSV', async ({ page }) => { /* ... */ });
});

Important: beforeAll and afterAll don't get page — they get browser. Create your own page if needed.

Test Isolation

"Can my tests share data with each other?"

No! Every test should be independent. It shouldn't rely on another test running first, and it shouldn't leave behind state that affects other tests.

Why Isolation Matters

// āŒ BAD - Tests depend on each other
test('create product', async ({ page }) => {
  await page.goto('/products/new');
  await page.getByLabel('Name').fill('Test Product');
  await page.getByRole('button', { name: 'Save' }).click();
  // Product created... next test depends on this
});

test('edit product', async ({ page }) => {
  await page.goto('/products');
  // This fails if 'create product' didn't run first!
  await page.getByText('Test Product').click();
  // ...
});
// āœ… GOOD - Each test is self-contained
test('can edit existing product', async ({ page }) => {
  // Create the product this test needs
  await page.goto('/products/new');
  await page.getByLabel('Name').fill('Test Product');
  await page.getByRole('button', { name: 'Save' }).click();
  
  // Now test editing
  await page.goto('/products');
  await page.getByText('Test Product').click();
  await page.getByLabel('Name').fill('Updated Product');
  await page.getByRole('button', { name: 'Save' }).click();
  
  await expect(page.getByText('Updated Product')).toBeVisible();
});

Playwright's Isolation

By default, each test gets a fresh browser context. Cookies, localStorage, everything — wiped clean. This is good! How cool is that?

test('test 1 sets localStorage', async ({ page }) => {
  await page.goto('/app');
  await page.evaluate(() => localStorage.setItem('key', 'value'));
});

test('test 2 cannot see localStorage from test 1', async ({ page }) => {
  await page.goto('/app');
  const value = await page.evaluate(() => localStorage.getItem('key'));
  console.log(value); // null - it's a fresh context
});

Naming Conventions

Good test names describe what and when:

// āŒ Vague names
test('login test', async ({ page }) => { /* ... */ });
test('works', async ({ page }) => { /* ... */ });
test('test1', async ({ page }) => { /* ... */ });

// āœ… Descriptive names
test('shows validation error when email is empty', async ({ page }) => { /* ... */ });
test('redirects to dashboard after successful login', async ({ page }) => { /* ... */ });
test('displays item count badge when cart has items', async ({ page }) => { /* ... */ });

Patterns That Work

// Action + Expected result
test('clicking Add to Cart increases item count', async ({ page }) => {});

// Condition + Expected behavior
test('logged-out user is redirected to login page', async ({ page }) => {});

// Feature + Scenario
test('search filters by category', async ({ page }) => {});
test('search shows "no results" when query has no matches', async ({ page }) => {});

Organizing Files

By Feature (Recommended)

tests/
ā”œā”€ā”€ auth/
│   ā”œā”€ā”€ login.spec.ts
│   ā”œā”€ā”€ logout.spec.ts
│   ā”œā”€ā”€ password-reset.spec.ts
│   └── signup.spec.ts
ā”œā”€ā”€ cart/
│   ā”œā”€ā”€ add-to-cart.spec.ts
│   ā”œā”€ā”€ checkout.spec.ts
│   └── remove-item.spec.ts
ā”œā”€ā”€ products/
│   ā”œā”€ā”€ search.spec.ts
│   ā”œā”€ā”€ filter.spec.ts
│   └── product-page.spec.ts
└── user/
    ā”œā”€ā”€ profile.spec.ts
    └── settings.spec.ts

With Shared Helpers

tests/
ā”œā”€ā”€ fixtures/
│   ā”œā”€ā”€ auth.ts        # Authentication helpers
│   └── database.ts    # Database seeding
ā”œā”€ā”€ pages/
│   ā”œā”€ā”€ LoginPage.ts   # Page objects
│   └── DashboardPage.ts
ā”œā”€ā”€ auth/
│   └── login.spec.ts
└── products/
    └── search.spec.ts

Config for Test Directory

// playwright.config.ts
export default defineConfig({
  testDir: './tests',
  // Or multiple directories
  // testDir: ['./tests/e2e', './tests/integration'],
});

Skipping and Focusing Tests

Skip a Test

"How do I skip a test that isn't ready yet?"

test.skip('feature not implemented yet', async ({ page }) => {
  // This test won't run
});

// Conditional skip
test('admin features', async ({ page }) => {
  test.skip(process.env.USER_ROLE !== 'admin', 'Only for admin users');
  // ...
});

Focus on a Test (Debugging)

test.only('just debug this one', async ({ page }) => {
  // Only this test runs in the file
});

test.describe.only('just this describe block', () => {
  test('runs', async ({ page }) => {});
  test('also runs', async ({ page }) => {});
});

Warning: Don't commit .only — you'll skip all other tests! Seriously, dude, don't do it.

Mark as Failing (Known Bug)

test.fail('known bug #123', async ({ page }) => {
  // Test runs but passes if it fails (and fails if it passes)
  // Good for known issues you've reported
});

Slow Tests

test.slow('this test takes a while', async ({ page }) => {
  // Triples the timeout
});

Tagging Tests

Use @ tags in test names or describe blocks:

test('checkout flow @smoke', async ({ page }) => { /* ... */ });
test('complex report generation @slow', async ({ page }) => { /* ... */ });

test.describe('admin features @admin', () => {
  test('can view all users', async ({ page }) => { /* ... */ });
});

Run specific tags:

# Run only smoke tests
npx playwright test --grep @smoke

# Run everything except slow tests
npx playwright test --grep-invert @slow

# Combine: smoke tests that aren't slow
npx playwright test --grep @smoke --grep-invert @slow

Configuring Tests

Test-Level Configuration

test.describe('mobile tests', () => {
  test.use({
    viewport: { width: 375, height: 667 },
    isMobile: true,
  });

  test('responsive menu works', async ({ page }) => { /* ... */ });
});

Parallel vs Serial

// Run tests in this describe in order (not parallel)
test.describe.serial('sequential checkout flow', () => {
  test('step 1: add to cart', async ({ page }) => { /* ... */ });
  test('step 2: enter shipping', async ({ page }) => { /* ... */ });
  test('step 3: payment', async ({ page }) => { /* ... */ });
});

// Run tests in this describe in parallel (default)
test.describe.parallel('independent tests', () => {
  test('test A', async ({ page }) => { /* ... */ });
  test('test B', async ({ page }) => { /* ... */ });
});

Note: .serial tests share state, which is usually bad. Use sparingly — only for flows that genuinely must be sequential.

Retries

// Retry flaky tests
test.describe('flaky third-party integration', () => {
  test.describe.configure({ retries: 2 });

  test('payment provider sometimes times out', async ({ page }) => { /* ... */ });
});

What's Next?

You now know how to keep your tests organized and maintainable:

  • test.describe() groups related tests
  • beforeEach / afterEach for per-test setup/cleanup
  • beforeAll / afterAll for one-time setup
  • Keep tests independent — no shared state
  • Descriptive names: "action + expected result"
  • Organize files by feature
  • Use .skip, .only, .fail for test control
  • Tags (@smoke, @slow) for filtering

Hooks are great, but fixtures are better. Learn about Playwright's fixture system to make setup and teardown cleaner and more reusable. Let's go!