Why Tool Selection Still Happens on the Wrong Criteria
Most tool decisions I see defended in RFC documents rest on three arguments: the syntax feels nicer, the tool is popular right now, or the team already knows it. None of those survive the first six months of a real project. Syntax preference disappears after two weeks. Popularity tells you about hiring, not about whether the tool can drive two browser contexts at once. And “the team already knows it” is only an argument if what the team knows fits the application under test.
Three things actually determine the outcome: the architecture of the application you are testing, your CI budget, and the language competence of the people who will maintain the suite. Everything else — reporting, plugins, IDE integration — is replaceable. Those three are not. The criteria in this post are built on them, and each comparison row exists because it maps back to one of the three.
Where the Three Tools Actually Stand in 2026
Playwright is no longer the new option. Backed by Microsoft, with official bindings for TypeScript/JavaScript, Python, Java and .NET, and with tracing, HTML reporting, sharding and retries built into the runner rather than bolted on. It was designed to answer one question: how do you drive modern browsers from outside, over a single protocol connection, fast enough to run thousands of tests in parallel? That design goal explains almost everything else about it.
Cypress was designed to answer a different question: how do you give a frontend developer a debugging experience where tests run next to the application, in the same event loop, with time-travel snapshots? It solved that well and it is still the most pleasant local loop of the three. But the model that makes it pleasant is also what constrains it, and the company has pushed its investment toward the commercial cloud offering. If you need parallelization, orchestration and reporting, you are buying a service or replacing it yourself.
Selenium was designed to answer: how do you drive any browser, on any machine, through a vendor-neutral standard? That is still valuable. WebDriver is a W3C standard, WebDriver BiDi has closed much of the gap on bidirectional features like network interception and console log capture, and Grid remains the most mature answer to “I need a 40-browser matrix across three OS versions.” Selenium is not the fast choice. It is the compatible choice.
Architecture: Three Different Ways to Talk to a Browser
The differences people argue about — speed, auto-waiting, network mocking, multi-tab support — are not feature decisions. They are consequences of where the test code runs relative to the browser.

Cypress runs in the browser, so it gets synchronous DOM access and excellent snapshots — but it is bound by the browser’s own security model. Multiple origins need cy.origin, multiple tabs are not supported, and the runner cannot easily step outside the page.
Playwright holds one persistent WebSocket connection per browser. Every action, every network event and every console message travels over it. That is why interception is first-class, why browser contexts are cheap enough to isolate every test, and why a trace file can contain a full DOM snapshot timeline.
Selenium goes through a driver process over HTTP. Each command is a round trip, which costs latency but buys you a stable, standardized contract that vendors implement — including in environments where you cannot attach a debugging protocol.
Comparison Table: Thirteen Concrete Criteria
| Criterion | Playwright | Cypress | Selenium |
|---|---|---|---|
| Language support | TS/JS, Python, Java, .NET — same API surface | JS/TS only; no path out for a Java or C# team | Widest: Java, C#, Python, Ruby, JS, plus community bindings |
| Browser matrix | Bundled Chromium, Firefox, WebKit; installs its own builds, no arbitrary old versions | Chrome family, Firefox, Electron; WebKit support still limited | Anything with a WebDriver implementation, including Edge in IE mode and vendor-specific builds |
| Parallelization cost | Workers plus --shard in the open-source runner; scales with CI runners only | Needs an orchestration service (Cypress Cloud or third party) or manual spec splitting | Grid scales horizontally, but you own the infrastructure and its upkeep |
| Auto-waiting | Per-action actionability checks (visible, stable, enabled, receives events) | Automatic retry on queued commands; retries the assertion, not the actionability contract | None by default; you write explicit waits, and bad ones are the main flakiness source |
| Network mocking | route / fulfill on any request, including non-XHR resources | cy.intercept is strong for app traffic | Via BiDi/CDP interception; works, but verbose and driver-dependent |
| Multi-origin / multi-tab | Native: multiple contexts, pages, popups, origins in one test | cy.origin for origins; no real multi-tab | Native window and tab handles |
| Mobile web | Device emulation profiles, touch, geolocation | Viewport resizing only; no true emulation | Emulation via browser options; real devices through Appium |
| iframe / shadow DOM | Frame locators; shadow DOM pierced by default | iframes need workarounds or a plugin; shadow DOM needs includeShadowDom | Explicit frame switching; shadow roots reachable but awkward |
| Trace / visual debugging | Trace viewer with DOM snapshots, network, source — usable from CI artifacts | Best local experience; time-travel in the runner | Screenshots and logs; anything richer you build yourself |
| Reporting | HTML, JSON, JUnit built in; blob reports merge across shards | Cloud product is the intended path; open-source reporting is basic | Third-party layer (Allure, ExtentReports) is effectively mandatory |
| Component testing | Experimental, and not the reason to pick it | Genuinely good; the strongest argument left for Cypress | Not applicable |
| Ecosystem / plugins | Smaller plugin market because more is built in | Large plugin ecosystem, some of it compensating for architectural limits | Largest and oldest ecosystem; quality varies widely |
| Learning curve | Async/await discipline required; concepts are explicit | Easiest start; the command-queue model confuses people later | Simple API, hard to use well — synchronization is left to you |
For a broader inventory of the tooling landscape, I keep an older overview in Turkish: test otomasyonunda kullanılan popüler araçlar.
The Same Scenario in All Three Tools
One scenario: log in, search a table, intercept the search API response and assert on both the payload and the rendered row.
Playwright (TypeScript)
import { test, expect } from '@playwright/test';
const SEARCH = /\/api\/orders\?query=ACME/;
test('order search renders the intercepted payload', async ({ page }) => {
await page.route(SEARCH, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [{ id: 'A-1001', customer: 'ACME Corp', total: 240.5 }],
}),
})
);
await page.goto('/login');
await page.getByLabel('Email').fill('**@*****le.com');
await page.getByLabel('Password').fill('s3cret');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('heading', { name: 'Orders' })).toBeVisible();
const responsePromise = page.waitForResponse(SEARCH);
await page.getByPlaceholder('Search orders').fill('ACME');
const body = await (await responsePromise).json();
expect(body.items).toHaveLength(1);
await expect(page.getByRole('row', { name: /A-1001/ })).toContainText('ACME Corp');
});
Cypress
describe('order search', () => {
it('renders the intercepted payload', () => {
cy.intercept('GET', /\/api\/orders\?query=ACME/, {
statusCode: 200,
body: { items: [{ id: 'A-1001', customer: 'ACME Corp', total: 240.5 }] },
}).as('search');
cy.visit('/login');
cy.get('input[name="email"]').type('**@*****le.com');
cy.get('input[name="password"]').type('s3cret', { log: false });
cy.contains('button', 'Sign in').click();
cy.contains('h1', 'Orders').should('be.visible');
cy.get('input[placeholder="Search orders"]').type('ACME');
cy.wait('@search').its('response.body.items').should('have.length', 1);
cy.contains('tr', 'A-1001').should('contain.text', 'ACME Corp');
});
});
Selenium 4 (Java)
import org.junit.jupiter.api.Test;
import org.openqa.selenium.By;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.devtools.NetworkInterceptor;
import org.openqa.selenium.remote.http.Contents;
import org.openqa.selenium.remote.http.HttpResponse;
import org.openqa.selenium.remote.http.Route;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.assertTrue;
class OrderSearchTest {
private static final String STUB =
"{\"items\":[{\"id\":\"A-1001\",\"customer\":\"ACME Corp\",\"total\":240.5}]}";
@Test
void rendersTheInterceptedPayload() {
ChromeDriver driver = new ChromeDriver();
try {
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
try (NetworkInterceptor interceptor = new NetworkInterceptor(
driver,
Route.matching(req -> req.getUri().contains("/api/orders?query=ACME"))
.to(() -> req -> new HttpResponse()
.setStatus(200)
.addHeader("Content-Type", "application/json")
.setContent(Contents.utf8String(STUB))))) {
driver.get("https://app.example.com/login");
driver.findElement(By.name("email")).sendKeys("**@*****le.com");
driver.findElement(By.name("password")).sendKeys("s3cret");
driver.findElement(By.cssSelector("button[type=submit]")).click();
wait.until(ExpectedConditions.visibilityOfElementLocated(
By.xpath("//h1[normalize-space()='Orders']")));
driver.findElement(By.cssSelector("input[placeholder='Search orders']"))
.sendKeys("ACME");
WebElement row = wait.until(ExpectedConditions.visibilityOfElementLocated(
By.xpath("//tr[td[contains(.,'A-1001')]]")));
assertTrue(row.getText().contains("ACME Corp"));
}
} finally {
driver.quit();
}
}
}
Note what the Selenium version cannot do without extra plumbing: assert on the response body it just served. It stubbed the request but has no handle on the response object the way the other two do. That is the practical meaning of “network access is a consequence of architecture.” If API-level assertions are central to your strategy, read that alongside API test otomasyonu (Turkish).
CI Cost and Execution Time
The most concrete consequence of your choice is the monthly CI bill. Playwright shards for free — the runner splits tests, each shard produces a blob report, and a final job merges them:
name: e2e
on: [push]
jobs:
test:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.49.0-jammy
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npx playwright test --shard=${{ matrix.shard }}/4 --reporter=blob
- uses: actions/upload-artifact@v4
if: always()
with:
name: blob-report-${{ matrix.shard }}
path: blob-report
retention-days: 7
report:
needs: test
if: always()
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- uses: actions/download-artifact@v4
with:
pattern: blob-report-*
path: all-blobs
merge-multiple: true
- run: npx playwright merge-reports --reporter=html ./all-blobs
- uses: actions/upload-artifact@v4
with:
name: html-report
path: playwright-report
Pin the container tag to the Playwright version in your package.json; a mismatch between the image’s browser builds and the library is a classic CI failure.
Cypress parallelization is different in kind, not degree. --parallel requires a build ID and a coordination service:
- run: npx cypress run --record --parallel --ci-build-id ${{ github.run_id }}
env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
Without a record key this command fails. Your options are the commercial cloud, a third-party orchestrator, or splitting spec files across matrix jobs by hand — which loses load balancing and gives you N disconnected reports.
Selenium Grid scales, but you operate it:
services:
selenium-hub:
image: selenium/hub:4
ports:
- "4444:4444"
chrome:
image: selenium/node-chromium:4
shm_size: 2gb
depends_on:
- selenium-hub
environment:
- SE_EVENT_BUS_HOST=selenium-hub
- SE_EVENT_BUS_PUBLISH_PORT=4442
- SE_EVENT_BUS_SUBSCRIBE_PORT=4443
- SE_NODE_MAX_SESSIONS=4
- SE_NODE_OVERRIDE_MAX_SESSIONS=true
Three cost lines get forgotten in every estimate. First, container image size: Playwright’s official image ships three browser engines and is the heaviest of the three by default, so cold pulls matter more than run time on short suites. Second, cold start: a Grid that boots per pipeline pays hub and node startup on every run. Third, artifact retention — traces and videos are large, and default retention multiplied by pipeline frequency becomes a real number. Record traces on retry only, not always.
The general pattern across the three is predictable: out-of-process drivers with per-test browser contexts parallelize more densely on the same hardware than in-browser or HTTP-round-trip models, and flakiness drops when waiting is a property of the action rather than something each author writes by hand. If you want to reduce the pipeline itself rather than just distribute it, see test otomasyonunda performans optimizasyonu (Turkish).
Four Scenarios That Make the Decision
1. New SPA, small team, TypeScript. Playwright. You get parallelization, tracing and reporting without buying or building anything, and multi-origin flows like third-party auth work without special constructs.
2. Enterprise regression with a long browser matrix. Selenium. If the requirement includes Edge in IE mode, specific pinned browser versions, or a certified OS/browser grid, Playwright’s bundled-browser model works against you. This is exactly the case WebDriver’s standardization exists for.
3. Suite owned by the frontend team, interleaved with component tests. Cypress is still defensible. If the same people write component tests and E2E tests in the same repo, in the same language, and the debugging loop is what keeps them writing tests at all, that consistency has real value. Accept the parallelization bill as part of the choice.
4. Java/C# monolith with a large existing Selenium investment. Not migrating is a decision too. A suite that runs, is trusted and is understood is worth more than a faster suite nobody has written yet. Plan a gradual path instead: new modules get Playwright’s Java or .NET binding, the legacy suite stays until its coverage is genuinely replaced.
Migrating from Selenium or Cypress to Playwright
Attempting a full rewrite fails, reliably. The suite becomes a branch nobody merges, and the old one keeps being the source of truth. Use this order instead:
- Write every new test in the new tool. No exceptions. This bounds the old suite immediately.
- Run both suites in CI, in parallel. Two jobs, two reports. Coverage overlap is acceptable during the transition; a coverage gap is not.
- Move the twenty flakiest tests first. They cost the most attention and produce the most visible improvement. Identify them from history, not from intuition — the method is in flaky testleri teşhis etmek (Turkish).
- Retire the old suite per area, as coverage equalizes. Delete, don’t archive.
Two things must be redesigned during the migration rather than ported. The locator strategy: brittle XPath chains and CSS selectors coupled to markup are the main reason the old suite hurt. Move to role-, label- and data-testid-based locators, and treat that as a product change requiring test IDs in the application. The test data setup: if tests depend on a shared seeded database and an execution order, parallelization will expose it immediately. Each test should create the data it needs — the argument is laid out in test verisi yönetimi (Turkish). Migrations that skip these two steps produce the same flaky suite in new syntax.
Putting the Decision on Paper
Do this before the next architecture meeting. Take the three criteria from the first section — application architecture, CI budget, team language competence — and assign each a weight out of 100 for your project specifically. A team of Java engineers testing an internal app behind SSO on a fixed browser matrix will land somewhere completely different from four TypeScript developers shipping a public SPA daily. Write the weights down, because unwritten weights get re-argued every week.
Then define a POC: pick your three most critical user flows, implement them in two candidate tools, and run both in CI for two weeks against real builds. Measure:
- Lines of code and files per flow, including helpers and fixtures.
- Median and p95 wall-clock runtime of the full POC suite in CI.
- CI minutes consumed, plus any licence or orchestration cost.
- Flaky rate: reruns that changed result, over total runs.
- Time to diagnose a genuine failure from CI artifacts alone, without local reproduction.
- Maintenance hours spent on test code after application changes.
- Onboarding time: how long a team member unfamiliar with the tool needs to add a fourth flow unaided.
Two weeks of your own numbers settle the question better than any comparison post, including this one. If your suite is heading past a few thousand tests, the scalability constraints that show up later are worth reading in advance: test otomasyonunda ölçeklenebilirlik (Turkish).