Resource library

QA How-To

How to Structure a test automation repository (2026)

Learn how to structure a test automation repository with layered folders, Playwright config, fixtures, CI lanes, naming rules, and scalable page object libraries.

22 min read | 2,317 words

TL;DR

Structure the repo in layers: config, reusable libraries, capability-oriented tests, data, and CI lanes. Keep dependency direction clean, validate env config, and make the happy path for adding a smoke test obvious in under an hour.

Key Takeaways

  • Define suite lanes, ownership, and secrets strategy before copying a folder tree.
  • Separate config, libraries (pages, clients, fixtures), tests, data, and CI entry points.
  • Group tests by business capability and map folders or tags to CI projects.
  • Enforce dependency direction: tests depend on libraries, never the reverse.
  • Validate environment configuration centrally and fail fast on misconfig.
  • Migrate legacy suites with a strangler approach instead of a big-bang rewrite.
  • Use PR checklists and docs so structure remains a living contract.

Learning how to structure a test automation repository is about making change safe as the suite grows. A good layout reduces onboarding time, prevents circular imports, keeps CI jobs obvious, and separates product flows from low-level driver details. A bad layout turns every new hire into an archaeologist and every selector change into a multi-file scavenger hunt.

This 2026 guide is for teams on Playwright, Cypress, Selenium, or mixed stacks who want a repository shape that scales past the first 50 tests. You will get recommended directory trees, naming rules, config patterns, runnable samples, and interview answers grounded in real maintenance pain.

TL;DR

Structure the repo around layers: configuration, domain fixtures and test data, page or screen objects (or app actions), tests grouped by business capability, utilities, and CI. Keep one obvious entry command per suite lane (PR smoke, regression, API). Prefer boring conventions over clever frameworks.

Area Put here Keep out
tests/ or specs/ Executable scenarios Shared business helpers that other packages need
src/ or lib/ Pages, flows, clients, fixtures One-off assertions for a single test
data/ Fixtures, schemas, static files Secrets
config/ Environments, reporter, projects Hard-coded production credentials
.github/workflows/ or ci/ Pipeline definitions Temporary debug jobs committed forever
docs/ How to run, tags, ownership Outdated tool comparisons nobody reads

1. How to Structure a test automation repository: Design Goals

When people search how to structure a test automation repository, they often want a tree to copy. Copying a tree without goals creates cargo cult folders. Decide these first:

  1. Primary language and runner (for example Playwright Test + TypeScript).
  2. Suite lanes (PR smoke, merge regression, nightly deep, API-only).
  3. Ownership model (one QA platform team vs feature teams contributing tests).
  4. Secrets strategy (CI secrets + local .env not committed).
  5. Artifact strategy (where traces, screenshots, and reports land).

Write those five decisions in docs/ARCHITECTURE.md. Folders should express them.

2. How to Structure a test automation repository: Baseline Layout

qa-automation/
  package.json
  playwright.config.ts
  tsconfig.json
  .env.example
  README.md
  docs/
    ARCHITECTURE.md
    TAGGING.md
    DATA.md
  config/
    env.ts
    testUsers.ts
  src/
    fixtures/
      index.ts
      db.ts
    api/
      clients/
        CartClient.ts
      types/
        cart.ts
    ui/
      pages/
        LoginPage.ts
        CartPage.ts
      flows/
        checkoutFlow.ts
    utils/
      money.ts
      ids.ts
  tests/
    smoke/
      checkout.smoke.spec.ts
    regression/
      cart/
        coupon.spec.ts
      account/
        profile.spec.ts
    api/
      cart.contract.spec.ts
  data/
    fixtures/
      products.json
    schemas/
      cart.schema.json
  scripts/
    seed-local.ts
    slowest-from-report.ts
  .github/
    workflows/
      pr-smoke.yml
      nightly.yml

This is not sacred. It is a clear separation: tests consume src, src does not import tests, and CI calls named lanes.

3. Monorepo vs Standalone Test Repo

Approach Pros Cons Best when
Tests inside product monorepo Shared types, easier PRs with code Noisy checkouts, permission complexity App teams own tests beside features
Standalone automation repo Clean permissions, multi-service focus Drift from product types, extra PRs Central QA owns cross-service journeys
Hybrid (unit in app, e2e standalone) Clear ownership split Two conventions to learn Common at mid-size companies

If product types are valuable, prefer path dependencies or published client packages over copy-pasted DTO interfaces. Duplicated types rot quietly.

4. Configuration Layer That Stays Honest

Centralize environment resolution. Do not scatter process.env.BASE_URL with different defaults across files.

Runnable example: config/env.ts

// config/env.ts
import { z } from "zod";

const EnvSchema = z.object({
  BASE_URL: z.string().url(),
  API_URL: z.string().url(),
  E2E_USER_EMAIL: z.string().email(),
  E2E_USER_PASSWORD: z.string().min(8),
  CI: z
    .string()
    .optional()
    .transform((v) => v === "true" || v === "1"),
});

export type AppEnv = z.infer<typeof EnvSchema>;

export function loadEnv(raw: NodeJS.ProcessEnv = process.env): AppEnv {
  const parsed = EnvSchema.safeParse(raw);
  if (!parsed.success) {
    const details = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
    throw new Error(`Invalid test environment: ${details}`);
  }
  return parsed.data;
}
// playwright.config.ts
import { defineConfig } from "@playwright/test";
import { loadEnv } from "./config/env";

const env = loadEnv();

export default defineConfig({
  testDir: "./tests",
  timeout: 30_000,
  fullyParallel: true,
  forbidOnly: !!env.CI,
  retries: env.CI ? 1 : 0,
  reporter: env.CI ? [["github"], ["html", { open: "never" }]] : [["list"], ["html"]],
  use: {
    baseURL: env.BASE_URL,
    trace: "on-first-retry",
    screenshot: "only-on-failure",
    video: "retain-on-failure",
  },
  projects: [
    { name: "smoke", testMatch: /smoke\/.*\.spec\.ts/ },
    { name: "regression", testMatch: /regression\/.*\.spec\.ts/ },
    { name: "api", testMatch: /api\/.*\.spec\.ts/ },
  ],
});

Fail fast on missing config. A suite that "kinda runs" against the wrong URL wastes hours.

5. Tests Grouped by Business Capability, Not by Page Only

Folder-by-page (tests/login, tests/cart) is fine early. As product complexity grows, folder-by-capability or risk often scales better:

  • tests/regression/checkout/
  • tests/regression/billing/
  • tests/regression/admin-catalog/

Why: a checkout change may touch cart page, payment page, and email notifications. Reviewers think in capabilities. Tags still cross-cut:

test("expired coupon shows inline error @smoke @checkout", async ({ page }) => {
  // ...
});

Document tags in docs/TAGGING.md. CI should select by project and tag intentionally. Related reading: smoke vs sanity vs regression.

6. Page Objects, Flows, and API Clients as Libraries

Keep raw selectors out of specs when the same screen appears more than once. Prefer small page objects and flow functions over a giant inheritance tree.

Runnable example: page + flow

// src/ui/pages/CartPage.ts
import type { Page, Locator } from "@playwright/test";

export class CartPage {
  readonly checkoutButton: Locator;
  readonly couponInput: Locator;
  readonly applyCouponButton: Locator;
  readonly total: Locator;

  constructor(private readonly page: Page) {
    this.checkoutButton = page.getByRole("button", { name: "Checkout" });
    this.couponInput = page.getByLabel("Coupon code");
    this.applyCouponButton = page.getByRole("button", { name: "Apply coupon" });
    this.total = page.getByTestId("cart-total");
  }

  async goto() {
    await this.page.goto("/cart");
  }

  async applyCoupon(code: string) {
    await this.couponInput.fill(code);
    await this.applyCouponButton.click();
  }
}
// src/ui/flows/checkoutFlow.ts
import type { Page } from "@playwright/test";
import { CartPage } from "../pages/CartPage";

export async function startCheckoutWithCoupon(page: Page, code: string) {
  const cart = new CartPage(page);
  await cart.goto();
  await cart.applyCoupon(code);
  await cart.checkoutButton.click();
}
// tests/regression/cart/coupon.spec.ts
import { test, expect } from "@playwright/test";
import { CartPage } from "../../../src/ui/pages/CartPage";

test("expired coupon keeps full total", async ({ page }) => {
  const cart = new CartPage(page);
  await cart.goto();
  await cart.applyCoupon("OLD10");
  await expect(page.getByText("Coupon expired")).toBeVisible();
  await expect(cart.total).toHaveText("$100.00");
});

For deeper page object guidance, see how to write maintainable page objects.

7. Fixtures: The Seam Between Runner and Domain

Custom fixtures beat deep base test classes for Playwright and pytest.

Runnable example: Playwright fixture module

// src/fixtures/index.ts
import { test as base, expect } from "@playwright/test";
import { CartPage } from "../ui/pages/CartPage";
import { loadEnv } from "../../config/env";

type Fixtures = {
  cartPage: CartPage;
  apiToken: string;
};

export const test = base.extend<Fixtures>({
  cartPage: async ({ page }, use) => {
    await use(new CartPage(page));
  },
  apiToken: async ({ request }, use) => {
    const env = loadEnv();
    const response = await request.post(env.API_URL + "/auth/login", {
      data: { email: env.E2E_USER_EMAIL, password: env.E2E_USER_PASSWORD },
    });
    expect(response.ok()).toBeTruthy();
    const body = await response.json();
    await use(body.token as string);
  },
});

export { expect };

Specs import from @/fixtures (or relative path) and stay thin.

8. Test Data Layout and Secrets Hygiene

Put static fixtures under data/. Generate unique dynamic data in code. Never commit real credentials, production exports, or customer PII.

data/
  fixtures/products.json
  schemas/cart.schema.json
  templates/invoice.html
// src/utils/ids.ts
import { randomUUID } from "node:crypto";

export function uniqueEmail(prefix = "qa"): string {
  return `${prefix}+${randomUUID()}@example.test`;
}

Document seed scripts and cleanup ownership in docs/DATA.md. Cross-link how to design a test data strategy and api test data management.

9. Naming Conventions That Survive Growth

Thing Convention Example
Spec files feature.area.spec.ts coupon.apply.spec.ts
Smoke files *.smoke.spec.ts checkout.smoke.spec.ts
Page classes PascalCase + Page CartPage
API clients PascalCase + Client CartClient
Tags @domain or @lane @checkout, @smoke
Env vars SCREAMING_SNAKE BASE_URL

Consistency beats perfection. Pick one style and enforce with lint and review.

10. Multi-Package and Plugin Boundaries

As the repo grows, split libraries:

packages/
  test-kit/          # fixtures, expect helpers
  ui-model/          # page objects
  api-client/        # HTTP clients
  eslint-plugin-qa/  # custom lint rules
apps/
  web-e2e/           # playwright suite
  admin-e2e/

Use a workspace (npm/pnpm/yarn) only when multiple suites truly share code. Premature monorepo packaging can slow simple teams. Start modular inside one package, split when import graphs hurt.

11. CI Entry Points Mapped to Folders

Your folder structure should make pipelines boring:

# .github/workflows/pr-smoke.yml
name: pr-smoke
on:
  pull_request:
jobs:
  smoke:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "22"
          cache: npm
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npx playwright test --project=smoke
        env:
          BASE_URL: ${{ vars.BASE_URL }}
          API_URL: ${{ vars.API_URL }}
          E2E_USER_EMAIL: ${{ secrets.E2E_USER_EMAIL }}
          E2E_USER_PASSWORD: ${{ secrets.E2E_USER_PASSWORD }}
          CI: "true"

Do not invent a custom bash maze that only one person understands. Structure plus standard CLI flags is enough. For reporting layout, see add reporting to a test framework.

12. Dependency Direction and Import Rules

Enforce a DAG:

tests -> fixtures/pages/flows/api clients -> utils/config

Forbidden:

  • src/ui/pages importing from tests/
  • utils importing Playwright test objects
  • circular page objects that construct each other in constructors

Optional ESLint import/no-restricted-paths rules catch this early. A clean DAG is half of how to structure a test automation repository for long-term speed.

13. Onboarding README That Matches Reality

Your root README should answer in five minutes:

  1. Prerequisites (Node version, browsers)
  2. How to copy .env.example
  3. How to run smoke locally
  4. How to run one test file
  5. Where to put a new test
  6. Who owns flaky triage

Sample commands:

cp .env.example .env
npm ci
npx playwright install chromium
npx playwright test --project=smoke
npx playwright test tests/regression/cart/coupon.spec.ts

If README and folders disagree, folders win only after you fix the README.

14. Migrating a Messy Legacy Suite Without a Big Bang

Do not freeze delivery for a rewrite.

  1. Introduce src/ and stop adding new selectors into specs.
  2. Add tests/smoke and route PR CI there first.
  3. Move files capability-by-capability when touched.
  4. Delete dead tests quarterly with product agreement.
  5. Add lint boundaries once the new path is majority traffic.

Strangler patterns work for test repos the same way they work for services.

15. Selenium/Java and pytest Variants (Same Ideas)

Maven/Java sketch

src/test/java/com/example/qa/
  config/
  pages/
  flows/
  api/
  tests/smoke/
  tests/regression/
src/test/resources/
  data/
  suites/

pytest sketch

tests/
  smoke/
  regression/
  api/
conftest.py
qa_lib/
  pages/
  api/
  data/
pytest.ini

The names change. The layering does not: config, library code, tests, data, CI.

16. Anti-Patterns in Repository Structure

  • A single helpers.js with 2,000 lines and no ownership
  • Copy-pasted page classes per suite with tiny differences
  • Environment URLs hard-coded in twenty specs
  • Mixing unit tests for the product app into e2e packages without clear tooling
  • Committing HTML reports and traces into git
  • One CI job named test that runs everything with no tags
  • Framework abstraction so thick that Playwright docs no longer apply

If a new engineer cannot add a smoke test in one hour, the structure is failing regardless of how "enterprise" the folders look.

Interview Questions and Answers

Q: How would you structure a test automation repository for a mid-size web product?

I separate config, reusable libraries (pages, API clients, fixtures), and tests grouped by business capability and suite lane. CI projects map to folders or tags for smoke, regression, and API. I enforce dependency direction so tests depend on libraries, not the reverse, and I keep secrets out of the repo.

Q: Why not put all helpers next to tests?

Local helpers are fine for one-file needs. Shared behavior belongs in libraries so page changes land in one place. Unstructured helpers become a junk drawer that slows every change.

Q: Page objects or no page objects?

I use thin page objects or screen modules when selectors and interactions are reused. I avoid deep inheritance and keep assertions mostly in tests unless a state check is truly ubiquitous. Flows orchestrate multi-page journeys.

Q: Should automation live in the product monorepo?

If feature teams own tests and share types, yes. If a central QA team validates multiple services with different release trains, a standalone repo or hybrid model can be cleaner. I choose based on ownership and coupling, not fashion.

Q: How do you keep the structure from rotting?

Lint import boundaries, document where new tests go, map CI to folders, and migrate legacy paths with a strangler approach. Review top-level layout when onboarding time rises.

Q: What goes in fixtures versus utilities?

Fixtures wire runner lifecycle and resources (page wrappers, tokens, temporary data). Utilities are pure helpers with no runner side effects. That split keeps unit testing of helpers possible.

Q: How do you organize multi-environment support?

Centralize env parsing with validation, use CI variables per environment, and avoid branching logic scattered in tests. Environment-specific data lives in config or data packs, not hard-coded if statements in every spec.

Common Mistakes

  • Starting with a heavy custom framework before ten solid tests exist.
  • Grouping only by technical layer forever while product domains explode.
  • Allowing circular imports between pages and flows.
  • Committing secrets, large reports, or browser binaries.
  • No mapping between folders and CI jobs.
  • Giant inheritance hierarchies for page objects.
  • Duplicating API DTO types until they silently diverge.
  • README commands that no longer run.
  • Putting every test in one flat directory after year two.
  • Treating structure as a one-time project instead of a maintained interface.

Conclusion

Mastering how to structure a test automation repository means designing for change: clear layers, boring names, validated config, capability-oriented tests, and CI lanes that match folders. The best structure is the one a new teammate can extend on their first day without a tribal walkthrough.

If you are restructuring now, create src/, tests/smoke, and validated env loading this week. Point PR CI at smoke only. Move the next ten touched tests into the new shape. That incremental path beats a freeze-the-world rewrite and compounds into a suite your future self can still navigate.

Concrete Contribution Checklist for Pull Requests

Add this checklist to pull request templates so structure stays real:

  1. Is the test in the correct lane folder (smoke, regression, api)?
  2. Did you reuse an existing page, flow, or client before adding a new one?
  3. Are selectors using roles, labels, or stable test ids rather than brittle CSS chains?
  4. Is dynamic data unique per run, with cleanup ownership documented?
  5. Did you avoid committing .env, traces, or HTML reports?
  6. Does CI selection still match the intended project or tag?
  7. If you added a new package boundary, did you update docs/ARCHITECTURE.md?

Reviewers should reject structural drift early. A single "just this once" helper in the wrong layer becomes the template everyone copies. Teaching how to structure a test automation repository through review comments is more effective than a wiki nobody opens.

Versioning, Releases, and Shared Libraries

When multiple product teams consume a shared ui-model or test-kit package, version it deliberately.

  • Use semantic versioning for shared libraries.
  • Prefer path dependencies inside one monorepo until external consumers exist.
  • Changelog breaking selector or fixture changes.
  • Keep example tests in the library package that run in CI.
  • Avoid exporting dozens of experimental utilities as public API.

A structured repo without version discipline still breaks consumers, only now the breakage is distributed. Treat the automation library as a product with compatibility expectations, even if the product is internal.

Example: Adding a New Billing Test Without Structural Debt

Suppose product ships "download invoice PDF." A structured contribution looks like this:

  1. Add API client method BillingClient.createPaidInvoice() under src/api/clients if setup should skip UI.
  2. Add InvoicePage with locators for download button and status badge under src/ui/pages.
  3. Add tests/regression/billing/invoice-download.spec.ts that seeds via API, opens the invoice deep link, downloads, and asserts file size or PDF header bytes.
  4. Tag @billing and optionally @smoke only if the journey is truly critical and stable.
  5. Update docs/TAGGING.md if @billing is new.
  6. Keep secrets for any third-party billing sandbox in CI variables.

What you do not do: paste a 120-line script into tests/misc/final-final2.spec.ts with hard-coded cookies and absolute XPaths. Structure is a forcing function for quality. Teaching how to structure a test automation repository through this happy path is more valuable than abstract diagrams alone.

Tooling That Protects Structure Automatically

Automate the boring enforcement:

  • TypeScript path aliases so imports stay short without ../../../ chaos, while still respecting layer boundaries.
  • ESLint rules for restricted paths and no-only-tests in CI.
  • Prettier for consistent formatting so reviews focus on behavior.
  • husky/lint-staged optional for local gates, with CI as source of truth.
  • CODEOWNERS for src/fixtures, CI workflows, and env schema files.
  • Dependabot or Renovate for runner and browser toolchain updates.
// tsconfig.json (excerpt)
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@config/*": ["config/*"],
      "@src/*": ["src/*"]
    }
  }
}

Structure without automated guardrails relies on hero reviewers. Heroes go on vacation. Guardrails do not.

When to Break the Rules

Rules exist to reduce average change cost. Break them deliberately:

  • A spike folder for experimental runner upgrades, deleted within a sprint.
  • A single-file test with inline selectors for a throwaway production hotfix validation.
  • A temporary dual-write of page objects during migration across packages.

Write the exception and expiry in the PR. Permanent exceptions without expiry are how structured repositories die. The craft of how to structure a test automation repository includes knowing when a temporary mess is cheaper than false purity.

Storage, Artifacts, and What Never Belongs in Git

A clean structure includes ignore rules and artifact directories that CI and local runs share by convention:

test-results/
playwright-report/
.auth/
.allure/
downloads/
*.log
.env

Commit .gitignore entries for all of them. Publish artifacts from CI as build attachments, not as repository history. Large binary traces in git destroy clone times and obscure real code review. Part of how to structure a test automation repository is deciding where ephemeral evidence lives and making that decision boringly consistent across laptops and pipelines.

Interview Questions and Answers

How would you structure a test automation repository for a mid-size web product?

I separate config, reusable libraries (pages, API clients, fixtures), and tests grouped by business capability and suite lane. CI projects map to folders or tags for smoke, regression, and API. I enforce dependency direction so tests depend on libraries, not the reverse, and I keep secrets out of the repo.

Why not put all helpers next to tests?

Local helpers are fine for one-file needs. Shared behavior belongs in libraries so page changes land in one place. Unstructured helpers become a junk drawer that slows every change.

Page objects or no page objects?

I use thin page objects or screen modules when selectors and interactions are reused. I avoid deep inheritance and keep assertions mostly in tests unless a state check is truly ubiquitous. Flows orchestrate multi-page journeys.

Should automation live in the product monorepo?

If feature teams own tests and share types, yes. If a central QA team validates multiple services with different release trains, a standalone repo or hybrid model can be cleaner. I choose based on ownership and coupling, not fashion.

How do you keep the structure from rotting?

Lint import boundaries, document where new tests go, map CI to folders, and migrate legacy paths with a strangler approach. Review top-level layout when onboarding time rises.

What goes in fixtures versus utilities?

Fixtures wire runner lifecycle and resources such as page wrappers, tokens, and temporary data. Utilities are pure helpers with no runner side effects so they stay easy to unit test.

How do you organize multi-environment support?

Centralize env parsing with validation, use CI variables per environment, and avoid branching logic scattered in tests. Environment-specific data lives in config or data packs, not hard-coded if statements in every spec.

Frequently Asked Questions

What is the best folder structure for a test automation repository?

A practical default separates config, reusable libraries (pages, API clients, fixtures), tests by capability and lane, static data, scripts, and CI workflows. Exact names can vary, but dependency direction and clear CI entry points matter more than trendy folder labels.

Should end-to-end tests live in the product monorepo?

Yes when feature teams own tests beside code and share types. Choose a standalone repo when a central QA function covers multiple services with different release trains. Hybrid models are common and valid.

How do I organize Playwright projects in one repository?

Use config projects mapped to smoke, regression, and API folders or tags. Keep shared page objects and fixtures in a library path imported by tests. Run named projects from CI rather than one opaque script.

Where should page objects live?

In a library directory such as `src/ui/pages`, not mixed among specs. Tests should import pages and flows. Pages should not import test files.

How do I restructure a messy legacy automation repo?

Introduce the target layout and stop adding new selectors into specs. Move files when they are touched, point PR CI at a new smoke lane first, and delete dead tests with product agreement. Avoid freezing delivery for a rewrite.

What naming conventions work well for automated tests?

Use consistent suffixes for specs, Page/Client suffixes for classes, capability-based folders, and documented tags like @smoke. Consistency and discoverability beat clever encoding schemes.

How should secrets be handled in the repository?

Commit only `.env.example` with dummy values. Inject real secrets from the CI secret store and local unttracked env files. Validate required variables at startup so misconfiguration fails fast.

Related Guides