Project Structure for Large Test Suites

9 min read

A flat cypress/e2e/ folder is fine for ten specs. At fifty it's confusing; at two hundred it's actively painful. Engineers can't find tests for the feature they're working on; PR reviews can't tell which area of the app changed; CI sharding has nothing meaningful to split on. This lesson lays out the folder structure most production Cypress projects converge on, the naming conventions that make spec files searchable, and the smoke-vs-regression tagging that lets one suite serve two CI cadences.

Why structure matters at scale

Test code is code. Apply the same hygiene any production codebase gets:

  • Discoverability. When the auth team breaks something, the auth specs should be in one folder.
  • Ownership. Folder structure maps onto team responsibility — each top-level folder has a clear owner.
  • CI sharding. Parallel execution (chapter 8) splits by file or pattern. A folder per feature gives you natural boundaries.
  • Refactor cost. When the auth flow changes, the engineer fixes tests in cypress/e2e/auth/ only. They don't have to grep across 200 files to find the seven that touch login.

The structure that pays for itself within the first month — and saves hours per week from then on — is below.

cypress/
├── e2e/
│   ├── auth/               → login, logout, registration, password reset
│   ├── products/           → listing, search, filtering, detail page
│   ├── cart/               → add, remove, update quantities
│   ├── checkout/           → shipping, payment, review, confirmation
│   ├── account/            → profile, settings, order history
│   ├── admin/              → admin panel, user management, reports
│   └── smoke/              → critical-path tests run on every push
├── fixtures/
│   ├── users/              → admin.json, standard.json, viewer.json
│   ├── products/           → full-list.json, empty.json, search-results.json
│   ├── orders/
│   └── api-responses/      → success and error response bodies
├── pages/                  → page objects, one file per page
│   ├── loginPage.ts
│   ├── productListPage.ts
│   └── cartPage.ts
├── support/
│   ├── commands/           → custom commands, split by feature
│   │   ├── auth.ts
│   │   ├── cart.ts
│   │   ├── admin.ts
│   │   └── index.ts        → re-exports all command files
│   ├── e2e.ts              → loads commands and global hooks
│   └── types.ts            → shared TypeScript interfaces
└── utils/                  → helper functions, factories, constants
    ├── factories.ts
    ├── constants.ts
    └── dateUtils.ts

Six top-level folders inside cypress/. The principles behind the layout:

  • e2e/ mirrors product features. A new engineer guesses where the cart tests live in five seconds.
  • fixtures/ mirrors e2e/. Cart fixtures live next to cart specs — close-coupling pays off when you refactor.
  • pages/ is flat. One file per page object; no nesting. Page objects rarely correspond to folders cleanly.
  • support/commands/ is nested. Custom commands grow into the dozens; one file per feature stops commands.ts from becoming a 2000-line monster.
  • utils/ holds non-Cypress helpers. Date formatters, factories, constants — anything you'd unit-test independently.

Spec-file naming

Two patterns that read well across hundreds of files:

  • feature.cy.ts — for the canonical spec covering one feature: login.cy.ts, cart.cy.ts, checkout.cy.ts.
  • feature-scenario.cy.ts — for a specific scenario branch of one feature: checkout-guest.cy.ts, login-2fa.cy.ts, signup-invite-code.cy.ts.

Keep the kebab-case in filenames; reserve camelCase for module names inside the file. The combination makes Cypress's runner output and CI logs readable.

Avoid these:

  • test1.cy.ts, test2.cy.ts — meaningless in a spec list of fifty.
  • LoginTest.cy.ts — case mismatched with everything else.
  • feature.spec.ts (without .cy) — .cy.* is the Cypress convention; non-.cy files won't be picked up by the default specPattern.

Feature-based vs type-based grouping

You'll see two competing philosophies in the wild:

Feature-based — what the lesson recommends:

e2e/
├── auth/login.cy.ts
├── auth/logout.cy.ts
├── cart/add-items.cy.ts
└── cart/checkout.cy.ts

Type-based — sometimes seen, almost always regretted:

e2e/
├── ui/login.cy.ts
├── ui/cart.cy.ts
├── api/auth.cy.ts
└── api/cart.cy.ts

Feature-based wins for one reason: when the auth code changes, every test in auth/ is potentially affected — UI and API alike. Co-locating them means one folder to inspect, one PR review, one engineer who owns the area. Type-based scatters the impact across multiple folders and turns refactors into archaeology.

The exception: e2e/smoke/ and e2e/quarantine/ — these are type-based folders, but only because they cross-cut features in ways the runner uses (smoke for "every push", quarantine for "skip these"). One or two cross-cutting type folders is fine; building the whole tree on type is the trap.

Smoke tests — the critical-path subset

A subset of tests that exercise the most important user flows — the ones whose failure means "the site is broken, page someone." Two ways to mark them:

  • Folder-based — keep them in cypress/e2e/smoke/ and run via spec pattern: npx cypress run --spec "cypress/e2e/smoke/**".
  • Tag-based — use @cypress/grep to mark it("does the thing", { tags: "@smoke" }, () => ...) and run via --env grepTags=@smoke.

Folder-based is simpler and easier to understand at a glance. Tag-based is more flexible — one test can be tagged @smoke and @auth. For most teams, folder-based smoke + feature folders is enough.

Run the smoke suite on every push (1-3 minutes); run the full suite on PRs to main (5-15 minutes); run a deeper regression nightly. Chapter 9's last lesson covers the tiered-testing strategy in depth.

A real-world layout

A representative production project's tree, lightly fictionalised:

cypress/
├── e2e/
│   ├── smoke/
│   │   ├── login.cy.ts
│   │   ├── add-to-cart.cy.ts
│   │   └── checkout.cy.ts            // 3 tests, ~90 seconds
│   ├── auth/
│   │   ├── login.cy.ts
│   │   ├── logout.cy.ts
│   │   ├── password-reset.cy.ts
│   │   ├── 2fa.cy.ts
│   │   └── session-expiry.cy.ts      // 12 tests, ~3 minutes
│   ├── products/
│   │   ├── product-list.cy.ts
│   │   ├── product-search.cy.ts
│   │   ├── product-filters.cy.ts
│   │   └── product-detail.cy.ts      // 18 tests
│   ├── cart/
│   ├── checkout/
│   ├── account/
│   └── admin/
├── fixtures/
├── pages/
├── support/commands/{auth,cart,admin,index}.ts
├── support/e2e.ts
├── utils/
└── cypress.config.ts

Total ~200 specs across seven feature folders, plus a smoke folder. CI matrix shards by folder for parallelisation; engineers work in their feature folder and rarely touch others; smoke runs on every push for immediate signal.

The structure visualised

cypress/
  • – auth/ — login, logout, 2fa
  • – products/ — list, search, detail
  • – cart/ — add, remove, update
  • – checkout/ — shipping, payment
  • – smoke/ — critical paths only
  • – users/, products/, orders/
  • – Co-located with the e2e/ folders
  • – JSON files, typed via interfaces
  • – Flat — one file per page object
  • – loginPage.ts, cartPage.ts
  • – Re-exported from a central pages/index.ts
  • commands/ split by feature –
  • e2e.ts loads everything globally –
  • types.ts holds shared interfaces –
  • Pure functions, no Cypress imports –
  • factories, constants, formatters –
  • Unit-testable independently of Cypress –

Migrating a flat project

If you've inherited a cypress/e2e/ with 80 files in it and no folder structure, do the migration in three passes:

  1. Group physically. Move each spec into a feature folder based on what it tests. Don't change content yet — just git mv to the new path. Run the suite; confirm everything still passes.
  2. Co-locate fixtures. Move JSON files in cypress/fixtures/ into matching feature subfolders. Update cy.fixture("...") calls to use the new paths.
  3. Split commands.ts. Break the monolith into commands/auth.ts, commands/cart.ts, etc. Update support/e2e.ts to import from commands/index.ts.

Plan a single sprint for the migration. The pain is concentrated; the payoff lasts forever.

⚠️ Common mistakes

  • Mirroring the URL/route structure of the app. Routes change every refactor; features don't. Group by feature (auth/, cart/) not by route (pages/login, pages/cart-summary). Otherwise every URL change triggers a structural reshuffle.
  • Putting integration tests, unit tests, and E2E tests under one cypress/ folder. Cypress is for E2E; component tests are also a Cypress feature but live under cypress/component/. Don't try to make cypress/e2e/ host random unit-flavoured tests too — they belong in your application's Vitest/Jest setup.
  • Naming files after individual it blocks. A spec with 5 tests named should-show-error.cy.ts puts you in pain the moment you have a sixth test that's not about errors. Name files after the area (login.cy.ts); name the it blocks after the specific assertions.

🎯 Practice task

Restructure a real project. 25-35 minutes.

  1. Pick a Cypress project (yours or a public one) with at least 15 spec files in a flat cypress/e2e/ folder.
  2. List every spec on paper. For each, write the feature it primarily tests (auth, cart, checkout, etc.). Group the list into 4-6 feature buckets.
  3. Create the folder tree from the lesson — cypress/e2e/{auth,cart,checkout,smoke,...}/. git mv each spec into the right folder.
  4. Identify smoke candidates. Pick 3-5 of the most critical specs (login, primary purchase flow). Move them into cypress/e2e/smoke/.
  5. Co-locate fixtures into matching subfolders under cypress/fixtures/. Update cy.fixture("name") calls to cy.fixture("users/admin") style paths.
  6. Split your cypress/support/commands.ts monolith. Create cypress/support/commands/{auth,cart,admin,index}.ts. Move command registrations into the right files; have index.ts re-export everything; have support/e2e.ts import only from index.ts.
  7. Run the full suite and the smoke suite separately: npm run cy:run and npm run cy:run -- --spec "cypress/e2e/smoke/**". Confirm both pass.
  8. Stretch: wire the smoke suite into a separate CI job that runs on every push, with the full suite running only on PRs to main. The two-tier setup is the foundation for the tiered-testing strategy in lesson 4.

The next lesson takes the new structure and fills it with the shared utilities, constants, and types that keep two hundred specs from drifting into chaos.

// tip to track lessons you complete and pick up where you left off across devices.