POM tightens the link between the test and the page; custom commands shrink the boilerplate. Both still drive the app from the outside — same way a user would. App actions break that habit. Instead of clicking through three pages to reach the checkout, the test reaches into the running app and sets the state directly: "you are logged in as Alice, your cart has these three items, render /checkout." The pattern is faster and more reliable than UI navigation, and it's the missing piece for testing pages that depend on a lot of upstream state.
The problem with UI-only setup
A spec for the checkout page typically has to:
- Visit
/login. - Type a username and password, click submit.
- Visit
/products, click Add to cart on three different products. - Visit
/cart, click Checkout. - Finally arrive at the page the test is actually about.
Steps 1–4 are repeating the work that other specs already test. A flake in step 2 fails a checkout spec for a login bug. The test that should be one assertion ends up taking 12 seconds and exercising three other features along the way.
Custom commands and page objects shorten the typing but not the time — cy.login() still types into a real form. App actions cut steps 1–4 out entirely.
What app actions actually are
The application code exposes a controlled API on window only when running under Cypress. The test calls those functions directly to seed the state:
// In your app's bootstrap (e.g., src/index.tsx)
import { useAuthStore } from "./stores/auth";
import { useCartStore } from "./stores/cart";
if (typeof window !== "undefined" && (window as any).Cypress) {
(window as any).appActions = {
login: (user: { email: string; token: string }) => {
useAuthStore.getState().setUser(user);
localStorage.setItem("authToken", user.token);
},
setCart: (items: CartItem[]) => {
useCartStore.getState().replaceItems(items);
},
clearState: () => {
useAuthStore.getState().reset();
useCartStore.getState().clear();
},
};
}The if (window.Cypress) guard ensures these helpers only exist during tests — they're stripped from production builds (or visible only to logged-in test runs, depending on how strict you want to be).
In the test, cy.window().its("appActions").invoke("...") calls them:
cy.window()
.its("appActions")
.invoke("login", { email: "alice@test.com", token: "test-token-abc123" });
cy.window()
.its("appActions")
.invoke("setCart", [
{ id: 1, name: "Headphones", price: 49.99, quantity: 1 },
{ id: 2, name: "Cable", price: 12.99, quantity: 2 },
]);
cy.visit("/checkout");The app boots, runs the actions to seed state, and renders /checkout exactly as if the user had walked through the previous flow. The test now contains only the assertions about checkout.
Three setup strategies, side by side
Three ways to reach a page that needs prior state
UI navigation
Drive every step through the real UI
5+ user actions: login, browse, add, view cart, checkout
10–20 seconds per test before assertions begin
Re-tests features the spec doesn't actually care about
API setup (cy.request)
Direct calls to backend endpoints for setup
POST /api/login + POST /api/cart/items
1–3 seconds per test for backend round-trips
Right tool for server-side state (users, products, orders)
App actions
Call functions exposed on window during test mode
appActions.login() + appActions.setCart() + cy.visit
Sub-second — no network at all
Right tool for client-side state (Redux/Zustand, local storage)
The three strategies don't compete — they cover different layers:
- UI navigation for the one spec that exists to verify the navigation itself.
- API setup for state the backend owns: created users, persisted orders, seeded products.
- App actions for state the frontend owns: auth tokens, cart contents, UI preferences, in-memory store data.
Most real test suites use all three. App actions just become the default for client-side state once you've seen the speed difference.
Typing app actions
The point of the action exposure is a typed test surface. Declare the shape on the global Window interface:
// cypress/support/types.ts
import type { CartItem } from "./types";
export interface AppActions {
login(user: { email: string; token: string }): void;
setCart(items: CartItem[]): void;
clearState(): void;
setFeatureFlag(name: string, value: boolean): void;
}
declare global {
interface Window {
appActions: AppActions;
}
}In the test, cy.window().its("appActions") is now typed:
cy.window().then((win) => {
win.appActions.login({ email: "alice@test.com", token: "test-token" });
win.appActions.setCart([
{ id: 1, name: "Headphones", price: 49.99, quantity: 1 },
]);
});A typo in appActions.logIn(...) (wrong casing) is a compile error. Wrong arguments to setCart are a compile error. Once the contract is shared between the app and the tests, it stops drifting.
For a custom-command wrapper that's even cleaner:
declare global {
namespace Cypress {
interface Chainable {
seedAuth(user: { email: string; token: string }): Chainable<void>;
seedCart(items: CartItem[]): Chainable<void>;
}
}
}
Cypress.Commands.add("seedAuth", (user) => {
cy.window().then((win) => win.appActions.login(user));
});
Cypress.Commands.add("seedCart", (items) => {
cy.window().then((win) => win.appActions.setCart(items));
});The test reads:
cy.seedAuth({ email: "alice@test.com", token: "test-token" });
cy.seedCart([{ id: 1, name: "Headphones", price: 49.99, quantity: 1 }]);
cy.visit("/checkout");Every spec that needs a logged-in user with items in cart now runs in well under a second.
A real checkout-only spec
Compare the same test with and without app actions to feel the difference:
// Without — UI-driven setup
describe("Checkout — UI setup", () => {
beforeEach(() => {
cy.visit("/login");
cy.get("[data-testid='email']").type("alice@test.com");
cy.get("[data-testid='password']").type("Sup3rS3cret!");
cy.get("[data-testid='submit']").click();
cy.url().should("include", "/dashboard");
cy.visit("/products");
cy.contains("[data-testid='product-card']", "Headphones")
.find("button")
.click();
cy.contains("[data-testid='product-card']", "Cable")
.find("button")
.click();
cy.visit("/cart");
cy.get("[data-testid='checkout-btn']").click();
});
it("validates the credit-card field", () => {
cy.get("[data-testid='card-number']").type("1234");
cy.get("[data-testid='pay-btn']").click();
cy.get("[data-testid='card-error']").should("contain", "Invalid card number");
});
});// With app actions — same test, fraction of the time
describe("Checkout — app-actions setup", () => {
beforeEach(() => {
cy.visit("/");
cy.seedAuth({ email: "alice@test.com", token: "test-token-abc" });
cy.seedCart([
{ id: 1, name: "Headphones", price: 49.99, quantity: 1 },
{ id: 2, name: "Cable", price: 12.99, quantity: 2 },
]);
cy.visit("/checkout");
});
it("validates the credit-card field", () => {
cy.get("[data-testid='card-number']").type("1234");
cy.get("[data-testid='pay-btn']").click();
cy.get("[data-testid='card-error']").should("contain", "Invalid card number");
});
});Twelve setup lines collapse to four. Login and cart-seeding are tested in their own dedicated specs; this checkout spec exercises only what it's about.
When app actions don't apply
Three scenarios where you can't (or shouldn't) use the pattern:
- You don't control the application code. Third-party SaaS, a vendor's checkout page, an embedded widget — there's no place to add the
if (window.Cypress)exposure. UI or API is your only option. - The action you'd seed is exactly the action under test. "App actions tests of the cart-seeding logic" is circular. Test the cart-seeding through the UI; test downstream pages with app actions.
- The state lives entirely on the backend. A "user has 100 orders" scenario isn't client state — it's database rows.
cy.requestwith a setup endpoint is the right tool there.
For everything else — anywhere the React/Vue/Zustand/Redux tree drives the rendered page — app actions are the cleanest setup mechanism Cypress has.
⚠️ Common mistakes
- Forgetting to gate the app-actions exposure with
if (window.Cypress). Shippingwindow.appActions.clearStateto production is a security hole — any malicious script on the page can call it. Theif (window.Cypress)block (or a build-timeprocess.env.NODE_ENV === "test"guard) keeps the surface invisible outside tests. - Using app actions for state that should be tested through the UI. If your "test the login form" spec uses
cy.seedAuth(...), you've removed the only spec that exercises the actual login UI. Keep one or two flagship UI specs per critical flow; use app actions everywhere else. - Reaching into store internals from the test instead of through
appActions.cy.window().its("__REDUX_STORE__").invoke("dispatch", { type: "LOGIN", payload: ... })works, but it couples your test to internal store shape. TheappActionsboundary is the controlled API — the implementation behind it can change without breaking tests.
🎯 Practice task
Wire up an app-actions setup in a real frontend you control. 30-40 minutes (longer if you set up the demo app from scratch).
- Pick a target. Any small React/Vue app you own works. Or scaffold a tiny Vite app:
npm create vite@latest cy-demo -- --template react-ts. Add a Zustand or React Context-based auth store and cart store. - In the app's bootstrap file, after the store is created, expose:
if (typeof window !== "undefined" && (window as any).Cypress) { (window as any).appActions = { login: (user) => useAuthStore.getState().setUser(user), setCart: (items) => useCartStore.getState().setItems(items), clearState: () => { useAuthStore.getState().reset(); useCartStore.getState().clear(); }, }; } - In
cypress/support/types.ts, declare theAppActionsinterface and the globalWindowextension. Confirm autocomplete oncy.window().its("appActions").invoke(...)works in a spec. - Wrap each action in a typed custom command (
cy.seedAuth,cy.seedCart,cy.clearAppState). The custom commands should callcy.window().then(...). - Write
cypress/e2e/checkout-app-actions.cy.tsthat usescy.seedAuth+cy.seedCart+cy.visit("/checkout")to bypass login and cart UI entirely. Compare run-time against an equivalent UI-driven test. - Security check — build the app for production (
npm run build). Inspect the bundle: confirm theappActionscode is not shipped because thewindow.Cypresscheck is false at build time (or, if the bundler doesn't tree-shake, add aprocess.env.NODE_ENV !== "production"guard). - Stretch: add a
setFeatureFlag(name, value)action. Use it in a test that toggles a feature flag for one specific spec — testing both states without rebuilding the app.
The next lesson handles the meta-layer that everything in this chapter has been silently relying on — environment variables and configuration. Where do credentials come from? How do you point the same suite at staging vs prod? cypress.config.ts and cypress.env.json answer both.