1) Mapping a user journey into testable checkpoints
Real user workflows (sign in, browse, edit, submit) are valuable because they exercise multiple parts of the app together. The risk is writing one huge test that is hard to debug and fails for unrelated reasons. A practical approach is to map the journey into checkpoints: small, observable milestones that prove the user is on the right step before moving on.
How to turn a journey into checkpoints
- Start state: what page/route should the user begin on, and what must be visible to confirm it?
- Action: what does the user do (click, type, select)?
- Transition: what changes (URL, route params, page title, key component)?
- Milestone assertion: what single, stable UI signal confirms success (heading, summary panel, toast, disabled button, table row count)?
- Data dependency: what data must exist (user, list items)? Decide whether to create it via UI, API, or stubs (covered elsewhere) and keep it consistent.
For the worked example (sign in → browse list → open details → submit a form → confirm success), a checkpoint map could look like this:
- Checkpoint A (Signed in): URL includes
/appand a user menu is visible. - Checkpoint B (List loaded): URL includes
/itemsand the list container renders at least one row. - Checkpoint C (Details opened): URL matches
/items/:idand the details heading shows the item name. - Checkpoint D (Form submitted): submit button becomes disabled/spinner appears, then a success message appears.
- Checkpoint E (Success state): URL or UI reflects the updated state (status badge changed, confirmation panel visible).
2) Navigation patterns (links, router changes, query params)
Navigation is more than clicking links. Modern apps change routes via client-side routers, update query parameters for filtering/sorting, and sometimes open pages in new tabs. Your tests should validate navigation in two ways: (1) the route changed as expected, and (2) the destination page’s key UI is present.
Clicking links and buttons that navigate
Prefer user-like navigation (click) when the goal is to test the wiring between UI and routing. After the click, assert both URL and a page milestone.
// Navigate via a link and confirm destination UI is ready
cy.contains('a', 'Items').click();
cy.location('pathname').should('eq', '/app/items');
cy.findByRole('heading', { name: 'Items' }).should('be.visible');Direct navigation for setup (visit a route)
When navigation itself is not the subject of the test (for example, you just need to start on a details page), go directly to the route to keep the test focused.
Continue in our app.
You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.
Or continue reading below...Download the app
// Start directly on a route when the navigation path is not under test
cy.visit('/app/items/123');
cy.findByRole('heading', { name: /item details/i }).should('be.visible');Validating router params and dynamic segments
For dynamic routes like /items/:id, assert the pathname pattern and optionally extract the id from the URL to use later (for example, to confirm the page is showing the same item you clicked).
cy.location('pathname').should('match', /\/app\/items\/\d+$/);
cy.location('pathname').then((path) => {
const id = path.split('/').pop();
expect(id).to.match(/^\d+$/);
});Query parameters for filters and pagination
When the UI updates query params (e.g., ?status=open&page=2), assert them explicitly. This confirms the router state and makes failures easier to diagnose.
// Apply a filter that updates the query string
cy.findByRole('combobox', { name: /status/i }).select('Open');
cy.location('search').should('include', 'status=open');
// Confirm the UI reflects the filter
cy.findByTestId('items-list').find('[data-testid="item-row"]').each(($row) => {
cy.wrap($row).should('contain.text', 'Open');
});New tab navigation (target=_blank)
If the app opens links in a new tab, Cypress stays in a single tab. A common pattern is to remove the target attribute before clicking, then validate the destination.
cy.contains('a', 'View Terms')
.should('have.attr', 'target', '_blank')
.invoke('removeAttr', 'target')
.click();
cy.location('pathname').should('eq', '/terms');3) Form interaction best practices (typing, clearing, selecting, file uploads if applicable)
Forms are where long journeys often become flaky: fields may be prefilled, inputs can be masked, validation can be async, and submit buttons may be disabled until the form is valid. The goal is to interact like a user while keeping assertions tied to visible behavior.
Typing and clearing reliably
- Assert the field is ready: visible and enabled before typing.
- Clear intentionally: use
.clear()for standard inputs; for masked inputs, consider selecting all then typing. - Assert the value: after typing, confirm the input value to catch formatting issues early.
cy.findByRole('textbox', { name: /email/i })
.should('be.visible')
.and('be.enabled')
.clear()
.type('user@example.com')
.should('have.value', 'user@example.com');
cy.findByLabelText(/phone/i)
.click()
.type('{selectall}{backspace}5551234567')
.invoke('val')
.should('match', /555/);Selects, comboboxes, and custom dropdowns
Native <select> elements work well with .select(). Custom dropdowns typically require clicking the trigger, then clicking an option. In both cases, assert the chosen value is reflected in the UI.
// Native select
cy.findByRole('combobox', { name: /priority/i }).select('High');
cy.findByRole('combobox', { name: /priority/i }).should('have.value', 'high');
// Custom dropdown (example pattern)
cy.findByTestId('assignee-select').click();
cy.findByRole('option', { name: 'Alex Kim' }).click();
cy.findByTestId('assignee-select').should('contain.text', 'Alex Kim');Checkboxes, radios, and toggles
Use .check()/.uncheck() for checkboxes and radios when possible, and assert the checked state. For custom toggles, assert the accessible state (e.g., aria-checked) or a visible label change.
cy.findByRole('checkbox', { name: /subscribe/i }).check().should('be.checked');
cy.findByRole('radio', { name: /monthly/i }).check().should('be.checked');File uploads (when applicable)
If your workflow includes attachments, Cypress can attach files using cy.selectFile(). Assert that the filename appears or that an upload indicator completes.
cy.findByLabelText(/attachment/i).selectFile('cypress/fixtures/sample.pdf');
cy.findByTestId('attachment-list').should('contain.text', 'sample.pdf');Submitting forms and handling validation
Before submitting, assert the submit button is enabled (or that required errors are not present). After submitting, assert a visible success state, and optionally assert the button becomes disabled during submission to confirm the app prevents double submits.
cy.findByRole('button', { name: /submit/i }).should('be.enabled').click();
cy.findByRole('button', { name: /submit/i }).should('be.disabled');
cy.findByRole('alert').should('contain.text', 'Saved');4) Validating route transitions and key UI milestones
In multi-step journeys, the most stable assertions are those tied to user-visible milestones: page headings, step indicators, summary panels, and confirmation messages. Combine these with route assertions to ensure you are on the correct step.
Route assertions: pathname + search + hash
Use cy.location() to assert different parts of the URL. This is especially useful when the UI looks similar across steps but the route differs.
// Pathname
cy.location('pathname').should('eq', '/app/items');
// Query string
cy.location('search').should('include', 'status=open');
// Hash (if used)
cy.location('hash').should('eq', '#details');Milestone assertions: “one strong signal per step”
For each step, pick one primary assertion that proves the step is ready. Avoid asserting many small details that are not essential to the workflow.
- List page: list container visible and at least one row present.
- Details page: heading includes the item name and a key action button exists.
- Form step: required fields visible and submit enabled after valid input.
- Success state: confirmation banner/toast and updated status badge.
// Example: list ready
cy.findByTestId('items-list').should('be.visible');
cy.findByTestId('items-list').find('[data-testid="item-row"]').its('length').should('be.gte', 1);
// Example: details ready
cy.findByRole('heading', { name: /item:/i }).should('be.visible');
cy.findByRole('button', { name: /edit|request change|submit/i }).should('be.visible');Confirming transitions after actions
After an action that should navigate (clicking a row, submitting a form), assert the route change and the milestone. This makes it clear whether the failure is navigation-related or rendering-related.
cy.findByTestId('item-row-123').click();
cy.location('pathname').should('eq', '/app/items/123');
cy.findByRole('heading', { name: /item 123/i }).should('be.visible');5) Splitting long journeys into independent tests without duplicating setup
A single end-to-end journey test is useful as a smoke test, but most workflows are easier to maintain when split into smaller tests that share setup. The key is to avoid repeating the same sign-in and navigation steps in every test while still keeping each test independent.
Use shared setup hooks for repeated prerequisites
Put repeated prerequisites in beforeEach so each test starts from a known state. Keep the hook focused: authenticate and land on a stable starting page for that group of tests.
describe('Item workflow', () => {
beforeEach(() => {
// Example: sign in via UI or a custom command
cy.visit('/signin');
cy.findByRole('textbox', { name: /email/i }).type('user@example.com');
cy.findByRole('textbox', { name: /password/i }).type('correct-horse-battery-staple', { log: false });
cy.findByRole('button', { name: /sign in/i }).click();
cy.location('pathname').should('include', '/app');
});
it('browses the list', () => {
cy.visit('/app/items');
cy.findByRole('heading', { name: 'Items' }).should('be.visible');
});
it('opens an item details page', () => {
cy.visit('/app/items');
cy.findByTestId('item-row-123').click();
cy.location('pathname').should('eq', '/app/items/123');
});
});Prefer “start at the step” tests for depth, keep one full journey smoke test
A practical split is:
- One smoke test that runs the entire journey to catch broken wiring across pages.
- Focused tests that start at the relevant page/route and validate one step deeply (validation errors, edge cases, permissions).
This reduces flakiness and makes failures more actionable: you immediately know which step is broken.
Extract repeated sequences into helper functions (not shared state)
If you repeat a sequence (like “open item details”), extract it into a function that performs actions and asserts the milestone. Avoid returning DOM elements for later use; instead, re-query when needed so Cypress can retry.
const openItemDetails = (id) => {
cy.visit('/app/items');
cy.findByTestId(`item-row-${id}`).click();
cy.location('pathname').should('eq', `/app/items/${id}`);
cy.findByRole('heading', { name: new RegExp(`item ${id}`, 'i') }).should('be.visible');
};
it('submits a request from item details', () => {
openItemDetails(123);
cy.findByRole('button', { name: /request change/i }).click();
cy.findByRole('dialog', { name: /request change/i }).should('be.visible');
});Worked example: sign in → browse list → open details → submit a form → confirm success state
This example shows a realistic workflow with checkpoints and route/milestone assertions. Adapt selectors to your app’s stable attributes and accessible roles.
Single end-to-end smoke test for the full journey
describe('User journey: sign in → browse → details → submit → success', () => {
it('completes the multi-step flow', () => {
// Step 1: Sign in
cy.visit('/signin');
cy.findByRole('textbox', { name: /email/i }).clear().type('user@example.com');
cy.findByRole('textbox', { name: /password/i }).type('correct-horse-battery-staple', { log: false });
cy.findByRole('button', { name: /sign in/i }).click();
// Checkpoint A: Signed in
cy.location('pathname').should('match', /^\/app/);
cy.findByTestId('user-menu').should('be.visible');
// Step 2: Browse list
cy.contains('a', 'Items').click();
// Checkpoint B: List loaded
cy.location('pathname').should('eq', '/app/items');
cy.findByRole('heading', { name: 'Items' }).should('be.visible');
cy.findByTestId('items-list').find('[data-testid="item-row"]').its('length').should('be.gte', 1);
// Step 3: Open details (choose a known row id for determinism)
cy.findByTestId('item-row-123').click();
// Checkpoint C: Details opened
cy.location('pathname').should('eq', '/app/items/123');
cy.findByRole('heading', { name: /item 123/i }).should('be.visible');
// Step 4: Submit a form (e.g., request change)
cy.findByRole('button', { name: /request change/i }).click();
cy.findByRole('dialog', { name: /request change/i }).should('be.visible');
cy.findByRole('textbox', { name: /reason/i }).clear().type('Need to update the delivery date.');
cy.findByRole('combobox', { name: /priority/i }).select('High');
// Optional file upload if present
cy.findByLabelText(/attachment/i).then(($input) => {
if ($input.length) {
cy.wrap($input).selectFile('cypress/fixtures/sample.pdf');
cy.findByTestId('attachment-list').should('contain.text', 'sample.pdf');
}
});
cy.findByRole('button', { name: /submit/i }).should('be.enabled').click();
// Checkpoint D: Form submitted and success feedback appears
cy.findByRole('alert').should('contain.text', 'Request submitted');
// Checkpoint E: Success state reflected in UI
cy.findByTestId('status-badge').should('contain.text', 'Pending');
});
});Splitting the same journey into focused, independent tests
Below is one way to split the journey while keeping setup centralized. Each test starts from a known route and asserts a clear milestone.
describe('Items workflow (split tests)', () => {
beforeEach(() => {
cy.visit('/signin');
cy.findByRole('textbox', { name: /email/i }).type('user@example.com');
cy.findByRole('textbox', { name: /password/i }).type('correct-horse-battery-staple', { log: false });
cy.findByRole('button', { name: /sign in/i }).click();
cy.findByTestId('user-menu').should('be.visible');
});
it('shows the items list', () => {
cy.visit('/app/items');
cy.location('pathname').should('eq', '/app/items');
cy.findByTestId('items-list').find('[data-testid="item-row"]').its('length').should('be.gte', 1);
});
it('opens item details from the list', () => {
cy.visit('/app/items');
cy.findByTestId('item-row-123').click();
cy.location('pathname').should('eq', '/app/items/123');
cy.findByRole('heading', { name: /item 123/i }).should('be.visible');
});
it('submits a request form on the details page', () => {
cy.visit('/app/items/123');
cy.findByRole('heading', { name: /item 123/i }).should('be.visible');
cy.findByRole('button', { name: /request change/i }).click();
cy.findByRole('dialog', { name: /request change/i }).should('be.visible');
cy.findByRole('textbox', { name: /reason/i }).clear().type('Need to update the delivery date.');
cy.findByRole('combobox', { name: /priority/i }).select('High');
cy.findByRole('button', { name: /submit/i }).click();
cy.findByRole('alert').should('contain.text', 'Request submitted');
cy.findByTestId('status-badge').should('contain.text', 'Pending');
});
});