I took the lead on setting up Cypress for end-to-end testing in a React + Vite + TypeScript application. The app is a shipping logistics portal and the test suite now covers 31 test files across the application. Here is how I approached the setup.
Installing Cypress
pnpm add -D cypressThen add scripts to package.json:
"cy:open": "cypress open",
"cy:open:frontend": "cypress open --env CYPRESS_BASE=frontend",
"cy:open:localhost": "cypress open --env CYPRESS_BASE=localhost",
"cy:run": "cypress run",
"cy:run:frontend": "cypress run --env CYPRESS_BASE=frontend"This gives us different commands for different environments - more on that below.
Multi-environment configuration
One of the first things I wanted to get right was being able to run the same tests against multiple environments. In cypress.config.ts I set up a function that returns the correct base URL and API URL depending on an environment variable:
import { defineConfig } from "cypress";
function getBaseUrls(env: string) {
switch (env) {
case "frontend":
return {
baseUrl: "https://frontend.example.se:3000/",
apiUrl: "https://frontend.api.example.se/",
};
case "localhost":
return {
baseUrl: "https://localhost:3000/",
apiUrl: "https://localhost:44367/",
};
default:
return {
baseUrl: "http://dev.portal.example.se/",
apiUrl: "https://dev.api.example.se/",
};
}
}
const env = process.env.CYPRESS_BASE || "dev";
const urls = getBaseUrls(env);
export default defineConfig({
projectId: "your-project-id",
e2e: {
baseUrl: urls.baseUrl,
setupNodeEvents(on, config) {
config.env.apiUrl = urls.apiUrl;
return config;
},
},
env: {
CYPRESS_BASE: env,
},
defaultCommandTimeout: 40000,
requestTimeout: 40000,
retries: 2,
experimentalStudio: true,
experimentalMemoryManagement: true,
experimentalRunAllSpecs: true,
});Now running pnpm cy:open opens Cypress against the dev environment, while pnpm cy:open:frontend targets the frontend staging environment.
Custom commands
I created reusable custom commands in cypress/support/commands.ts to avoid repeating common actions across tests.
Login commands
Cypress.Commands.add("loginAsSuperAdmin", (visit: string = "/") => {
cy.visit(visit);
cy.get('[data-cy="username"]').type("[email protected]");
cy.get('[data-cy="password"]').type("testpassword", { log: false });
cy.get('[data-cy="login-button"]').click();
});
Cypress.Commands.add("loginAsSupplier", (visit: string = "/") => {
cy.visit(visit);
cy.get('[data-cy="username"]').type("[email protected]");
cy.get('[data-cy="password"]').type("supplierpass", { log: false });
cy.get('[data-cy="login-button"]').click();
});Test data reset
Cypress.Commands.add("resetTests", () => {
cy.request("GET", Cypress.env("apiUrl") + "api/v2/test/reset-tests").then(
(response) => {
cy.log("Reset status: " + response.status);
}
);
});This calls an API endpoint to reset the test data before test runs, keeping things isolated.
Reusable UI interactions
Cypress.Commands.add(
"selectLocation",
(type: string, locationName: string, locationId: string) => {
cy.get(`[data-cy="${type}Location"] input`).type(locationName);
cy.wait(2000);
cy.get(`[data-cy="location-${locationId}"]`).click();
}
);Don’t forget to add type definitions in commands.d.ts so TypeScript is happy:
declare namespace Cypress {
interface Chainable {
loginAsSuperAdmin(visit?: string): Chainable<void>;
loginAsSupplier(visit?: string): Chainable<void>;
resetTests(): Chainable<void>;
selectLocation(
type: string,
locationName: string,
locationId: string
): Chainable<void>;
}
}Using data-cy attributes
We use data-cy attributes on React components to make selectors resilient to styling and markup changes:
// In the React component
<TextField data-cy="username" ... />
// In the test
cy.get('[data-cy="username"]').type("[email protected]");This is one of Cypress best practices and saves a lot of headaches when the UI is redesigned.
API interception
One of the most useful Cypress features for us is cy.intercept(). We use it heavily to wait for API calls and assert on responses:
cy.intercept("POST", "/api/v2/search?query=&key=dashboard").as(
"dashboardRequest"
);
// trigger the action that makes the API call
cy.get('[data-cy="search-button"]').click();
cy.wait("@dashboardRequest").then((interception) => {
expect(interception.response?.statusCode).to.equal(200);
});This is much more reliable than using fixed cy.wait(3000) calls since the test waits for the actual network request to complete.
CI integration with GitHub Actions
I set up a GitHub Actions workflow that runs the Cypress suite on every push to develop:
name: Cypress Tests
on:
push:
branches:
- develop
jobs:
cypress-run:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v2
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Cypress run
uses: cypress-io/github-action@v6
with:
record: true
env:
CYPRESS_RECORD_KEY: $We also connected it to Cypress Cloud for test recording, which gives us screenshots and video on failures plus historical trends.
Lessons learned
- Start with data-cy attributes early. Adding them retroactively to a large codebase takes time.
- Use cy.intercept() over cy.wait(). Waiting for network requests is more reliable than arbitrary timeouts.
- Multi-environment support from day one. Being able to run the same tests against dev, staging and localhost has been very useful.
- Keep custom commands small and focused. A few well-designed commands go a long way.
- Enable retries. Setting
retries: 2in the config helps with flaky tests while you work on making them more stable.