When I joined iMedia24 the test suite had about thirty Cypress specs. Well-organized, well-named, readable. They also false-positived on things that were clearly working and missed regressions that caused real incidents.
The custom command library was the problem. It was clever. It abstracted DOM interactions behind named operations, which made tests readable at the top level but fragile underneath. A small DOM change could break the abstraction silently.
What boring looks like
My tests read like instructions you'd give to a first-time user of the app:
hljs jsit('user can log in', () => {
cy.visit('/login')
cy.get('[data-testid="email-input"]').type('test@example.com')
cy.get('[data-testid="password-input"]').type('password123')
cy.get('[data-testid="submit-btn"]').click()
cy.url().should('include', '/dashboard')
cy.get('[data-testid="user-greeting"]').should('be.visible')
})
it('shows error on wrong password', () => {
cy.visit('/login')
cy.get('[data-testid="email-input"]').type('test@example.com')
cy.get('[data-testid="password-input"]').type('wrongpassword')
cy.get('[data-testid="submit-btn"]').click()
cy.get('[data-testid="error-message"]').should('contain', 'Invalid credentials')
cy.url().should('not.include', '/dashboard')
})
No custom commands. No Page Object Model. No shared selector files. Just the steps, in order.
This violates DRY. I genuinely don't care. Test code has a different job than production code. Production code is optimised for extension. Test code is optimised to fail loudly and obviously when the thing it tests breaks.
Why data-testid
Not CSS classes — they change for styling reasons and nobody thinks of the tests when renaming them. Not text content — breaks with copy changes. Not XPath — unreadable and fragile.
data-testid attributes exist only for testing. Nobody removes them for aesthetic reasons during a refactor. They stay stable.
The "they pollute production HTML" objection doesn't hold up. They're invisible to users and irrelevant to scrapers. The cost is negligible against the benefit of selectors that don't break.
One test, one flow
Each spec covers exactly one user journey. Not "the auth section." One flow: login, the core happy path, payment, logout.
If I had to pick five tests and nothing else:
- Login with valid credentials
- Core feature, happy path
- Account settings change that persists after refresh
- Payment or upgrade flow
- Logout
Everything else is secondary. Get these five right before adding anything else.
Why it ends up being faster
Boring tests fail in obvious ways. The failure message says:
Expected to find element: [data-testid="submit-btn"], but never found it
Open the app. Find the submit button. Check if the data-testid attribute was renamed during a refactor. Fifteen seconds.
Clever tests fail mysteriously. The custom command behaves differently in a specific viewport. The shared fixture was stale. The Page Object method was correct for Component A but breaks because Component B reuses the same selector pattern.
A test that's easy to debug is worth twice a test that never fails.
Writing boring tests is also fast. No learning curve for a command library. No decisions about Page Object structure. Open the file, write the steps, close the file. Twenty minutes instead of two hours.
The one rule
Test what the user sees, not what the code does. Assert on visible text, on URL changes, on elements that appear and disappear. Not on component state. Not on internal data structures. Not on whether a function was called.
The user doesn't know about your state machine. They know that clicking a button closes a modal. Test the modal closing. The implementation can change as many times as it needs to; the test stays true.
