We were walking through the E2E test structure with two developers new to the project. I pulled up a file in the Cypress folder, and one of them said something that stuck with me: “Wait… where’s the cy.get? Where are the selectors?”
That’s the right question to ask. And it’s exactly why I spend time thinking about how tests are written, not just whether they pass.
What You See in Most Tutorials
If you look up any Cypress tutorial or open a default Cypress example, you’ll see something like this:
cy.visit('/inventory')
cy.get('[data-testid="lot-row"]').first().click()
cy.get('.mat-input').clear().type('100')
cy.get('[data-testid="submit-btn"]').click()
cy.url().should('include', '/inventory')
It works. It automates the browser. But read it again: the first thing your eyes land on is CSS selectors and implementation details. You have to mentally translate what’s happening before you can think about why it matters.
On this project, the same scenario reads more like this:
given_I_have_permission_to_adjust_inventory()
and_there_are_inventory_lots()
and_I_access_inventory()
when_I_choose_a_lot_to_adjust()
and_I_adjust_to_a_given_quantity(100)
then_I_see_the_lot_adjusted_successfully()
No CSS. No cy.get. And notice: no mention of pages, buttons, or the system. What you see is what the person is doing, described entirely in business terms. The implementation stays hidden: which pages are visited, which selectors are used, how the data flows.
One refactoring I find myself suggesting often when working with developers new to this approach: they’ll write given_there_are_inventory_lots_in_the_system() or given_there_are_inventory_lots_in_the_database(). Both carry implementation language. “System” and “database” are technical artifacts. They reflect what the developer is aware of, not what the person doing the work cares about. The person just needs there to be inventory lots. The step name should say exactly that: given_there_are_inventory_lots().
Where the Plumbing Actually Lives
This doesn’t mean the Cypress code disappears. It means it lives somewhere appropriate: inside a context class.
Every feature has a context class that holds all the implementation details: the selectors, the cy.get calls, the cy.type, the URL patterns. The test file reads like a scenario; the context class reads like a Cypress implementation. Both have their place. They just don’t belong in the same file.
Here’s what a method inside the context class might look like:
and_I_adjust_to_a_given_quantity(quantity) {
cy.get('[data-cy="adjust-quantity-input"]')
.clear()
.type(quantity.toString())
cy.get('[data-cy="submit-adjustment"]').click()
}
That detail is real, and it’s necessary. But someone reading the test doesn’t need to see it to understand the behavior being verified. They see: “I adjust to a given quantity.” That’s the level of abstraction that matters when you’re reviewing a scenario.
One Place to Fix
There’s a practical reason beyond readability. When you scatter cy.get('.mat-input-2').type(...) across dozens of test files, you’re coupling your tests to UI implementation. If that input changes to a different class, or the component gets replaced, you’re touching dozens of files.
With a context class, there’s exactly one place to go. Update when_I_adjust_to_a_given_quantity, and every test that calls it inherits the fix.
I’ve heard people argue that updating tests after a UI change is fine. And it is, sometimes. But there’s a difference between updating a test because the expected behavior changed and updating thirty selectors because a component library upgraded. Only one of those is meaningful work.
Stubbing Without Exposing It
The same principle applies to how we handle API interception. Cypress has cy.intercept for stubbing network calls, and it’s great. But we don’t call it directly in the test. There’s a wrapper in the context class that knows the right endpoint, sets up the stub, and assigns an alias for later verification.
From the test’s perspective, it looks like:
given_there_are_adjustment_reasons()
Inside the context, that method sets up the intercept, returns the stubbed data, and gives the rest of the scenario a stable handle to reference if needed. The test never sees the endpoint URL or the fixture shape unless it needs to verify a specific payload.
When the test does need to verify what was submitted, there’s a helper for that too:
then_the_adjustment_was_submitted_correctly()
This internally compares the UI’s output against the contract we defined. If the UI drifts, the test catches it. If the contract changes, the backend test catches the other side.
Specs First, Tests Second
One thing I try to remind people is that these aren’t really “tests” in the traditional sense. They’re specs: specifications of behavior. Testing is the byproduct. I’ve explored that framing in more depth in a companion post, Specs, Not Tests: A Better Way to Think About Automated Verification.
The reason that framing matters: when you think of them as specs, you stop writing them after you’ve shipped the feature and start writing them before. Or alongside. You describe what the behavior should be, then implement it until the spec passes.
What I’ve seen happen when teams write Cypress tests in the raw style, with selectors and cy.get calls inline, is that the tests become a burden. They’re brittle. They fail for reasons unrelated to behavior. People stop trusting them.
When the test reads like a scenario, it’s easier to maintain trust in what it’s telling you. A failure in when_I_adjust_to_a_given_quantity is telling you something about the adjustment flow, not about a mismatched selector on a control you barely remember adding.
The Test Doesn’t Know the Implementation
The goal is for the test to survive a significant UI refactoring without changing.
If we implement a feature with one page and later split it into two, the spec shouldn’t care. The spec says: I have permission, I’m in the inventory section, I adjust a lot. If that now takes two pages instead of one, the context class absorbs that change. The spec stays the same.
That’s the kind of stability that makes test suites worth maintaining over time. Not because tests are easy to write, but because the right abstraction separates what we’re verifying from how we’re verifying it.
I cover this in more depth in Testing Behavior, Not Implementation, including why even “natural language” testing tools can miss the mark when the language stays tied to UI mechanics.
Where This Points
For the project we discussed during that session, we’ve been tracking test counts and their distribution during several sprints. Over time, the ratio of frontend, backend, and end-to-end tests has shifted as the product has matured. E2E tests have stayed around 5% of the total suite. Slow to run, but always present. Every feature has at least one.
What’s changed recently is that AI tooling has made writing these tests faster. The context class pattern, once understood, becomes something an AI can extend reliably. Describe the scenario, point at the context, and it knows where to add the new method.
The abstraction originally about human readability turns out to be the one that also makes AI-assisted test generation more accurate. The English-first surface is a clean interface, for people and for tools.
That feels like the right place to land.





Leave a Reply