# Cypress and Selenium

> Two more ways to test in a browser: Cypress's developer-friendly in-browser runner, and Selenium/WebDriver, the long-standing cross-language standard.


---

# Cypress and Selenium

You need to test a real app in a real browser, and somebody told you to "use Cypress" or "set up Selenium" without telling you what either of them actually is or why they're different. One runs inside the browser and gives you a gorgeous time-travel debugger. The other has been the cross-language industry standard since before most of your dependencies existed. They solve the same problem from opposite ends, and picking wrong means months of fighting your tools.

This guide gives you the mental model for both, shows you what writing and running tests actually feels like, and tells you plainly where each one breaks so you can choose without regret.

## How to read this

Read the phases in order the first time. Phase 1 builds the two mental models side by side so the rest makes sense. Phase 2 is the hands-on core: what real tests look like in each tool. Phase 3 is the reality check: limits, flake, scaling, and how both compare to Playwright. If you already know one tool, skim phase 1 and slow down on phase 3.

## The phases

1. [Two Tools, Two Architectures](01-two-tools-two-architectures.md) - the mental model: in-browser runner vs. the W3C standard.
2. [Writing and Running Tests](02-writing-and-running-tests.md) - what real Cypress and Selenium tests look like day to day.
3. [Limits, Flake, and Choosing](03-limits-flake-and-choosing.md) - where each breaks, how to scale, and how they stack up against Playwright.


---

# Two Tools, Two Architectures

Here's the situation you're probably in. You've got a web app. You want a test that opens a real page, clicks a button, fills a form, and checks that the right thing appears on screen. That's end-to-end testing, and if the words *unit*, *integration*, and *end-to-end* still feel fuzzy, the [unit-integration-e2e](/guides/unit-integration-e2e) guide draws the boundaries. This guide is about two specific tools for the *e2e* end of that spectrum: Cypress and Selenium.

People talk about them as if they're interchangeable. They're not. They're built on fundamentally different ideas about *where the test code lives relative to the browser*, and almost every difference in how they feel, what they can do, and where they fall apart traces back to that one architectural choice. Get this mental model right and the rest of the guide is detail.

## The one question that explains everything

When your test says "click the Submit button," something has to reach into the browser and make that click happen. The question is: **where does the code doing the reaching actually run?**

- **Cypress runs your test code *inside* the browser**, in the same run loop as your app. The test and the app share a process. When you ask Cypress to click something, it's not sending a command across a wire - it's already in the room.
- **Selenium runs your test code *outside* the browser**, as a separate program that sends commands to the browser over a network protocol. Your test is in one process; the browser is in another; a standardized protocol carries instructions between them.

That's it. That's the fork in the road. Hold onto it.

```text
  CYPRESS                          SELENIUM / WEBDRIVER
  ┌─────────────────────┐         ┌──────────┐   protocol   ┌──────────┐
  │  Browser            │         │ Your test│ ───────────► │ Browser  │
  │  ┌────────┐ ┌─────┐ │         │ (any     │   (HTTP/     │ (driver  │
  │  │ Your   │ │ App │ │         │  language)│    JSON)     │  inside) │
  │  │ test   │ │     │ │         └──────────┘ ◄─────────── └──────────┘
  │  └────────┘ └─────┘ │
  └─────────────────────┘
```

*What just happened:* the left box is one process - test and app together. The right side is two processes talking over a wire. Every tradeoff below grows out of this picture.

## What Selenium actually is (and why it's everywhere)

Selenium is old, and that's a compliment. It predates almost every modern testing tool, and over the years its remote-control protocol became **WebDriver** - a formal W3C standard. That word *standard* is the whole point.

Because WebDriver is a published spec, the browser vendors themselves ship the piece that obeys it. Chrome has `chromedriver`, Firefox has `geckodriver`, Edge has `msedgedriver`, Safari has one built in. Your Selenium test sends standard WebDriver commands; the browser's own driver carries them out. Nobody at the Selenium project has to reverse-engineer Chrome - Google maintains the Chrome side, Mozilla maintains the Firefox side.

Two big consequences fall out of being a cross-process standard:

- **Every language.** Since the test only speaks a protocol, the client library can be written in anything. Selenium has official bindings for Java, Python, JavaScript, C#, Ruby, and more. A Java shop writes Selenium in Java; a Python shop in Python. Same protocol underneath.
- **Every real browser, including remote ones.** The browser doesn't have to be on your machine. Point your test at a remote WebDriver endpoint and you're driving a browser somewhere else - which is exactly how **Selenium Grid** (more on that in phase 3) lets you run hundreds of tests across many browsers and machines at once.

```python
from selenium import webdriver
from selenium.webdriver.common.by import By

driver = webdriver.Chrome()              # launches a real Chrome via chromedriver
driver.get("https://example.com")
driver.find_element(By.NAME, "q").send_keys("hello")
driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()
print(driver.title)
driver.quit()                            # always quit - it's a separate process
```

*What just happened:* this Python program is not the browser. It launched a real Chrome, then sent it a sequence of WebDriver commands - navigate, find, type, click - and read the page title back across the protocol. The `driver.quit()` matters because the browser is a separate process that won't clean up after itself.

## What Cypress actually is (and why developers fall for it)

Cypress took the opposite bet. Instead of standing outside and shouting commands, it loads your app in a browser and **injects your test code into that same browser**. The test executes alongside your application, sharing the same JavaScript run loop and DOM.

Living inside the browser buys Cypress a set of conveniences that are genuinely hard to get from the outside:

- **Automatic waiting.** Cypress knows when the app is mid-render because it's right there. Commands retry until the thing you asked for exists or a timeout hits, so you rarely write explicit waits. This alone kills a huge category of timing flake (see the [flaky-tests](/guides/flaky-tests) guide).
- **Time-travel debugging.** The Cypress runner UI shows every command as a step, and you can hover over each one to see a *snapshot* of the DOM at that exact moment. Test failed on step 7? Hover step 6 and see what the page looked like before it broke.
- **Direct access to the app's internals.** Because it shares the process, Cypress can stub network calls, reach into app state, and assert on things an outside tool can't easily touch.

```javascript
describe('search', () => {
  it('finds a result', () => {
    cy.visit('https://example.com')
    cy.get('input[name=q]').type('hello')   // auto-waits for the input to exist
    cy.get('button[type=submit]').click()
    cy.contains('Results for hello')         // auto-retries until it appears or times out
  })
})
```

*What just happened:* no driver, no `quit`, no explicit waits. The `cy.*` commands queue up and run inside the browser; each one retries on its own until the DOM cooperates. That ergonomic difference is the whole reason developers reach for Cypress.

> The Cypress experience is so smooth that teams adopt it before checking whether its architecture fits their app. Phase 3 is where that bill comes due - same-origin limits and the single-tab model are direct costs of living inside the browser. The convenience and the constraints are the *same coin*.

## For builders: the tradeoff in one sentence

Cypress trades architectural reach for developer experience; Selenium trades developer experience for architectural reach. Cypress is a delight to write and debug but boxed in by the browser it lives in. Selenium can drive anything in any language but makes you assemble more of the machine yourself and manage the flake that comes with talking over a wire. Neither is "better" - they're optimized for different worlds, and phase 2 shows you what each world feels like to actually work in.

```quiz
[
  {
    "q": "What is the single architectural difference that explains most other differences between Cypress and Selenium?",
    "choices": [
      "Cypress is newer than Selenium",
      "Cypress runs test code inside the browser; Selenium runs it outside and sends commands over a protocol",
      "Cypress only supports JavaScript while Selenium supports CSS",
      "Selenium has a nicer debugging UI"
    ],
    "answer": 1,
    "explain": "Cypress shares the browser process with the app; Selenium is a separate process speaking the WebDriver protocol. Nearly every other difference follows from this."
  },
  {
    "q": "Why can Selenium tests be written in Java, Python, C#, Ruby, and more?",
    "choices": [
      "Selenium ships a compiler for each language",
      "The browser translates the test at runtime",
      "WebDriver is a standard protocol, so the client library can be written in any language",
      "Selenium converts everything to JavaScript first"
    ],
    "answer": 2,
    "explain": "Because the test only needs to speak the WebDriver protocol, the client binding can be implemented in any language. The browser's own driver obeys the standard."
  },
  {
    "q": "Where does Cypress's automatic waiting come from?",
    "choices": [
      "It guesses fixed sleep durations between commands",
      "It runs inside the browser and can retry commands until the DOM is ready",
      "It polls a remote server for readiness",
      "The W3C spec mandates it"
    ],
    "answer": 1,
    "explain": "Living in the same process as the app, Cypress sees render state directly and retries each command until it succeeds or times out - no manual sleeps."
  }
]
```


---

# Writing and Running Tests

Phase 1 gave you the why. Now the day-to-day. You'll spend most of your time doing four things: finding elements, acting on them, waiting for the app to catch up, and asserting that something is true. Each tool has a distinct rhythm for those four, and once you feel the rhythm, both tools become predictable.

We'll walk the same little flow in each - open a page, search, check a result - and point out where the experience genuinely diverges.

## Getting each one running

Cypress installs as a single npm dev dependency and brings its own bundled browser plus a runner. There's no separate driver to manage.

```bash
npm install --save-dev cypress
npx cypress open      # opens the interactive runner with time-travel UI
npx cypress run       # headless, for CI - prints results to the terminal
```

*What just happened:* one install gave you the test framework, the assertion library, and a browser, all wired together. `open` is for writing and debugging; `run` is what your CI uses.

Selenium needs two pieces: a client library in your language and the browser's driver. Modern Selenium can fetch the matching driver for you (Selenium Manager), which removes the old headache of version-matching `chromedriver` to your Chrome by hand.

```bash
pip install selenium          # the Python client binding
# No manual chromedriver download - Selenium Manager resolves it on first run.
python my_test.py             # runs as a plain program; pair with pytest for structure
```

*What just happened:* Selenium isn't a test runner - it's a browser-control library. You bring your own test framework (pytest, JUnit, NUnit) to organize and report. That's more assembly, and also more freedom.

## Finding elements: the shared vocabulary

Both tools locate elements with familiar selectors - CSS selectors most of the time, occasionally XPath or by-text. The mental difference is what you get *back*.

Selenium returns an **element object right now**. If the element isn't there yet, you get an error unless you've set up waiting (next section).

```python
from selenium.webdriver.common.by import By

el = driver.find_element(By.CSS_SELECTOR, "input[name=q]")   # found now, or raises
el.send_keys("hello")
```

*What just happened:* `find_element` resolved immediately to a concrete handle. There's no retry baked in - the lookup is a single attempt at that instant.

Cypress returns a **chainable subject that retries**. `cy.get` doesn't hand you an element so much as a promise-like queue that keeps re-querying until the element shows up.

```javascript
cy.get('input[name=q]').type('hello')   // re-queries until the input exists, then types
```

*What just happened:* you never held a stale handle. Cypress kept asking the DOM for `input[name=q]` until it appeared, then typed into it. This is the ergonomic gap you'll feel most.

> A durable habit across both tools: select by a dedicated test attribute like `data-test="search-input"`, not by CSS classes or text that designers change weekly. Selectors tied to styling are the quiet source of half your future breakage.

## Waiting: the part that separates calm tests from flaky ones

This is where the architecture from phase 1 shows up most. In Cypress, waiting is mostly automatic and you rarely write it. In Selenium, **you must wait deliberately**, and the single biggest mistake new Selenium users make is reaching for a fixed sleep.

Here is the wrong way and the right way, side by side:

```python
import time
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.by import By

# WRONG - a fixed sleep is either too short (flaky) or too long (slow)
time.sleep(5)
driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()

# RIGHT - an explicit wait polls until the condition is true, then proceeds
wait = WebDriverWait(driver, timeout=10)
btn = wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, "button[type=submit]")))
btn.click()
```

*What just happened:* the sleep gambles on a guess and loses both ways. The explicit wait polls the page repeatedly for up to ten seconds and continues the instant the button is clickable - fast when the app is fast, patient when it's slow. If your Selenium suite is flaky, this pattern fixes most of it. The [flaky-tests](/guides/flaky-tests) guide goes deeper on why fixed sleeps are a trap.

Selenium also offers an **implicit wait** (a global default applied to every `find_element`), but mixing implicit and explicit waits causes confusing timeouts. Pick explicit waits and stick with them.

Cypress, by contrast, handles this for you because it's inside the browser:

```javascript
cy.get('button[type=submit]').click()    // already waits for existence + actionability
cy.contains('Results for hello')          // retries the assertion until it passes or times out
```

*What just happened:* both lines retry internally. You wrote zero wait code and got the polling behavior Selenium made you spell out. Avoid `cy.wait(5000)` for the same reason you avoid `time.sleep` - it's the one anti-pattern Cypress can't save you from.

## Asserting: same idea, different ergonomics

Selenium hands the value back to your test framework, and you assert with that framework's tools.

```python
assert "Results for hello" in driver.page_source
title = driver.title
assert title == "Example Domain"
```

*What just happened:* Selenium gave you raw values; pytest's `assert` did the checking. The assertion runs once, against whatever the page held at that instant - which is why the *wait* before it matters so much.

Cypress folds assertions into the same retrying chain, so the assertion and the waiting are one motion.

```javascript
cy.get('[data-test="result"]').should('contain.text', 'Results for hello')
cy.title().should('eq', 'Example Domain')
```

*What just happened:* `should` doesn't check once - it retries the whole `get` + assertion until it passes or the timeout fires. The wait and the check are the same operation, which is the core reason Cypress assertions feel less brittle.

## A full Cypress test, end to end

```javascript
describe('product search', () => {
  beforeEach(() => {
    cy.visit('/search')
  })

  it('shows matching products', () => {
    cy.get('[data-test="search-input"]').type('keyboard')
    cy.get('[data-test="search-button"]').click()
    cy.get('[data-test="result-card"]').should('have.length.at.least', 1)
    cy.contains('[data-test="result-card"]', 'keyboard')
  })
})
```

*What just happened:* `beforeEach` reset state before the test, then the body searched and asserted. Every command auto-waited, so there isn't a single explicit timing line - that's a representative, readable Cypress test.

## The same flow in Selenium with pytest

```python
import pytest
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

@pytest.fixture
def driver():
    d = webdriver.Chrome()
    yield d
    d.quit()                                  # teardown - the separate process must be closed

def test_shows_matching_products(driver):
    driver.get("http://localhost:3000/search")
    wait = WebDriverWait(driver, 10)
    driver.find_element(By.CSS_SELECTOR, '[data-test="search-input"]').send_keys("keyboard")
    driver.find_element(By.CSS_SELECTOR, '[data-test="search-button"]').click()
    cards = wait.until(EC.presence_of_all_elements_located(
        (By.CSS_SELECTOR, '[data-test="result-card"]')))
    assert len(cards) >= 1
    assert "keyboard" in cards[0].text.lower()
```

*What just happened:* the `driver` fixture owns startup and the all-important `quit`. The test reads almost like the Cypress one, but you supplied the framework (pytest), the explicit wait, and the cleanup yourself. More wiring, and in exchange you could swap `pytest` for Java + JUnit and run the identical idea in another language.

## In the wild

Real suites layer two habits on top of these basics. First, the **Page Object pattern**: wrap each page's selectors and actions in a class (`SearchPage.search("keyboard")`) so a redesign changes one file, not fifty tests. Both tools use it; Selenium teams especially live by it. Second, **stub the network for speed and determinism** - Cypress does this natively with `cy.intercept`, while Selenium leans on a proxy or backend test mode. A test that hits the real backend is a test that fails when the backend has a bad day.

```quiz
[
  {
    "q": "Why is `time.sleep(5)` an anti-pattern in Selenium tests?",
    "choices": [
      "Selenium forbids importing the time module",
      "A fixed sleep is either too short (flaky) or too long (slow); an explicit wait polls and proceeds as soon as the condition is met",
      "It only works in Python, not Java",
      "Sleeps disable the WebDriver protocol"
    ],
    "answer": 1,
    "explain": "Fixed sleeps guess at timing and lose both ways. WebDriverWait polls until the condition holds, so it's fast when the app is fast and patient when it's slow."
  },
  {
    "q": "What is the practical difference between Selenium's find_element and Cypress's cy.get?",
    "choices": [
      "find_element resolves once immediately (or errors); cy.get returns a chainable subject that retries until the element appears",
      "They are identical in behavior",
      "cy.get only works with XPath",
      "find_element automatically waits but cy.get does not"
    ],
    "answer": 0,
    "explain": "find_element is a single attempt at that instant. cy.get keeps re-querying the DOM until the element exists or the timeout fires - that's Cypress's built-in waiting."
  },
  {
    "q": "Why must a Selenium test call driver.quit() (often in a fixture teardown)?",
    "choices": [
      "To save the test report to disk",
      "Because the browser runs as a separate process that won't clean itself up",
      "It resets CSS selectors for the next test",
      "It is required by the assert statement"
    ],
    "answer": 1,
    "explain": "Selenium drives a browser in a separate process. Without quit, those browser processes leak and pile up across a run."
  }
]
```


---

# Limits, Flake, and Choosing

Every tool is wonderful in the demo. The question that actually matters is where it hurts at 2am when the suite is red and you can't tell if the app broke or the test did. This phase is the clear-eyed map: where Cypress hits walls it can't climb, where Selenium leaks flake, how each one scales, and how the whole picture shifts now that Playwright exists. By the end you'll be able to choose on purpose.

## Where Cypress hits a wall

Remember phase 1: Cypress lives inside the browser. That gift is also the cage. The big limits are direct consequences of sharing the browser process, and no amount of config makes them fully disappear.

- **Same-origin restriction.** Historically a single Cypress test could only operate within one origin (scheme + domain + port). Newer Cypress relaxes this: the `cy.origin()` command lets a test visit a second origin by running a block of commands in that origin's context. It works, but you have to wrap that code deliberately - cross-origin flows like third-party OAuth logins are extra effort, not free.
- **One browser tab, one window.** Cypress cannot drive multiple tabs or a second browser window. A flow that opens a new tab (a "share" popup, a payment window) can't be followed there. The usual workaround is to test the *intent* - assert the link has the right `target` and `href` - rather than the new tab itself.
- **JavaScript/TypeScript only.** Tests are JS or TS, full stop. A Python or Java shop can't write Cypress in their main language. That's fine if your team lives in the JS world and a real constraint if it doesn't.
- **Browser coverage is narrower.** Cypress runs in Chromium-family browsers, Firefox, and (with caveats and version dependence) WebKit-based browsers. It is not the "every browser, every version" story that WebDriver is.

```text
Cypress can't follow a new tab:

  [Test in tab A] --click "Open report"--> [tab B opens]
        |                                       ✗ Cypress stays in tab A
        └── workaround: assert href/target, then visit that URL directly in tab A
```

*What just happened:* the test never crosses into tab B. Instead of chasing the new tab, you verify the link is correct and, if you need to test the destination, `cy.visit()` that URL in the original tab.

## Where Selenium leaks flake

Selenium's pain is the mirror image: the cross-process protocol that gives it superpowers also opens a gap where timing problems breed. The test and the browser are not in sync by default, so **flake is the tax you pay for reach.**

The usual culprits:

- **Timing.** The test fires a command before the page is ready, or reads state a moment too early. This is the explicit-wait discipline from phase 2 - get it right and most flake evaporates.
- **Stale element references.** You grabbed an element handle, the page re-rendered, and now the handle points at a DOM node that no longer exists. Selenium raises `StaleElementReferenceException`. The fix is to re-find the element right before you use it rather than holding handles across actions.
- **Environment drift.** Driver version vs. browser version mismatches once caused endless grief; Selenium Manager has largely tamed this, but headless-vs-headed differences and OS quirks still surprise people.

```python
from selenium.common.exceptions import StaleElementReferenceException

# Re-find instead of reusing a handle across a re-render
def click_fresh(driver, by, selector):
    el = driver.find_element(by, selector)
    el.click()
```

*What just happened:* by looking the element up immediately before clicking, you sidestep the stale-reference trap that comes from caching a handle while the DOM churns underneath it. Flake of this kind is a deep topic - the [flaky-tests](/guides/flaky-tests) guide is worth your time if a suite is fighting you.

## Scaling up: Grid and parallelism

When one machine isn't enough, the two tools scale differently.

**Selenium Grid** is the classic answer and a direct payoff of the protocol design. A Grid has a hub that distributes tests and many nodes, each offering browsers. Because your test only speaks WebDriver to a remote endpoint, you point it at the Grid and it runs your suite across many browsers and machines in parallel - different OSes, different browser versions, all at once.

```python
driver = webdriver.Remote(
    command_executor="http://grid-hub:4444/wd/hub",   # the Grid, not localhost
    options=webdriver.ChromeOptions(),
)
```

*What just happened:* the only change from a local run is the endpoint. Because Selenium was always talking over the protocol, aiming it at a remote Grid is a one-line difference - that portability is the architecture paying dividends.

Cypress scales mainly by **splitting specs across CI machines** and running them in parallel, with its dashboard service balancing the load. It's effective, but it's parallelism-by-sharding rather than a true distributed browser farm, and broad cross-browser, cross-OS matrices are more Selenium's home turf.

## How both compare to Playwright

You can't choose between Cypress and Selenium fairly without naming the third player, because Playwright reshaped the decision. Playwright is a newer tool that, in a sense, takes the best of both architectures: like Selenium it's an **out-of-process** driver, so it isn't boxed into one tab or one origin; like Cypress it has **auto-waiting and excellent developer experience** built in.

A fair, qualitative comparison:

| Dimension | Cypress | Selenium / WebDriver | Playwright |
|---|---|---|---|
| Architecture | In-browser | Out-of-process (W3C standard) | Out-of-process |
| Auto-waiting | Yes, built in | No - explicit waits | Yes, built in |
| Languages | JS / TS only | Java, Python, C#, Ruby, JS, more | JS/TS, Python, Java, .NET |
| Multiple tabs / origins | Constrained | Native | Native |
| Browser coverage | Narrower | Widest (vendor drivers) | Chromium, Firefox, WebKit |
| Standard | Proprietary runner | W3C WebDriver | Own protocol (uses CDP-style control) |
| Sweet spot | JS apps, great DX, simple flows | Polyglot teams, broad matrices, legacy | Modern apps wanting DX + reach |

*What just happened:* Playwright collapses the old tradeoff - auto-waiting *and* multi-tab *and* multiple languages. That's why many new projects pick it. Cypress still wins for some teams on debugging feel and ecosystem; Selenium still wins where the W3C standard, sheer browser breadth, or an existing investment matters.

## So which do you choose?

Decide from your constraints, not from hype:

- **Reach for Cypress** when your team is JavaScript/TypeScript, your flows stay within one origin and one tab, and you value the time-travel debugger and gentle learning curve over breadth.
- **Reach for Selenium** when you need a non-JS language, the widest possible browser and OS matrix, a real distributed Grid, or you're maintaining a suite that already exists. The W3C-standard footing is a long-term bet that ages well.
- **Seriously consider Playwright** for a greenfield project, since it sidesteps Cypress's tab/origin limits while keeping the auto-waiting ergonomics - it's the default many teams now start from.

> The deepest mistake isn't picking the "wrong" tool - it's picking a tool whose *architecture* fights your app's reality. A multi-tab, multi-origin checkout flow will quietly punish a Cypress team for a year. A small JS app driven by a hand-rolled Selenium Grid is a maintenance burden nobody needed. Match the architecture to the shape of what you're testing, and the tool mostly disappears into the background - which is exactly what a good test tool should do.

```quiz
[
  {
    "q": "Why can't a single Cypress test naturally follow a flow that opens a second browser tab?",
    "choices": [
      "Cypress lacks a click command",
      "Cypress runs inside one browser context and cannot drive multiple tabs or windows",
      "New tabs are blocked by the WebDriver standard",
      "It requires a paid plan to enable"
    ],
    "answer": 1,
    "explain": "Living inside the browser limits Cypress to a single tab/window. The common workaround is to assert the link's href/target and visit the URL directly instead."
  },
  {
    "q": "What does Selenium Grid let you do that follows directly from WebDriver being a protocol?",
    "choices": [
      "Write tests without any selectors",
      "Run tests against remote browsers across many machines and OS/browser combos by pointing at a remote endpoint",
      "Eliminate all flake automatically",
      "Convert Selenium tests into Cypress tests"
    ],
    "answer": 1,
    "explain": "Because the test only speaks WebDriver to an endpoint, swapping localhost for a Grid hub distributes the suite across many remote browsers - a near one-line change."
  },
  {
    "q": "How does Playwright relate to the Cypress-vs-Selenium tradeoff?",
    "choices": [
      "It is just a rebrand of Selenium",
      "It runs inside the browser exactly like Cypress",
      "It is out-of-process like Selenium (multi-tab/origin, multiple languages) but adds auto-waiting and DX like Cypress",
      "It only works for mobile apps"
    ],
    "answer": 2,
    "explain": "Playwright is an out-of-process driver - so no single-tab/origin cage and multiple language bindings - while keeping built-in auto-waiting and strong developer experience."
  }
]
```
