ScreenshotNeo

BlogEngineering

Page Objects vs. App Actions in Cypress

Cypress discourages shared page objects, but the right choice depends on what a test needs to prove. Use the UI for user-visible behavior and app actions for setup.

By the ScreenshotNeo team4 October 20269 min read

Short answer: In Cypress, drive the real UI when the test needs to prove that a user can complete a flow. Use an application action, cy.request(), or a small helper to establish preconditions the test is not meant to verify, such as creating a record or signing in. Cypress currently discourages shared page objects; that is context-specific guidance, not a rule that every UI helper is harmful.

Keep the expected outcome visible in the test. Choose the smallest reusable abstraction that makes the test easier to read and debug: inline steps for one-off work, a local function for reuse within a spec, or a small custom command for behavior used across specs.

1. What the terms mean

Page objects

A page object is a helper that represents a page or component and usually wraps selectors and UI interactions. A test might call methods such as loginPage.visit() and loginPage.submitCredentials() instead of showing the Cypress commands directly. This can group repeated UI behavior, but a large abstraction can hide what a test does and couple it to the page structure.

Application actions

An application action changes state through application logic rather than repeating the visible UI flow. For example, a test helper might call a supported app method to create a draft or sign in. The 2019 Cypress article introducing this approach describes it as an alternative for repeated operations. Because it uses an application’s internal interface, it does not prove that a user can perform the same operation through the UI.

Custom commands and ordinary helpers

A Cypress custom command is a reusable command registered on cy, commonly for suite-wide behavior such as setup or login. A plain JavaScript function can be clearer when reuse is local to one spec. Cypress recommends keeping custom commands composable and unopinionated, and cautions against turning every repeated line into a command.

2. How to choose

Question Use Reason
Does the assertion claim a person can complete the flow? UI interactions The test should exercise the visible controls and assert the user-visible result.
Is this operation only preparing shared state? App action, API setup, or cy.request() Repeated setup through the UI adds work without testing the behavior named by the test.
Is the code reused only in one spec? Plain function or inline steps Local code keeps the behavior close to the test.
Is the behavior useful across many specs? Small custom command A shared command can standardize setup while leaving assertions and intent with each test.
Does a page helper clarify repeated UI behavior? Focused UI helper, if useful Keep it narrow and avoid a shared page hierarchy that obscures test intent.

Cypress’s best-practices guidance lists sharing page objects and using the UI to log in among its anti-patterns. Its advice also emphasizes isolated tests and programmatic login. Read that as an opinionated recommendation for Cypress test design: avoid organizing an entire suite around a copied page hierarchy, while keeping focused helpers when they make a test clearer.

3. Runnable example: UI behavior with a setup shortcut

In this example, a custom command creates a project through a test-support method exposed by the app. The test then uses the UI to rename the project and checks the visible result. The app method shown is illustrative: replace window.testSupport.createProject with an explicit setup interface your own application provides, or use an API request if that is how your app creates test data. Do not expose test-only methods in a production build.

// cypress/support/commands.js
Cypress.Commands.add('createProject', (name) => {
  return cy.window().then((win) => {
    if (!win.testSupport || typeof win.testSupport.createProject !== 'function') {
      throw new Error('testSupport.createProject is unavailable');
    }
    return win.testSupport.createProject({ name });
  });
});

// cypress/support/e2e.js
import './commands';

// cypress/e2e/project-edit.cy.js
describe('project editing', () => {
  it('lets a user rename a project', () => {
    cy.visit('/projects');
    cy.createProject('First draft');
    cy.reload();

    cy.get('[data-cy="project-row"]').contains('First draft').click();
    cy.get('[data-cy="project-name"]').clear().type('Launch plan');
    cy.get('[data-cy="save-project"]').click();

    cy.get('[data-cy="project-name"]').should('have.value', 'Launch plan');
    cy.get('[data-cy="save-confirmation"]').should('be.visible');
  });
});

The setup shortcut does not remove the need for the UI assertions: the test still checks the behavior it names. In a real application, ensure setup completes before the page reads the created state. If setup is asynchronous, return or await its promise and then wait for an observable condition before interacting with the page.

4. A page object example, and how to keep it focused

Here is a small page-shaped helper that keeps UI commands together. It is an option for repeated interaction, not a requirement to structure the whole test suite this way.

// cypress/support/project-page.js
export class ProjectPage {
  visit() {
    cy.visit('/projects');
  }

  openProject(name) {
    cy.get('[data-cy="project-row"]').contains(name).click();
  }

  rename(name) {
    cy.get('[data-cy="project-name"]').clear().type(name);
    cy.get('[data-cy="save-project"]').click();
  }
}

// cypress/e2e/project-edit.cy.js
import { ProjectPage } from '../support/project-page';

describe('project editing', () => {
  it('shows the saved project name', () => {
    const projectPage = new ProjectPage();

    projectPage.visit();
    cy.get('[data-cy="project-row"]').contains('First draft').click();
    projectPage.rename('Launch plan');
    cy.get('[data-cy="project-name"]').should('have.value', 'Launch plan');
  });
});

The test still exposes its main actions and assertion. If helpers start combining navigation, setup, several interactions, and assertions, it becomes harder to tell what failed and what the test is proving. Consider keeping setup separate and leaving the important assertion beside the test case.

5. Selectors, reuse, and organization

  • Prefer stable test selectors: use dedicated data-* attributes such as data-cy for UI automation when practical. They avoid relying on styling classes or incidental markup. Use accessible roles and names when the test is intentionally checking accessible user-facing controls.
  • Keep selectors near their use: a tiny helper can reduce duplication, but a central locator registry can make changes hard to trace. Choose the simplest arrangement that keeps failures understandable.
  • Put suite-wide commands in support code: Cypress’s test organization guidance describes the support file as the place for globally shared behavior. Keep spec-local functions in the spec when they are not genuinely shared.
  • Avoid hidden assertions: commands should generally perform a reusable action, while the calling test decides which assertions establish its claim. This keeps expectations explicit and avoids a helper that passes despite an incomplete test.
  • Preserve test isolation: a test should establish the state it needs rather than depending on another test’s mutations or execution order. Programmatic setup can help, but it must be deterministic and cleaned up or uniquely scoped.

6. Synchronization and edge cases

Direct app actions can finish before the UI has processed the resulting state, or before a request has reached the server. Do not add an arbitrary delay as the default fix. Wait for a meaningful signal: a returned promise, a specific network response, or a DOM state that represents completion. Cypress commands retry assertions against the DOM, so an assertion on the expected state is often a better wait than a fixed sleep.

Some application operations have no safe or supported in-process method. Use an API or database-backed test setup where available, or use the UI if the setup operation itself is under test. If a setup action changes browser state, account for navigation and reload behavior so the page under test actually reads the new state.

Be careful with retries and duplicate setup. A retryable test or command may run more than once; creation methods should be idempotent where possible, or use unique identifiers and cleanup. Keep authentication secrets out of source control and use the project’s supported Cypress configuration and environment handling for credentials.

7. Common problems and fixes

Symptom Likely cause Fix
The test passes locally but fails in CI Setup and the UI are racing, or the test depends on leftover state. Return/await setup work, wait on a response or visible state, and make each test establish its own data.
cy.window() cannot find the app method The method is installed only after app initialization, on a different window, or is absent in that build. Expose the test hook before the app needs it, verify it exists, or choose an API setup path. Keep test-only hooks out of production builds.
The app action changes data but the page stays stale The page loaded before the mutation or does not subscribe to that update. Perform setup before visiting, reload after the action, or wait for the app’s observable update mechanism.
A page helper makes failures difficult to diagnose It hides several commands or assertions behind one method. Split the helper into small actions and keep the key assertion in the test.
A selector breaks after a visual redesign The test relies on CSS classes, DOM nesting, or visible copy that changed incidentally. Use stable data-* selectors for structural targeting, or assert accessible roles and names when those are part of the expected behavior.
Custom command runs but Cypress does not wait for it The command does not return the Cypress chain or promise representing its work. Return the chain/promise and assert on completion through an observable result.
Retries create duplicate records The setup operation is not safe to repeat. Make setup idempotent, use a unique test identifier, or clean up test data reliably.

8. Performance, reliability, and cost

Skipping repeated UI setup can reduce needless browser work, but the amount depends on the application and suite. In a 2019 article, Gleb Bahmutov reported a local TodoMVC example taking 17 seconds with application actions versus 34 seconds through the UI. That is one small example using Cypress’s Electron browser, not a general benchmark or a promise of a particular speedup.

Optimize for reliable coverage rather than minimizing every UI interaction. A direct action may make setup faster, while also coupling a test to an internal app method. UI flows tend to exercise more of the user-visible path, while direct setup can make preconditions explicit and repeatable. Measure your own suite if runtime is a concern, and retain UI coverage for behaviors where the interface is the claim.

There is no separate product cost implied by choosing a page object or app action; the practical costs are maintenance, test runtime, and debugging effort. Avoid broad claims that one pattern always reduces maintenance. A focused helper can help one suite and hinder another if it conceals behavior.

9. ScreenshotNeo for Cypress failure artifacts

For screenshots of a page in a Cypress workflow, ScreenshotNeo can capture the rendered URL through an API call. Its API can return PNG, JPEG, or WebP, and it can also return PDFs. The product removes supported consent banners, newsletter popups, and chat widgets before capture; each cleanup step can be turned off. Failed loads, blank pages, bot checks/CAPTCHAs, and cache hits are not billed, and the response includes page-verdict and billing headers. See ScreenshotNeo and the API documentation.

Or skip the browser setup:

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

Cookie banners, popups, and chat widgets are removed before the shot. Bot checks, blank pages, and failed loads are never billed. An MCP server lets AI agents use take_screenshot, get_page_info, and capture_pdf. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000. Sign up for 1,000 free screenshots a month, with no card.

10. FAQ

Are page objects an anti-pattern in Cypress?

Cypress labels sharing page objects an anti-pattern in its best-practices guidance. Treat that as Cypress’s recommendation about shared suite structure, not a universal claim that every focused UI helper is harmful.

Should every test use an application action?

No. Use one when it establishes setup state outside the behavior being tested. Keep the UI path when the purpose is to verify that a person can complete that flow.

Can page objects and app actions coexist?

Yes. A team can use a small UI helper for repeated interactions and a separate setup action for preconditions. Keep each abstraction narrow and keep the test’s expected outcome visible.

Where can I read more about Cypress custom commands?

See Cypress’s Custom Commands documentation and its guide to writing and organizing tests. For the app-actions approach and its historical example, see Application Actions: Use Them Instead of Page Objects.

Sources