A 200-spec project that scatters the same data-testid='email' selector across forty files is a project that breaks every Tuesday after a sprint review. A 200-spec project that imports a single typed SELECTORS constant fixes the same selector once. This lesson covers the three shared-code surfaces — utilities, constants, and types — that make a Cypress framework grep-friendly, refactor-safe, and resilient to the constant churn of a real product.
Why this layer exists
Custom commands and page objects (chapters 5) reduce repetition inside specs. But specs aren't the only place repetition shows up. Test data has the same shape everywhere; selectors get hardcoded into both commands and page objects; date arithmetic appears whenever a test needs "30 days from now." Centralising this infrastructure code is what stops the framework from drifting into a copy-paste graveyard.
The structure (from the previous lesson):
cypress/
├── utils/ → pure functions, no Cypress imports
│ ├── factories.ts
│ ├── constants.ts
│ ├── dateUtils.ts
│ └── api.ts
└── support/
└── types.ts → shared TypeScript interfaces and types
Each file has a single responsibility. None imports cypress — the utilities work in any TypeScript context, including unit tests of the utilities themselves.
Shared utilities
Helper functions that don't fit naturally as a custom command — usually because they're synchronous and don't yield a Cypress chainable. Date arithmetic is the textbook example:
// cypress/utils/dateUtils.ts
export function formatDate(date: Date): string {
return date.toISOString().split("T")[0];
}
export function getFutureDate(daysAhead: number): string {
const date = new Date();
date.setDate(date.getDate() + daysAhead);
return formatDate(date);
}
export function getPastDate(daysAgo: number): string {
return getFutureDate(-daysAgo);
}Used inside any spec or page object:
import { getFutureDate, formatDate } from "../../utils/dateUtils";
it("books a flight 30 days out", () => {
cy.get("[data-testid='depart-date']").type(getFutureDate(30));
});The win: when "30 days from now" turns out to need timezone handling, you fix dateUtils.ts once. Every test that called getFutureDate(30) continues to compile and runs against the corrected value.
Centralised selectors as constants
Selectors are the most fragile, copy-pasted strings in any test suite. Centralise them and a data-testid rename becomes a single-file edit:
// cypress/utils/constants.ts
export const SELECTORS = {
LOGIN: {
EMAIL: "[data-testid='email']",
PASSWORD: "[data-testid='password']",
SUBMIT: "[data-testid='submit']",
ERROR: "[data-testid='login-error']",
},
NAV: {
HOME: "[data-testid='nav-home']",
PRODUCTS: "[data-testid='nav-products']",
CART: "[data-testid='nav-cart']",
ACCOUNT: "[data-testid='nav-account']",
},
CART: {
ITEM_ROW: "[data-testid='cart-item']",
QUANTITY: "[data-testid='cart-quantity']",
REMOVE_BUTTON: "[data-testid='remove-item']",
CHECKOUT_BTN: "[data-testid='checkout-btn']",
},
} as const;
export const URLS = {
HOME: "/",
LOGIN: "/login",
PRODUCTS: "/products",
CART: "/cart",
CHECKOUT: "/checkout",
} as const;
export const TEST_TIMEOUTS = {
DEFAULT: 4000,
SLOW_PAGE: 10_000,
REPORT_GEN: 30_000,
} as const;The as const suffix makes every nested string a literal type — autocomplete on SELECTORS.LOGIN.EMAIL gives you the exact string at the type level.
In specs, page objects, and custom commands:
import { SELECTORS, URLS } from "../utils/constants";
cy.visit(URLS.LOGIN);
cy.get(SELECTORS.LOGIN.EMAIL).type("alice@test.com");
cy.get(SELECTORS.LOGIN.PASSWORD).type("Sup3rS3cret!");
cy.get(SELECTORS.LOGIN.SUBMIT).click();The win is twofold:
- Refactor cost. A
data-testidrename in the app is a single edit inconstants.ts; the rest of the suite recompiles automatically. - Discoverability. A new engineer can read
constants.tsand learn what's testable without grepping the spec folder.
Don't centralise every selector — the rule of thumb is: if a selector appears in three or more places, hoist it to constants. One-off selectors stay inline.
Shared TypeScript types
Test data has shape; the shape should live in one place. The single source of truth pattern:
// cypress/support/types.ts
export type UserRole = "admin" | "standard" | "viewer";
export interface User {
id: number;
name: string;
email: string;
role: UserRole;
}
export interface Product {
id: number;
name: string;
price: number;
category: string;
inStock: boolean;
}
export interface CartItem {
productId: number;
quantity: number;
}
export interface Order {
id: number;
userId: number;
items: CartItem[];
total: number;
status: "pending" | "paid" | "shipped" | "cancelled";
}
export interface ApiResponse<T> {
status: number;
data: T;
message?: string;
}Every spec, page object, factory, and custom command imports from here:
import type { User, Product, Order, ApiResponse } from "../support/types";
Cypress.Commands.add("createUser", (data: Partial<User>) => {
return cy
.request<ApiResponse<User>>("POST", "/api/test/users", data)
.its("body.data");
});When the app team adds phoneNumber: string to the User schema, you update types.ts once and TypeScript flags every test that needs to handle the new field. Without the shared type, that change ripples invisibly until a test breaks at runtime.
Environment-aware helpers
A small piece of glue that makes API calls portable across environments:
// cypress/utils/api.ts
import { URLS } from "./constants";
export function getApiUrl(path: string): string {
const base = Cypress.env("apiUrl") ?? "http://localhost:3000/api";
return `${base}${path}`;
}
export function getFullUrl(path: keyof typeof URLS): string {
const base = Cypress.config("baseUrl") ?? "http://localhost:3000";
return `${base}${URLS[path]}`;
}import { getApiUrl } from "../utils/api";
cy.request("POST", getApiUrl("/users"), { name: "Test" });Now switching environments (chapter 5) flows through one helper. No spec hardcodes a URL; every API call lands at the right host on every environment.
How shared code flows into tests
Types feed every other layer. Constants feed commands and page objects. Factories produce typed test data. Specs sit at the end of the chain and consume the abstractions — they don't define them.
A real-world cypress/utils/ slice
A complete typed file as it would land in a production project:
// cypress/utils/index.ts — re-exports for clean imports
export * from "./constants";
export * from "./dateUtils";
export * from "./factories";
export * from "./api";// In any spec
import {
SELECTORS, URLS,
getFutureDate,
createUser, createProduct,
getApiUrl,
} from "../../utils";
it("creates an order with a 30-day delivery window", () => {
const user = createUser({ role: "standard" });
const product = createProduct({ category: "electronics" });
cy.request("POST", getApiUrl("/users"), user);
cy.request("POST", getApiUrl("/products"), product);
cy.sessionLogin(user.email, "TestPass1!");
cy.visit(URLS.PRODUCTS);
cy.get(SELECTORS.NAV.PRODUCTS).should("be.visible");
cy.contains(SELECTORS.CART.ITEM_ROW, product.name);
cy.get("[data-testid='delivery-date']").type(getFutureDate(30));
});Six imports, every one typed. Refactoring any selector, URL, type field, or factory rule is a single-file change.
⚠️ Common mistakes
- Centralising every selector and creating a 1000-line constants file. A selector used in one spec doesn't earn a hoisted constant. The rule of thumb is "appears in three or more places" — anything less is premature abstraction.
- Putting Cypress chains in
cypress/utils/. Helpers inutils/should be pure functions. The moment you importcyinto a util, the file can't be unit-tested independently and it's no longer reusable outside Cypress. Cypress chains belong in custom commands or page objects. - Letting
support/types.tsdrift from the real API contract. AUserinterface that saysemail: stringwhile the backend renamed the field toemailAddressproduces tests that compile cleanly and fail at runtime in confusing ways. Either generate the types from your API spec (OpenAPI codegen) or pairtypes.tsupdates with the same PR that changes the backend.
🎯 Practice task
Build the utils/ and types.ts layer for a real project. 25-35 minutes.
- In
cypress/support/types.ts, defineUser,Product, andOrderinterfaces matching your test target. Use literal-union types for status enums (type UserRole = "admin" | "standard" | "viewer"). - Create
cypress/utils/constants.tswithSELECTORS,URLS, andTEST_TIMEOUTSobjects. Useas constso the literals are typed precisely. - Refactor your existing custom commands and page objects to import from
constants.ts. Confirm autocomplete works onSELECTORS.LOGIN.EMAIL. - Create
cypress/utils/dateUtils.tswithformatDate,getFutureDate,getPastDate. Use them in any spec that types a date. - Create
cypress/utils/api.tswithgetApiUrl(path: string). Refactor any hardcoded API URLs in your specs to callgetApiUrl(...). Confirm switchingCYPRESS_TARGET(chapter 5) routes API calls to the right host. - Refactor drill — pretend the design team renamed
data-testid='email'todata-testid='email-input'across the app. Update onlyconstants.ts. Run the full suite. Every test that imported the constant works automatically. - Stretch: wire
cypress/utils/index.tsto re-export everything from the other utils files, then refactor specs to import from../../utilsinstead of individual files. The barrel export keeps spec imports short as the utils tree grows.
The next lesson scales the utilities into the data layer — factories that generate typed test data with sensible defaults, perfect for the API-seeded setup pattern most production suites adopt.