Automating a GUI you can read but cannot change: the command queue and retry-ability, building selectors that survive a redesign, finding what a control actually listens for in Chrome DevTools, driving a div that pretends to be a checkbox, and handling native dialogs and application modals correctly.
Subject: Cypress Test Automation · 65 slides · code lesson
Open the interactive version of this deck · Homework for this lesson
Title
Session 1
Automating a GUI you can read but cannot change
Objectives
You are writing against someone else's framework, on a machine where the app source is not visible. So everything here works from the rendered DOM and the DevTools panels, which is all you actually have.
Cypress Documentation — the reference for every command in this deck
Section
Section 1
Warm-up
Two minutes before any explanation.
Discussion prompt
Have you written a test where a value you assigned came out undefined, or where a click seemed to happen before the page was ready? Describe one.
Hint: When exactly did your assignment statement run, relative to the click?
Answer:
Both symptoms have the same cause: your test function finishes running before any Cypress command has executed.
The function body only enqueues work. Cypress runs the queue afterwards. Everything confusing about Cypress traces back to that one fact.
Cypress Documentation — the Introduction to Cypress guide states this explicitly
Concept
Writing a cy command does not do anything. It appends an instruction to a queue. When your test function returns, Cypress starts working through that queue.
command queue — The ordered list of instructions your test body builds. Cypress executes it after the body has finished running.
Cypress Documentation — Introduction to Cypress
Picture it
Your code runs first and enqueues. Cypress runs second and executes.
Figure (svg): A pipeline of four Cypress commands being enqueued, with a note that the test body finishes before any of them execute
Concept
The classic first bug. The assignment runs during the enqueue pass, when nothing has been fetched yet.
let count = 0;
cy.get('tbody tr').then(($rows) => {
count = $rows.length; // runs later, during the queue
});
cy.log(count); // enqueued now, but reads the old value
// works: keep the value inside the callback, or use an alias
cy.get('tbody tr').its('length').as('rowCount');
cy.get('@rowCount').should('eq', 3);| line | when it runs | value of count |
|---|---|---|
| let count = 0 | enqueue pass | 0 |
| cy.get(...).then(...) | enqueued | 0 |
| cy.log(count) | enqueued, argument evaluated now | 0 |
| the then callback body | queue pass | 3 |
Cypress Documentation — Variables and Aliases
Prediction
Three rows exist in the table.
Predict first
What number appears in the Cypress log?
Correct: 0.
Why: The argument to cy.log was evaluated during the enqueue pass, when count was still 0. The then callback did set it to 3, but that happened later, during the queue pass, and the log had already captured the old value.
Concept
Cypress retries the last query in a chain until the assertion after it passes, or the timeout runs out. The default is four seconds.
Actions and callbacks do not retry. Neither does a value you already pulled out of the DOM. This is the rule that decides where waiting belongs.
Cypress Documentation — Retry-ability
Picture it
Left column retries. Right column fires once and moves on.
Figure (svg): Two panels listing Cypress commands that retry, such as get, contains, find and should, against commands that run once, such as click, type, then and wait
This is why a well-written Cypress test almost never needs an explicit wait: the assertion is the wait.
Trap
The modal takes a moment to appear, so the test sleeps.
Annotate
| machine | modal appears after | result |
|---|---|---|
| your laptop | 300ms | passes, wastes 1.7s |
| loaded CI runner | 2400ms | fails |
| after a slow deploy | 3100ms | fails |
Ship a test that is both slow and flaky
Why: The fixed wait is simultaneously too long on a fast machine and too short on a slow one.
Let the assertion do the waiting.
cy.get('[data-cy=delete]').click();
cy.get('[data-cy=confirm-modal]').should('be.visible');| machine | modal appears after | result |
|---|---|---|
| your laptop | 300ms | passes in 300ms |
| loaded CI runner | 2400ms | passes in 2.4s |
| after a slow deploy | 3100ms | passes in 3.1s |
Get a test that is as fast as the app and as patient as it needs to be
Why: The assertion retries until it is true, so the test takes exactly as long as reality requires.
Elimination
The row status changes from pending to healthy some time after a click.
Eliminate the wrong options
Which line waits properly?
Survives elimination: A
Why: The should assertion makes Cypress re-run the preceding query until the text matches or the timeout expires. That is the whole waiting mechanism, and reaching past it is what makes suites flaky.
Pattern
Every command in this deck follows the same three-part shape.
Query, assert, act. If a test is flaky, the missing piece is almost always the middle one.
Cypress Documentation — Retry-ability and Best Practices
Check
Solve it on paper before you click.
Check your understanding
A button becomes enabled a second or two after the page loads. Which line handles that correctly?
Answer: A
Why: The assertion retries the query until the button is enabled, then the click happens on an element that is genuinely ready. The waiting and the readiness check are the same line.
Section
Section 2
Concept
A selector copied from DevTools describes a position in the tree. Add a row above it and the selector points at something else.
A selector built from a dedicated test attribute describes identity. It survives restyling, reordering and refactoring, because none of those change what the element is.
Cypress Best Practices — selecting elements, and why UI-coupled selectors break — the Cypress team's own ranking of selector strategies
Picture it
Longer bar means it survives more kinds of change.
Figure (svg): Five bars ranking selector strategies by stability, with data-cy attributes highest and nth-child paths lowest
If the team will not add test attributes, role and accessible name is the next best thing, and it has the side benefit of failing when accessibility breaks.
Sorting
The team is about to migrate the CSS framework.
Sort into buckets
Stable, or brittle?
Note the third one: it is stable and it doubles as an accessibility check, which is often the easiest way to get test attributes approved.
Concept
Open DevTools, focus the Elements panel, and press the find shortcut. The box accepts a plain string, a CSS selector, or an XPath expression, and it walks the matches.
This is the fastest way to answer the only question that matters before writing a command: does my selector match exactly one thing?
Chrome DevTools — Console Utilities API reference — Chrome's DevTools reference for the panels and the console helpers
Concept
The Console has selector helpers built in. They are the quickest possible check that a selector is neither empty nor over-broad.
$$('[role=checkbox]').length // 3
$$('[data-cy=asset-row]').length // 3
$0 // the node selected in Elements
getEventListeners($0) // what the app is listening for| helper | what it returns | use it to |
|---|---|---|
| $$(selector) | an array of matching nodes | check the count before writing cy.get |
| $(selector) | the first matching node | grab one node to poke at |
| $0 | the node currently selected in Elements | chain off whatever you just inspected |
| getEventListeners($0) | the listeners attached to that node | learn which event actually drives the control |
The last one is the important one for this session, and it is Chrome-only.
Chrome DevTools — Console Utilities API reference — the full list of console utilities
Picture it
This is the loop you run for every unfamiliar control.
Figure (svg): A five step vertical flow from inspecting the control, through searching the Elements panel and confirming the count in the Console, to reading the listeners and writing the command
Worked example
You need the control in the first cell of the row for relay-07.
Inspect it, then read upwards
Why: The node itself may be anonymous. Its ancestors usually are not, and the row almost always carries an identifier.
Search the Elements panel for a candidate
Why: Type the candidate selector into the find box and read the match counter.
// in the Console, confirm what the panel showed
$$('tr[data-row-id] [role=checkbox]').length // 3, one per row
$$('[data-row-id="relay-07"] [role=checkbox]').length // 1| candidate selector | matches | verdict |
|---|---|---|
| [role=checkbox] | 3 | too broad on its own |
| tr [role=checkbox] | 3 | same three, no narrower |
| [data-row-id=relay-07] [role=checkbox] | 1 | exactly one, and named by data |
Write it as a cy command only now
Why: One match confirmed in the live DOM means the command cannot be ambiguous.
Verify: that the count is exactly one
Why: Zero means the selector is wrong. More than one means Cypress will throw as soon as you try to act on it, because actions require a single element.
Picture it
Type the candidate into the Elements find box and read the number on the right.
Figure (svg): The DevTools Elements panel showing a DOM tree with three highlighted matches for the selector role equals checkbox and a counter reading three of three
Narrow it until the counter says one of one. Only then write the cy command.
Discrimination
Matching the question to the panel saves most of the time you currently spend hunting.
Sort into buckets
Elements panel, or Console?
Check
Solve it on paper before you click.
Check your understanding
Your selector matches four elements and you call .click() on it. What happens?
Answer: A
Why: Action commands require a single subject. Cypress raises an error naming the count, which is a much better failure than silently picking one. Use .first(), .eq(n) or a narrower selector to resolve it.
Section
Section 3
Concept
Component libraries frequently render an interactive control as a plain element carrying a role attribute, a tabindex, and an aria-checked state. It looks like a checkbox and it behaves like one for a keyboard user, but it is not an input.
That means it has no checked property, no change event, and nothing for Cypress's own check command to work with.
W3C WAI-ARIA Authoring Practices Guide — the Checkbox pattern Checkbox Pattern — the spec these components are implementing
Picture it
Three rows, each with a box in the first column. The middle one has keyboard focus.
Figure (svg): A browser window showing a table of three assets, each row beginning with a checkbox-styled control, with the second row focused
Picture it
The same control, in the DOM.
Figure (svg): A DOM tree from table down through tbody and a row with a data-row-id attribute to a table cell containing a div with role checkbox
That highlighted node is what every command in this section targets.
Prediction
The control is a div with role of checkbox.
Predict first
What happens when you call cy.check() on it?
Correct: Cypress throws.
Why: The check command is defined only for input elements of type checkbox or radio. Given anything else it raises an error saying so. That error is helpful: it tells you immediately that you are dealing with a custom control rather than leaving you to wonder why nothing toggled.
Concept
Do not guess between click, keydown, keyup and keypress. Ask the DOM.
// select the control in Elements, then in the Console:
getEventListeners($0)
// a typical answer for a keyboard-driven custom control:
// { keydown: [ { useCapture: false, ... } ],
// focus: [ ... ],
// blur: [ ... ] }
// note: no click listener at all| listener present | what to send | cypress command |
|---|---|---|
| click | a real click | .click() |
| keydown only | a keydown carrying the key | .trigger('keydown', { key: ' ' }) |
| keyup only | a keyup | .trigger('keyup', { key: ' ' }) |
| none on the node | check the ancestors: the row may be delegating | query the ancestor instead |
If the listener list is empty, the handler is bound higher up and the events are bubbling to it. Walk up the tree and check the row.
Chrome DevTools — Console Utilities API reference — getEventListeners is a Chrome console utility, not a JavaScript API
Worked example
The listeners show keydown and nothing else, so a click will never reach the handler.
Query the row by its data attribute, then the control inside it
Why: The row identifier is the stable part; the control is found relative to it.
Focus the control first
Why: The application's handler is on the control, so it has to be the active element for a keyboard event to make sense.
Send the exact event the listener expects
Why: The trigger command dispatches a real DOM event with whatever properties you give it.
cy.get('[data-row-id="relay-07"]')
.find('[role=checkbox]')
.should('have.attr', 'aria-checked', 'false')
.focus()
.trigger('keydown', { key: ' ', code: 'Space', keyCode: 32, which: 32 })
.should('have.attr', 'aria-checked', 'true');| step | state before | state after |
|---|---|---|
| query and assert | aria-checked is false | unchanged, but confirmed |
| focus | not the active element | is the active element |
| trigger keydown | aria-checked is false | handler runs |
| assert | handler has run | aria-checked is true |
Verify: with the aria-checked assertion at the end
Why: This is the only real proof the toggle worked. A passing trigger proves an event was dispatched, not that anything listened to it.
Invariant
Four commands, and only one of them touches the application state.
Step through it
Which single frame would look different if the app removed its keydown listener?
Only the third. Everything else would carry on exactly as before, which is precisely why the final assertion is not optional.
Error analysis
A first attempt at the same control. It goes green, and it is worthless.
Annotate
The fix is two lines: name the row, and assert the state afterwards.
This is the most dangerous failure mode in UI automation: a green test that verifies nothing, quietly protecting nothing at all.
Trap
Clicking did nothing, so the next instinct is to force it.
Annotate
| what happened | what the test reported |
|---|---|
| a click event was dispatched | passed |
| no listener existed for click | passed |
| aria-checked never changed | passed |
Ship a test that can never fail
Why: Forcing removes the checks that would have told you the click was pointless.
Find the event the app listens for, send that, and assert the state.
cy.get('[data-row-id="relay-07"]').find('[role=checkbox]')
.focus()
.trigger('keydown', { key: ' ', code: 'Space', keyCode: 32 })
.should('have.attr', 'aria-checked', 'true');| what happened | what the test reported |
|---|---|
| a keydown was dispatched | continues |
| the app's handler ran | continues |
| aria-checked became true | passed, and meant it |
Ship a test that fails when the feature breaks
Why: Which is the only kind worth having.
Comparison
Same appearance, different everything. Fill in the gaps.
Comparison matrix
| input type=checkbox | div with role=checkbox | |
|---|---|---|
| how state is stored | the checked property | the aria-checked attribute |
| cypress command | .check() | .trigger() with the right event |
| assertion | .should('be.checked') | .should('have.attr', 'aria-checked', 'true') |
| responds to a plain click | always | only if the app added a click listener |
When you meet an unfamiliar control, filling in this table for it is the fastest way to know what to write.
Socratic
Worth understanding, because it tells you what else to expect from the same codebase.
Discussion prompt
Why do component libraries replace a native checkbox with a styled div, and what does that choice cost them?
Hint: What can you do to a div that you cannot do to a native checkbox?
Answer:
Because native form controls are famously hard to style consistently across browsers. Replacing them buys complete visual control.
What it costs is everything the native element gave away for free: keyboard handling, focus behaviour, the accessibility tree, and form submission. The library has to reimplement all of it, and the ARIA attributes are that reimplementation.
For you this predicts the rest of the app. If the checkbox is custom, the dropdowns, tabs and dialogs almost certainly are too, and they will all need the same treatment.
W3C WAI-ARIA Authoring Practices Guide — the Checkbox pattern — the pattern documents exactly which keys and attributes a custom control owes
Fill the middle
The trigger is written. The proof is missing.
Fill in the blanks
cy.get('[role=checkbox]').first()
.trigger('keydown', { key: ' ' })
.should('have.attr', 'aria-checked', 'true');
Why: Space is the toggle key in the ARIA checkbox pattern, and aria-checked is where the state lives. Without the assertion the test proves only that an event was dispatched, which is never the thing you wanted to know.
Section
Section 4
Concept
A native dialog produced by the browser is chrome, not content. It has no DOM nodes, so no selector can ever reach it.
A modal rendered by the application is ordinary DOM. Every normal command works on it.
The first question about any dialog is therefore which kind it is, and the DevTools Elements panel answers it in one look.
Cypress Documentation — Cypress catalog of events, window:alert and window:confirm
Picture it
They can look nearly identical on screen and behave nothing alike under test.
Figure (svg): Two panels comparing a native browser dialog which is not in the DOM against an application rendered modal which is real DOM
Concept
Cypress automatically accepts a confirm and dismisses an alert, so a native dialog will not hang your test. What you usually want is to assert on its text.
// assert on the text of a native confirm, which Cypress auto-accepts
cy.on('window:confirm', (text) => {
expect(text).to.equal('Delete this asset?');
});
cy.get('[data-cy=delete]').click();
// to make it cancel instead, return false
cy.on('window:confirm', () => false);| dialog | cypress default | how to control it |
|---|---|---|
| window.alert | dismissed automatically | listen on window:alert to read the text |
| window.confirm | accepted automatically | listen on window:confirm; return false to cancel |
| window.prompt | not handled | stub it in onBeforeLoad before the page runs |
Cypress Documentation — the window:alert and window:confirm events
Prediction
Alerts and confirms work fine, but a page that calls window.prompt freezes the run.
Predict first
What is different about prompt?
Correct: Cypress does not handle window.prompt automatically.
Why: Alerts are dismissed and confirms are accepted for you, but prompt needs a return value that Cypress cannot invent. You stub it on the window object before the application code runs, using the onBeforeLoad hook of cy.visit.
Worked example
The page asks for a reason before deleting. Cypress will not answer that for you.
Stub the function before the app can call it
Why: The onBeforeLoad hook runs after the window exists and before any page script executes, which is the only window in which this works.
Give the stub the value the test needs
Why: Now the application receives a real answer and carries on.
cy.visit('/assets', {
onBeforeLoad(win) {
cy.stub(win, 'prompt').returns('decommissioned').as('prompt');
},
});
cy.get('[data-cy=delete]').click();
cy.get('@prompt').should('have.been.calledWith', 'Reason for deletion?');| moment | window.prompt is | effect |
|---|---|---|
| before onBeforeLoad | the real browser function | would block the run |
| after onBeforeLoad | a stub returning a string | returns immediately |
| when the app calls it | the stub | app receives the reason |
| at the assertion | the stub, with recorded calls | arguments can be checked |
Verify: by asserting on the stub's arguments
Why: Aliasing the stub means you can prove the application asked the question you expected, not just that it asked something.
Picture it
Before the window exists there is nothing to stub. After the page scripts run it is too late.
Figure (svg): A timeline from cy.visit through window creation and the onBeforeLoad hook to the page scripts running, with the stub window marked
The same hook is how you stub anything else the app reads off the window at startup: a feature flag, a clock, a geolocation call.
Concept
For a modal rendered by the application, there is nothing special to learn. Query it, assert it is visible, act inside it.
cy.get('[data-cy=delete]').click();
cy.get('[data-cy=confirm-modal]')
.should('be.visible')
.within(() => {
cy.contains('Delete this asset?');
cy.get('[data-cy=confirm]').click();
});
cy.get('[data-cy=confirm-modal]').should('not.exist');| line | why it is there |
|---|---|
| should be.visible | the retry window that waits for the animation to finish |
| within | scopes every command inside it to the modal, so a stray match elsewhere cannot be picked up |
| contains | asserts the message, which is usually the actual requirement |
| should not.exist | proves the modal closed, which is the half people forget |
Cypress Documentation — the within command
Two truths and a lie
Three claims about the app-rendered modal above.
Eliminate the wrong options
Which one holds?
Survives elimination: A
Why: Checking that the modal disappeared is the assertion most people skip, and modals that stay open after confirming are one of the most common real defects in this kind of interface.
Matching
Four situations you will hit in this app.
Match the pairs
Why: Three of the four need no selector at all, because native dialogs are not part of the page. Only the last one is ordinary DOM work, and that is exactly why telling the two kinds apart is the first step.
Section
Section 5
Concept
Assertions cover most waiting. The exception is when you need to know that a request finished, or what it returned, rather than what it eventually drew.
Intercept names the request, and then you can wait on that name instead of on a duration.
Cypress Documentation — the intercept command
Concept
Three lines, and the fixed wait disappears from the spec entirely.
cy.intercept('GET', '/api/assets').as('assets');
cy.visit('/assets');
cy.wait('@assets').its('response.statusCode').should('eq', 200);
cy.get('[data-cy=asset-row]').should('have.length', 3);| line | what it does | why not a sleep |
|---|---|---|
| intercept and as | names the request before it is made | the name is stable; the duration is not |
| visit | loads the page, firing the request | - |
| wait on the alias | blocks until that request completes | as fast as the network actually is |
| its response.statusCode | reads the real response | a sleep could never tell you this |
| should have.length | asserts what got rendered | retries on top of the completed request |
Note the ordering: the intercept has to be registered before the request is made, so it goes above the visit.
Cypress Documentation — intercept and aliases
Prediction
A test loads the page first and registers the intercept afterwards.
Predict first
What happens to the wait on that alias?
Correct: It times out.
Why: An intercept only sees requests made after it is registered. Putting it below the visit means the request fired unwatched, and the wait sits there until the timeout looking for a request that will never come again.
Trade off
Not one right answer. Fill in what each one costs.
Comparison matrix
| approach | what it gives you | what it costs |
|---|---|---|
| click() | the most realistic interaction | does nothing if there is no click listener |
| trigger() | exact control over the event and its properties | it is a synthetic event, so it can pass where a real user would fail |
| cypress-real-events | genuine browser-level input through the CDP | an extra dependency, and Chromium browsers only |
Start with click, because it is honest. Move to trigger when the listeners tell you the app is not listening for clicks. Reach for real events only when a synthetic one demonstrably is not enough.
Missing information
The ticket says: automate selecting a row and deleting it, and verify the confirmation.
Discussion prompt
Before writing a line, what three things do you need that the ticket does not say?
Hint: Every one of them is a question about the DOM, not about the requirement.
Answer:
Which event the row control listens for, and therefore whether a click will do anything at all.
Whether the confirmation is a native browser dialog or an application modal, because the two need completely different code.
What identifies the row and the control stably, since a copied selector will break the first time the table changes.
All three are answerable in about two minutes in DevTools, and none of them is answerable by reading the ticket again.
Explain it
Two sentences, out loud.
Discussion prompt
Why does a Cypress test almost never need an explicit wait?
Hint: What is Cypress doing between the moment a query fails and the moment it gives up?
Answer:
Because an assertion makes Cypress re-run the query in front of it, over and over, until the assertion passes or the timeout expires.
So the assertion is the wait. Adding a sleep on top of it only makes the test slower on fast machines without making it any safer on slow ones.
Cypress Documentation — Retry-ability
Commit first
Answer, then rate your confidence. Confident and wrong is the pair worth finding before it costs you a day.
Predict first
A test asserts should('not.exist') on a modal immediately after clicking confirm. The modal has a 400ms close animation. Does the test pass?
Correct: Yes.
Why: The not.exist assertion retries just like every other one, so Cypress keeps re-querying for up to the default four seconds and passes as soon as the element is gone. Animations are exactly the case retry-ability was built for.
Section
Section 6
Concept
Three tests, covering the three things this session set out to solve.
describe('Asset table', () => {
beforeEach(() => cy.visit('/assets'));
it('toggles a row with the keyboard', () => {
cy.get('[data-row-id="relay-07"]').find('[role=checkbox]')
.should('have.attr', 'aria-checked', 'false')
.focus()
.trigger('keydown', { key: ' ', code: 'Space', keyCode: 32 })
.should('have.attr', 'aria-checked', 'true');
});
it('confirms a native delete dialog', () => {
cy.on('window:confirm', (t) => expect(t).to.equal('Delete this asset?'));
cy.get('[data-row-id="relay-07"]').find('[data-cy=delete]').click();
cy.contains('[data-row-id="relay-07"]').should('not.exist');
});
it('waits out the app modal without a fixed wait', () => {
cy.get('[data-cy=bulk-delete]').click();
cy.get('[data-cy=confirm-modal]').should('be.visible').within(() => {
cy.get('[data-cy=confirm]').click();
});
cy.get('[data-cy=confirm-modal]').should('not.exist');
});
});| test | the risk it covers | the assertion that proves it |
|---|---|---|
| keyboard toggle | the control ignores clicks | aria-checked flips to true |
| native dialog | the confirm text changed | the text equals the expected string |
| app modal | the modal never closes | the modal no longer exists |
Cypress Documentation — every command used here
Picture it
Three tests, about a second, no fixed waits anywhere.
Figure (svg): A terminal showing a Cypress run with three passing tests for the asset table completing in one second
Ranking
The next time a control will not respond, run these in this order.
Put in order
Why: Identify, then confirm, then listen, then act, then prove. Most wasted time comes from jumping straight to step four and guessing at the event, which is a search over a space you could have just read.
Pattern
Five rules that cover everything here.
| symptom | the cause, nearly always |
|---|---|
| value is undefined | read outside the callback |
| flaky on CI, fine locally | a fixed wait instead of an assertion |
| passes but nothing happened | forced click, or no state assertion |
| cypress throws on the action | the selector matched more than one node |
| test hangs | an unstubbed prompt |
Cypress Best Practices — selecting elements, and why UI-coupled selectors break — the same advice from the people who wrote the tool
Check
Solve it on paper before you click.
Check your understanding
In the keyboard toggle test, which line is the one that would fail if the feature broke?
Answer: A
Why: Only the final assertion looks at the outcome. Everything before it describes what the test did, and all of it would keep succeeding on a completely broken control.
Exit ticket
One honest answer, and it sets the agenda for next time.
Predict first
Which of these is still the murkiest?
Correct: Whichever you named opens the next session.
Why: All five are separable and all five are drillable. If the answer is the last one, that is the best possible use of a session, because it is the skill that makes every other problem in this list solvable without help.
Connect it up
The highest-value twenty minutes you can spend before the next session.
Draw it
List every distinct control type in the screen you are testing. Beside each, write its role attribute, the event its listeners actually take, and the attribute that holds its state. Mark the ones you had to guess.
The guesses are the agenda. Bring the list.
Recap
Five things, and the third one is the one that will save you the most time this week.
| you want to | write |
|---|---|
| toggle a custom checkbox | focus() then trigger('keydown', { key: ' ' }) |
| prove it toggled | should('have.attr', 'aria-checked', 'true') |
| read a native confirm | cy.on('window:confirm', (t) => ...) |
| cancel a native confirm | cy.on('window:confirm', () => false) |
| answer a prompt | cy.stub(win, 'prompt') inside onBeforeLoad |
| work inside a modal | get(modal).should('be.visible').within(...) |
Cypress Documentation — keep this open in a tab while you write
Want this taught 1-on-1? Alexander tutors Cypress Test Automation — $55/session, free consultation.