DEV Community

Cover image for WICK-DOM-OBSERVER: The Deterministic Cypress Plugin for Fast Spinners, Blinking Toasts, Optional Overlays, and UI’s Most Wanted
Sebastian Clavijo Suero
Sebastian Clavijo Suero

Posted on

WICK-DOM-OBSERVER: The Deterministic Cypress Plugin for Fast Spinners, Blinking Toasts, Optional Overlays, and UI’s Most Wanted

Because the most dangerous UI behaviors are the ones that leave almost no trace.


ACT 1: EXPOSITION

 

There are some UI elements that seem to exist for one noble purpose only: to make your Cypress tests miserable.

I am talking about the spinner that appears and disappears so absurdly fast that by the time Cypress gets there, it is already gone.

The toast that politely informs the user that something happened, but only for a tiny and inconvenient window of time.

And, of course, the annoying modal or overlay that shows up on page load only when it feels like it, blocks the whole page, and leaves your test suite wondering whether it should close it... or pretend it never existed.

These are not exotic edge cases. These are real UI behaviors we deal with all the time.

And the worst part is not even testing them. The worst part is testing them reliably.

Because traditional approaches usually push you toward one of these painful paths:

  • cy.wait(500) and pray.
  • Force the UI to behave unnaturally just for the test.
  • Add network intercepts and cross your fingers, hoping that somehow will be enough.
  • Write fragile logic that still flakes the moment the DOM decides to move just a little faster than usual.
  • And do not even get me started on what happens when all that reaches your project’s notoriously unpredictable CI pipeline.

That is exactly why I built wick-dom-observer.

⭐⭐⭐⭐⭐ Its mission is simple: help Cypress detect and validate those rebel UI elements that appear too fast, disappear too fast, or appear only sometimes, without turning your tests into a dramatic mess. ⭐⭐⭐⭐⭐


ACT 2: CONFRONTATION

 

Two commands. Same mission. Different entry point.

When the UI feedback is triggered after a click, use clickAndWatchForElement().

When the element may appear on its own, use watchForElement().
 

1) clickAndWatchForElement()

Use this one when the testing event starts with a user action: clicking a button, link, icon, or anything else that kicks off a spinner, toast, modal, or overlay.

cy.get(subject).clickAndWatchForElement(config)
cy.get(subject).clickAndWatchForElement(config, options)
cy.get(subject).clickAndWatchForElement(config, position)
cy.get(subject).clickAndWatchForElement(config, position, options)
cy.get(subject).clickAndWatchForElement(config, x, y)
cy.get(subject).clickAndWatchForElement(config, x, y, options)
Enter fullscreen mode Exit fullscreen mode

2) watchForElement()

Use this one when there is no click involved and the element may show up by itself, like page-load overlays, announcements, promos, or optional blocking panels.

cy.watchForElement(config)
Enter fullscreen mode Exit fullscreen mode

The shared config object

Both commands use the same config shape:

{
  selector: '...',
  assert: ($el) => { ... },
  action: ($el) => { ... },      // optional
  timeout: 4000,                 // optional
  appear: 'optional',            // 'optional' | 'required'
  disappear: false,              // optional
  pollingInterval: 10,           // optional
  mustLast: 800,                 // optional
}
Enter fullscreen mode Exit fullscreen mode

What each option does

  • selector → the element you want to watch in the DOM
  • assert → synchronous assertion callback executed when the element is found
  • action → optional synchronous callback to perform side effects after assert passes
  • timeout → max time to wait for appear / disappear phases
  • appear: 'optional' → valid if the element appears or not
  • appear: 'required' → fail if the element never shows up
  • disappear: false → validate appearance only; do not wait for disappearance
  • disappear: true → also wait for the element to go away
  • pollingInterval → how often the DOM is checked
  • mustLast → minimum time the element must remain visible in the DOM

Important: assert() and action() must stay synchronous.

No cy.get(), no cy.wrap(), no Cypress commands inside them.

This is plain JavaScript / jQuery territory.

The mental model

  • clickAndWatchForElement() = trigger the event, then watch the DOM
  • watchForElement() = just watch the DOM
  • mustLast = make sure the element did not vanish too quickly
  • disappear: true = validate the full lifecycle, not just the appearance

Case 1: The super-fast spinner that mocks your test

 

The Mark

You click a button to load data.

A spinner appears, a loading label is shown, the button is disabled for a moment, the spinner disappears, and then the data is displayed.

Sounds easy enough to test, right?

What if the spinner only shows for 200ms? Or 100ms? Better yet, 25ms?

Because if that spinner is fast enough, this classic pattern can betray you:

it('shows the loading spinner and then displays the data', () => {
  cy.visit('/your-page')

  cy.get('[data-cy="load-data-btn"]').click()

  cy.get('[data-cy="service-spinner"]').should('be.visible')
  cy.get('[data-cy="loading-label"]')
    .should('be.visible')
    .and('contain', 'Fetching latest campaign analytics')

  cy.get('[data-cy="load-data-btn"]').should('be.disabled')

  cy.get('[data-cy="service-spinner"]').should('not.exist')
  cy.get('[data-cy="loading-label"]').should('not.exist')
  cy.get('[data-cy="load-data-btn"]').should('be.enabled')

  cy.get('[data-cy="campaign-table"]').should('be.visible')
  cy.get('[data-cy="campaign-row"]').should('have.length.greaterThan', 0)
})
Enter fullscreen mode Exit fullscreen mode

Why? Because Cypress starts looking after the click has already happened, and if the spinner had a very short life in the DOM, you may miss it entirely.

So now you have a flaky test for a UI behavior that absolutely happens.
 

The Takedown

const assertLoadCampaignDataSpinner = ($el) => {
  // The spinner must be visible when observed.
  expect($el).to.be.visible()

  // Move to a stable parent scope so we can inspect related elements
  // that participate in the same loading flow.
  const $root = $el.closest('body')

  // Validate the loading label shown while data is being fetched.
  const loadingLabel = $root.find('[data-cy="loading-row"] .loading-label')
  expect(loadingLabel).to.be.visible()
  expect(loadingLabel.text()).to.contain('Fetching latest campaign analytics')

  // Validate that the trigger button is temporarily disabled
  const loadDataBtn = $root.find('[data-cy="load-data-btn"]')
  expect(loadDataBtn).to.be.visible()
  expect(loadDataBtn.prop('disabled')).to.eq(true)
}

it('shows the loading spinner and then displays the data', () => {
  cy.visit('/modal-table-demo.html')

  // Click the trigger button and start observing the DOM beforehand to catch the spinner
  cy.get('[data-cy="load-data-btn"]').clickAndWatchForElement({
    // Element to observe after the click.
    selector: '[data-cy="service-spinner"]',

    // The spinner is expected to appear in this flow.
    appear: 'required',

    // The spinner is also expected to disappear before the command finishes.
    disappear: true,

    // Max time (ms) to wait for the spinner to appear or disappear.
    timeout: 5000,

    // Run the custom assertions while the spinner is present.
    assert: assertLoadCampaignDataSpinner,
  })

  cy.get('[data-cy="loading-label"]').should('not.be.visible')
  cy.get('[data-cy="load-data-btn"]').should('be.enabled')

  cy.get('[data-cy="campaign-table"]').should('be.visible')
  cy.get('[data-cy="campaign-row"]').should('have.length.greaterThan', 0)
})
Enter fullscreen mode Exit fullscreen mode

 

Why the Hit Lands 👊

The key trick is that clickAndWatchForElement() starts observing before the click happens, and an additional secret sauce.

And that matters a lot.

Cypress is already playing its own game here: its command queue runs asynchronously and processes commands at its own pace, while plain JavaScript, jQuery, and the DOM keep moving on a different timeline. So if you click first and only then ask Cypress to check for the spinner, there is a very real chance the DOM already did its thing and moved on.

That is why wick-dom-observer flips the sequence around: it prepares the observation flow first, performs the click, and then keeps observing the DOM to catch the exact moment the spinner appears and the exact moment it disappears.

What is the secret sauce? The MutationObserver interface.

It is the browser API designed to watch for changes in the DOM tree, which makes it a perfect fit for tracking fast UI elements.

In other words, this is not just about watching earlier. It is about watching on the right timeline.

So the example above using clickAndWatchForElement() validates the full user-facing behavior:

  • the spinner appears,
  • the loading label is visible and contains the expected text,
  • the action button is temporarily disabled,
  • and finally, the spinner disappears and the button is enabled again.

That is much closer to testing the real visual completion of the interaction than simply stubbing a network call and assuming everything on screen behaved correctly.

If you want to inspect how Cypress queued and executed everything around that flow, the cypress-flaky-test-audit plugin is a very good partner in crime.

If you feel even more reckless and want to understand how those two timelines actually interact — the Cypress queue and plain JavaScript — then you may want to venture into The Async Nature of Cypress: Don't Mess with the Timelines in Your Cypress Tests 'Dual-Verse'.


Case 2: The toast that tries to disappear before the witness sees it

 

The Mark

A toast is supposed to confirm that something important happened.

But if it appears and disappears too quickly, then from a user perspective it might as well have whispered the message into the void.

And from a testing perspective, this creates a second problem: it is not enough to prove the toast appeared. You may also want to prove it stayed visible long enough to actually matter.

That is exactly where mustLast enters the room.
 

The Takedown

// Assertion helper executed when the toast is detected.
// It validates the toast visibility and its success message.
const assertSuccessToast = ($el) => {
  // The toast must be visible while it is being observed.
  expect($el).to.be.visible()

  // The toast should contain the expected success message.
  expect($el.text()).to.contain('Campaign saved successfully')
}

it('validates that the success toast stays visible long enough', () => {
  cy.visit('/your-page')

  // Click the button and start observing the DOM beforehand
  // so the plugin can catch the toast as soon as it appears.
  cy.get('[data-cy="save-campaign-btn"]').clickAndWatchForElement({
    // Element to observe after the click.
    selector: '[data-cy="success-toast"]',

    // The toast is expected to appear in this flow.
    appear: 'required',

    // The toast is also expected to disappear before the command finishes.
    disappear: true,

    // Maximum time allowed for the full toast lifecycle.
    timeout: 8000,

    // Check the DOM very frequently so short-lived toasts are not missed.
    pollingInterval: 10,

    // Ensure the toast remains visible for at least 3 seconds.
    // This helps validate meaningful user-visible feedback, not just a blink.
    mustLast: 3000,

    // Run the custom assertions while the toast is present.
    assert: assertSuccessToast,
  })

  // After the lifecycle is complete, the toast should no longer exist in the DOM.
  cy.get('[data-cy="success-toast"]').should('not.exist')
})
Enter fullscreen mode Exit fullscreen mode

 

Why the Hit Lands 🔨

This one is different from the spinner case.

With a spinner, the challenge is often just to catch it at all before it disappears.

With a toast, the challenge is more subtle: you also want to prove it did not vanish too soon. In other words, the problem is not merely appearance, but meaningful visibility.

That is exactly what mustLast is for.

So this test does not just verify that the toast showed up. It verifies something far more useful: that it stayed visible for at least a minimum amount of time before disappearing.

That is important, because a toast that flashes too fast may technically exist in the DOM, but from a UX perspective it is almost the same as not being there at all.

So in this case, clickAndWatchForElement() validates the full behavior:

  • the toast appears,
  • it contains the expected message,
  • it remains visible for at least the required duration,
  • and then it disappears.

That is far more aligned with actual user-facing feedback than just confirming that a success event happened somewhere in the background.


Case 3: The optional page-load overlay that shows up only when it feels like it

 

The Mark

Now let’s talk about one of the most annoying UI patterns ever created:

A modal / overlay / promo banner / “helpful” announcement panel that appears on page load only some of the time.

Sometimes it shows up.

Sometimes it does not.

When it does, it blocks the page, and you need to discard.
When it does not, your test should continue normally.

This is exactly the kind of behavior that makes tests nondeterministic if you handle it with normal Cypress assertions.

Because this will fail when the overlay does not exist:

cy.get('[data-cy="ad-overlay"]').should('be.visible')
Enter fullscreen mode Exit fullscreen mode

And this is also not enough by itself:

cy.get('body').then(($body) => {
  if ($body.find('[data-cy="ad-overlay"]').length) {
    cy.get('[data-cy="close-ad-btn"]').click()
  }
})
Enter fullscreen mode Exit fullscreen mode

That kind of conditional logic often becomes ugly, fragile, and inconsistent very quickly.
 

The Takedown

// This overlay is one of those annoying guests that may or may not show up.
// If it appears, handle it. If it does not, move on without failing the test.

it('handles an optional startup overlay without flaking', () => {
  cy.visit('/modal-table-demo.html')

  // Observe startup banner modal that may or may not appear on page load (about 50%)
  cy.watchForElement({
    // Element to observe in the DOM.
    selector: '[data-cy="ad-overlay"]',

    // The overlay may or may not appear, and both outcomes are valid
    appear: 'optional',

    // We do not require the plugin itself to wait for the overlay to disappear
    // as part of the command lifecycle
    disappear: false,

    // Maximum time to wait for the optional overlay to show up
    timeout: 1500,

    // Check the DOM very frequently so the plugin can catch the overlay
    // as soon as it appears
    pollingInterval: 10,

    // If the overlay appears, validate its visible state and confirm
    // the close button is present
    assert: ($el) => {
      // If the overlay appears, validate its visible state and confirm
      // the close button is present
      expect($el).to.be.visible()
      expect($el.find('[data-cy="close-ad-btn"]')).to.be.visible()
    },
    action: ($el) => {
      // Close the overlay via DOM click and verify the same it is no longer visible
      const closeBtnEl = $el.find('[data-cy="close-ad-btn"]')[0]
      if (closeBtnEl) closeBtnEl.click()
      expect($el).to.not.be.visible()
    },
  })

  // Rest of your test code
  cy.get('[data-cy="load-data-btn"]').should('be.visible')
  // [...]
})
Enter fullscreen mode Exit fullscreen mode

 

Why the Hit Lands 🪏

This is where watchForElement() shines.

There is no click involved here. The command simply starts observing the DOM and waits to see whether the target element appears within the configured timeout.

And the crucial part is:

appear: 'optional'
Enter fullscreen mode Exit fullscreen mode

That means both outcomes are valid:

  • if the overlay appears, the plugin runs the assert callback and then the action,
  • if the overlay never appears, the test still passes and continues normally.

This is huge, because it lets you model a truly real-world UI behavior without lying to the test and without introducing flaky branching logic.

Also, notice the action callback. That is very convenient here because when the overlay is actually found and validated, you can immediately close it in a deterministic way and unblock the rest of the page.

So instead of asking “will the overlay appear this time?”, your test says:

“If it appears, validate it and close it. If it does not, fine, move on.”


Case 4: The full real-world mess — optional overlay + required spinner flow

 

The Mark

Now let’s combine some of these problems into one realistic test flow.

You open a page.

Half of the time, an annoying overlay appears and blocks the page.

After dealing with that, the user clicks a button that triggers a loading spinner and a temporary loading state.

In other words:

  • one UI element is optional,
  • another one is required,
  • one appears automatically,
  • the other appears after a click,
  • and both need to be handled in one deterministic test.

This is the kind of scenario where many tests start becoming fragile little monsters.
 

The Takedown

// Assertion helper for the spinner/loading state.
// It validates the spinner itself plus the related UI signals.
const assertLoadCampaignDataSpinner = ($el) => {
  expect($el).to.be.visible()

  const $root = $el.closest('body')

  const labelFetching = $root.find('[data-cy="loading-row"] .loading-label')
  expect(labelFetching).to.be.visible()
  expect(labelFetching.text()).to.contain('Fetching latest campaign analytics')

  const loadDataBtn = $root.find('[data-cy="load-data-btn"]')
  expect(loadDataBtn).to.be.visible()
  expect(loadDataBtn.prop('disabled')).to.eq(true)
}

it('handles optional overlay and validates the spinner flow', () => {
  // Open the demo page that contains both behaviors:
  // an optional blocking overlay on load and a spinner triggered by a button click.
  cy.visit('/modal-table-demo.html')

  // First, watch for the optional overlay that may or may not appear on page load.
  // If it appears, validate it and close it so the rest of the test can continue.
  cy.watchForElement({
    selector: '[data-cy="ad-overlay"]',
    appear: 'optional',
    disappear: false,
    timeout: 1500,
    pollingInterval: 10,
    assert: ($el) => {
      expect($el).to.be.visible()
      expect($el.find('[data-cy="close-ad-btn"]')).to.be.visible()
    },
    action: ($el) => {
      const closeBtnEl = $el.find('[data-cy="close-ad-btn"]')[0]
      if (closeBtnEl) closeBtnEl.click()
      expect($el).to.not.be.visible()
    },
  })

  // Then, trigger the load-data action and observe the spinner lifecycle.
  // The spinner is required to appear and later disappear, while the custom
  // assertion helper validates the loading state during that window.
  cy.get('[data-cy="load-data-btn"]').clickAndWatchForElement({
    selector: '[data-cy="service-spinner"]',
    appear: 'required',
    disappear: true,
    timeout: 10000,
    assert: assertLoadCampaignDataSpinner,
  })

  // Finally, confirm the loading flow completed successfully
  // and the UI returned to its interactive state.
  cy.get('.loading-label').should('not.be.visible')
  cy.get('[data-cy="load-data-btn"]').should('be.enabled')
})
Enter fullscreen mode Exit fullscreen mode

 

Why the Hit Lands 🪦

This example is the one I like the most because it represents the kind of ugly, imperfect, wonderfully realistic UI flow we often have to automate.

The plugin allows each moving part to be described according to its real behavior:

1) The overlay is treated as optional

It may appear or not, and the test remains stable in both cases.

2) The spinner is treated as required

Once the user clicks the button, the spinner must show up, pass the assertions, and disappear correctly.

3) The test still reflects what the user experiences

This is not a fake synchronization trick.

It is validating actual DOM behavior that the user would perceive:

  • a blocking overlay may interrupt the flow,
  • the user closes it,
  • the user click a button
  • then a loading state appears,
  • then it goes away,
  • then the UI becomes interactive again.

You are testing that the interface behaved properly under realistic conditions.

And yes, that includes those fast DOM transitions that traditional Cypress chaining can miss.


ACT3: RESOLUTION

 

Some UI elements are simply built like tiny rebels.

They show up too fast.

They disappear too fast.

They block your page unexpectedly.

They appear only half the time.

And then they sit there, very proudly, making your tests flaky and your patience shorter.

This is where wick-dom-observer turns those flaky little ghosts into observable DOM evidence.

It gives Cypress a much better chance to deal with these troublesome UI behaviors in a deterministic and elegant way, without forcing you into cy.wait(...) nonsense, awkward conditionals, intersects, or synthetic workarounds that do not really test real user experience.

So if your application contains:

  • fast spinners,
  • short-lived toasts,
  • startup overlays,
  • optional modals,
  • or any UI element that behaves like it does not respect authority...

then this plugin was made for you.

Because sometimes what your test suite really needs is not more waiting, not more retries, and definitely not more hope.

It needs a proper DOM observer.

You can find the plugin here:

GitHub repo: https://github.com/sclavijosuero/wick-dom-observer
npm: https://www.npmjs.com/package/wick-dom-observer


I'd love to hear from you! Please don't forget to follow me, leave a comment, or a reaction if you found this article useful or insightful. ❤️ 🦄 🤯 🙌 🔥

You can also connect with me on my new YouTube channel: https://www.youtube.com/@SebastianClavijoSuero

If you'd like to support my work, consider buying me a coffee or contributing to a training session, so I can keep learning and sharing cool stuff with all of you.
Thank you for your support!
Buy Me A Coffee

Top comments (0)