Shared Utilities, Constants, and Type Definitions

8 min read

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-testid rename in the app is a single edit in constants.ts; the rest of the suite recompiles automatically.
  • Discoverability. A new engineer can read constants.ts and 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 in utils/ should be pure functions. The moment you import cy into 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.ts drift from the real API contract. A User interface that says email: string while the backend renamed the field to emailAddress produces tests that compile cleanly and fail at runtime in confusing ways. Either generate the types from your API spec (OpenAPI codegen) or pair types.ts updates with the same PR that changes the backend.

🎯 Practice task

Build the utils/ and types.ts layer for a real project. 25-35 minutes.

  1. In cypress/support/types.ts, define User, Product, and Order interfaces matching your test target. Use literal-union types for status enums (type UserRole = "admin" | "standard" | "viewer").
  2. Create cypress/utils/constants.ts with SELECTORS, URLS, and TEST_TIMEOUTS objects. Use as const so the literals are typed precisely.
  3. Refactor your existing custom commands and page objects to import from constants.ts. Confirm autocomplete works on SELECTORS.LOGIN.EMAIL.
  4. Create cypress/utils/dateUtils.ts with formatDate, getFutureDate, getPastDate. Use them in any spec that types a date.
  5. Create cypress/utils/api.ts with getApiUrl(path: string). Refactor any hardcoded API URLs in your specs to call getApiUrl(...). Confirm switching CYPRESS_TARGET (chapter 5) routes API calls to the right host.
  6. Refactor drill — pretend the design team renamed data-testid='email' to data-testid='email-input' across the app. Update only constants.ts. Run the full suite. Every test that imported the constant works automatically.
  7. Stretch: wire cypress/utils/index.ts to re-export everything from the other utils files, then refactor specs to import from ../../utils instead 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.

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