Designing Maintainable Automated Suites
Test code is code. Learn how to structure, name, review, and own automated test suites so they stay useful instead of becoming technical debt.
This post is part of the Automation Strategy series. If you missed the previous post, read Part 4 — Test Data Strategies first.
The test suite that took three months to write becomes unmaintainable in six.
Tests that were clear when written are incomprehensible six months later. Folder structures that made sense for 20 tests collapse under 200. Naming conventions drift. Nobody knows who owns the flaky test that’s been failing for two weeks.
Test code is code. It requires the same discipline as production code: clear structure, consistent naming, ownership, and review. Without that discipline, your test suite becomes technical debt — slow, brittle, and ignored.
In this post, we’ll cover how to design test suites that stay maintainable as they grow: folder structure, naming conventions, layering, ownership, and code review practices for tests.
Principle #1: Organise by Feature, Not by Test Type
Most teams start with a structure organised by test type:
tests/
├── unit/
├── integration/
└── e2e/
This breaks down as the codebase grows. When a feature changes, you need to hunt through three folders to find all related tests. When a developer works on checkout, they don’t know if tests exist in unit/, integration/, or e2e/.
Better: Organise by Feature
tests/
├── authentication/
│ ├── login.spec.ts (E2E)
│ ├── password-reset.spec.ts (E2E)
│ ├── jwt-validation.test.ts (unit)
│ └── session-api.test.ts (integration)
├── checkout/
│ ├── place-order.spec.ts
│ ├── discount-logic.test.ts
│ └── payment-gateway.test.ts
└── user-profile/
├── update-profile.spec.ts
└── profile-validation.test.ts
Now when checkout logic changes, all related tests are in one place. Developers know where to look. Test ownership becomes clear.
:::tip[Mirror Your Codebase Structure]
If your app has src/features/checkout/, create tests/checkout/. This makes tests discoverable and ties them to the code they validate.
:::
Handling Cross-Cutting Concerns
Some tests don’t map to a single feature (e.g., performance tests, security tests, smoke tests). Use top-level folders for these:
tests/
├── authentication/
├── checkout/
├── smoke/ ← Critical path tests run on every deploy
├── performance/ ← Load/stress tests
└── security/ ← Auth bypass, injection, CSRF tests
Principle #2: Use Consistent, Descriptive Naming
Test names are documentation. A good test name tells you:
- What is being tested
- What scenario or condition
- What the expected outcome is
Bad Test Names
test('test1', async () => { /* ... */ });
test('checkout works', async () => { /* ... */ });
test('bug fix', async () => { /* ... */ });
These names tell you nothing. When they fail, you have no idea what broke without reading the implementation.
Good Test Names
test('guest user can complete checkout with card payment', async () => { /* ... */ });
test('checkout fails when payment is declined', async () => { /* ... */ });
test('discount code is applied before tax calculation', async () => { /* ... */ });
Now when a test fails, the name gives you immediate context.
Naming Convention
Use this pattern:
[Actor] [Action] [Context/Condition] → [Expected Outcome]
Examples:
admin can delete user from user management pageorder fails when inventory is insufficientpassword reset email is sent within 60 seconds
File Naming
Be consistent:
- E2E tests:
*.spec.ts(Playwright convention) - Unit/integration tests:
*.test.ts - Test helpers:
*.helpers.ts
This makes it easy to run specific test types in CI:
# Run only E2E tests
npx playwright test **/*.spec.ts
# Run only unit/integration tests
npm test -- **/*.test.ts
Principle #3: Separate Layers — Don’t Mix E2E and Unit Tests
Mixing test types in the same file or folder leads to confusion and slow test runs.
Bad: Mixed Layers
// checkout.test.ts — mixing unit and E2E
test('calculateDiscount returns correct value', () => {
expect(calculateDiscount(100, 0.1)).toBe(10); // Unit test
});
test('user can apply discount at checkout', async ({ page }) => {
// E2E test
await page.goto('/checkout');
// ...
});
This file mixes fast unit tests (milliseconds) with slow E2E tests (seconds). Running the file takes seconds even though most of it could run in milliseconds.
Good: Separate Layers
tests/checkout/
├── discount-logic.test.ts ← Unit tests (fast)
├── checkout-api.test.ts ← Integration tests (medium)
└── place-order.spec.ts ← E2E tests (slow)
Now you can run only the fast tests during development and reserve E2E for CI.
Principle #4: Extract Reusable Helpers, Don’t Copy-Paste
Duplication in tests is as bad as duplication in production code. When the login flow changes, you shouldn’t have to update 50 tests.
Bad: Duplicated Setup
test('user can view order history', async ({ page }) => {
await page.goto('/login');
await page.fill('[name=email]', 'test@example.com');
await page.fill('[name=password]', 'Test1234!');
await page.click('button[type=submit]');
await expect(page).toHaveURL('/dashboard');
// ...test logic
});
test('user can update profile', async ({ page }) => {
await page.goto('/login');
await page.fill('[name=email]', 'test@example.com');
await page.fill('[name=password]', 'Test1234!');
await page.click('button[type=submit]');
await expect(page).toHaveURL('/dashboard');
// ...test logic
});
Now if the login form changes (e.g., adds a CAPTCHA), you have to update both tests.
Good: Extract Helper
// helpers/auth.helpers.ts
export async function loginAs(page: Page, email: string, password: string) {
await page.goto('/login');
await page.fill('[name=email]', email);
await page.fill('[name=password]', password);
await page.click('button[type=submit]');
await expect(page).toHaveURL('/dashboard');
}
// Tests
test('user can view order history', async ({ page }) => {
await loginAs(page, 'test@example.com', 'Test1234!');
// ...test logic
});
test('user can update profile', async ({ page }) => {
await loginAs(page, 'test@example.com', 'Test1234!');
// ...test logic
});
Now when the login flow changes, update one helper, not 50 tests.
Folder Structure for Helpers
tests/
├── helpers/
│ ├── auth.helpers.ts
│ ├── data.helpers.ts ← createUser, createOrder factories
│ └── assertions.helpers.ts
├── authentication/
│ └── login.spec.ts
└── checkout/
└── place-order.spec.ts
Principle #5: Use Page Object Model (POM) for E2E Tests
Page Object Model abstracts UI selectors and interactions into reusable classes. When UI changes, you update one page object, not dozens of tests.
Without POM
test('user can add product to cart', async ({ page }) => {
await page.goto(`/products/${productId}`);
await page.click('button[data-testid="add-to-cart"]');
await expect(page.locator('.cart-count')).toHaveText('1');
});
If the selector .cart-count changes to .shopping-cart-badge, this test breaks.
With POM
// pages/product.page.ts
export class ProductPage {
constructor(private page: Page) {}
async goto(productId: string) {
await this.page.goto(`/products/${productId}`);
}
async addToCart() {
await this.page.click('button[data-testid="add-to-cart"]');
}
async getCartCount() {
return this.page.locator('.cart-count');
}
}
// Test
test('user can add product to cart', async ({ page }) => {
const productPage = new ProductPage(page);
await productPage.goto(productId);
await productPage.addToCart();
await expect(productPage.getCartCount()).toHaveText('1');
});
Now when .cart-count changes, update ProductPage.getCartCount() once. All tests using it remain unchanged.
:::info[POM is Optional for Small Suites] If you have < 20 E2E tests, POM might be overkill. Use helpers instead. Once you hit 50+ tests touching the same pages, POM becomes valuable. :::
Principle #6: Assign Ownership — Don’t Let Tests Become Orphaned
Every test suite needs an owner. Without ownership, tests decay:
- Flaky tests are ignored instead of fixed
- Nobody refactors brittle selectors
- New tests are added without review
How to Assign Ownership
Option 1: Team Ownership
Tests map to feature teams. The checkout team owns tests/checkout/. The auth team owns tests/authentication/.
Option 2: Named Owner in Metadata
/**
* @owner team-checkout
* @contact checkout-team@company.com
*/
describe('Checkout flow', () => {
test('guest user can complete checkout', async () => { /* ... */ });
});
When a test fails, CI can tag the owning team automatically.
Option 3: CODEOWNERS File
GitHub/GitLab support CODEOWNERS for automatic PR review assignment:
# .github/CODEOWNERS
tests/checkout/** @checkout-team
tests/authentication/** @auth-team
Now when someone modifies tests in tests/checkout/, the checkout team is automatically requested for review.
Principle #7: Review Test Code Like Production Code
Test code is code. It should go through the same review process as production code.
Code Review Checklist for Tests
When reviewing a PR with test changes, check:
- Test names are descriptive — Can I tell what’s being tested without reading the implementation?
- No hardcoded data — Are factories or helpers used for test data?
- No duplication — If setup logic is repeated, extract it.
- Appropriate layer — Is this test at the right level (unit/integration/E2E)?
- Stable selectors — Are selectors using
data-testid, roles, or labels (not brittle CSS)? - No hardcoded waits — Are
waitForTimeoutcalls replaced with condition-based waits? - Cleanup included — Does the test clean up data it creates?
- Test actually validates something — Does the test have assertions? Does it fail when the feature breaks?
:::warning[Don’t Merge Failing Tests] If a test is flaky or broken, fix it before merging. Don’t merge with “TODO: fix flaky test” comments. Flaky tests erode trust immediately. :::
Principle #8: Track Test Health Metrics
You can’t manage what you don’t measure. Track key metrics to catch decay before it’s too late.
Key Metrics
| Metric | Target | What It Measures |
|---|---|---|
| Flake rate | < 2% | % of test runs with at least one flaky test |
| Average runtime | < 10 min (E2E), < 2 min (unit+integration) | Speed of feedback |
| Test coverage | > 80% on critical paths | Risk coverage |
| Failure investigation time | < 10 min per failure | How easy it is to debug failures |
How to Track
- Flake rate: Most CI systems can track this. Playwright has built-in flaky test detection.
- Runtime: CI job duration. Graph it over time to detect slowdown.
- Coverage: Use coverage tools (Istanbul, Coverlet) but measure risk coverage (are critical paths tested?) not line coverage.
- Investigation time: Manual observation. If tests frequently fail for unclear reasons, structure needs improvement.
Recommended Folder Structure
Putting it all together:
tests/
├── helpers/
│ ├── auth.helpers.ts ← Login, logout, session helpers
│ ├── data.helpers.ts ← Factories for users, orders, products
│ └── assertions.helpers.ts ← Custom assertions
├── pages/ ← Page Object Model (E2E only)
│ ├── login.page.ts
│ ├── product.page.ts
│ └── checkout.page.ts
├── authentication/
│ ├── login.spec.ts ← E2E
│ ├── password-reset.spec.ts
│ ├── jwt-validation.test.ts ← Unit
│ └── session-api.test.ts ← Integration
├── checkout/
│ ├── place-order.spec.ts
│ ├── discount-logic.test.ts
│ └── payment-gateway.test.ts
├── smoke/ ← Critical path tests
│ └── critical-paths.spec.ts
└── playwright.config.ts ← Test configuration
Anti-Patterns to Avoid
Anti-Pattern #1: Tests That Test the Test Framework
// Bad: testing Playwright, not your app
test('page loads', async ({ page }) => {
await page.goto('/');
expect(page).toBeTruthy();
});
This tests that Playwright can load a page, not that your app works.
Anti-Pattern #2: Tests Without Assertions
// Bad: no validation
test('user can submit form', async ({ page }) => {
await page.goto('/contact');
await page.fill('[name=email]', 'test@example.com');
await page.click('button[type=submit]');
// No assertion — did it work?
});
Tests without assertions are noise. Always validate the expected outcome.
Anti-Pattern #3: God Tests (One Test Does Everything)
// Bad: 200-line test covering login, profile update, checkout, logout
test('complete user journey', async ({ page }) => {
// 200 lines of test code
});
When this test fails, you have no idea which part broke. Split it into focused tests.
Anti-Pattern #4: Commented-Out Tests
// test('admin can delete user', async () => {
// // TODO: fix flaky test
// });
Commented tests rot. Either fix them or delete them. Don’t let them linger.
Conclusion
Test suites become unmaintainable the same way codebases do: poor structure, unclear naming, no ownership, and no review discipline.
The fix is to treat test code with the same respect as production code:
- Organise by feature, not test type
- Use descriptive naming — test names are documentation
- Separate layers — don’t mix unit and E2E tests
- Extract reusable helpers — no duplication
- Use Page Object Model for E2E tests (when you hit scale)
- Assign ownership — every test needs a maintainer
- Review test code — apply the same standards as production code
- Track health metrics — flake rate, runtime, coverage
Apply these principles from day one and your test suite will stay useful instead of becoming technical debt.
In the next post, we’ll tackle flaky tests in depth — a taxonomy of root causes, systematic fixes, and how to build a team process for preventing and triaging flakes.
Action for this week: Pick one folder in your test suite with poor structure or naming. Refactor it: rename files descriptively, extract duplicated setup into helpers, and add a CODEOWNERS entry for ownership. Run the tests to confirm nothing breaks. Commit the refactor as a standalone change.