From Cypress/Playwright sprawl back to a healthy test pyramid
Many teams rely almost entirely on slow end-to-end tests and manual click-through testing, because a unit test culture never took hold. This ice-cream-cone anti-pattern inverts the classic test pyramid and leads to sluggish CI pipelines, high flakiness, and late bug detection. This article shows how teams can recognize the pattern and find their way back to a fast, reliable test pyramid with a practical migration path.
Table of Contents
- 1. What the ice-cream-cone anti-pattern is
- 2. Symptoms of an inverted test pyramid
- 3. Why teams end up there: a missing unit test culture
- 4. Why E2E tests feel "more real" than unit tests
- 5. The cost of the anti-pattern in numbers
- 6. Diagnosing your own test portfolio
- 7. A practical migration path back to the pyramid
- 8. Anchoring the culture shift in the team
- 9. Anti-pattern versus healthy pyramid, compared
- 10. Summary
- 11. FAQ
1. What the ice-cream-cone anti-pattern is
The ice-cream-cone anti-pattern describes a test architecture in which the ratio of Mike Cohn's classic test pyramid gets flipped: instead of many fast unit tests at the base and fewer, targeted E2E tests at the top, hundreds of slow Cypress or Playwright specs and manual click-through tests pile up at the top, while barely any unit tests exist at the bottom. Drawn as a diagram, this produces the shape of an ice-cream cone instead of a pyramid, hence the name.
In practice, this looks like this: a team tests a discount calculation not with a single function and three assertions, but by opening a browser, logging in, filling a cart, clicking through checkout, and finally checking a piece of text in the DOM. QA clicks through the same checklist manually before every release. The actual business logic is only ever tested indirectly, across five UI steps.
2. Symptoms of an inverted test pyramid
The most obvious symptom is CI runtime: pipelines that take 45 to 90 minutes because hundreds of browser sessions run sequentially, or only partially in parallel. On top of that come flaky tests, which turn red not because of real bugs but because of timing issues, network latency, or race conditions in the UI. Workarounds like automatic re-runs paper over the problem instead of solving it, and the pipeline just gets slower overall.
Team behavior is just as telling: developers don't dare refactor code without first running the entire E2E suite locally, because that's the only source of confidence they have. Pull request reviews ask "is the E2E pipeline green?" instead of examining the actual logic. Bugs in pure calculation logic, the kind a unit test would have caught in milliseconds, instead show up in production.
// cypress/e2e/checkout/discount-calculation.cy.js
// PROBLEM: the only place discount math is verified is a full browser E2E run
describe('Checkout discount calculation', () => {
it('applies a 10% loyalty discount to the cart total', () => {
cy.login('loyalty-customer@example.com', 'password123');
cy.visit('/cart');
cy.get('[data-testid="cart-item"]').should('have.length', 3);
cy.get('[data-testid="apply-loyalty-discount"]').click();
// The actual assertion we care about is a pure calculation,
// but we can only reach it through five UI steps and a full page load.
cy.get('[data-testid="cart-total"]')
.invoke('text')
.then((text) => {
const total = parseFloat(text.replace('€', '').trim());
expect(total).to.equal(179.91); // 199.90 * 0.9, rounded
});
});
});
3. Why teams end up there: a missing unit test culture
The most common reason is historical: a codebase has grown over years without ever establishing dependency injection, clear module boundaries, or testable functions. Business logic is tightly coupled to DOM access, global state, or API calls, which makes an isolated unit test technically difficult to write. "We'll add the tests later" is, in codebases like this, a promise that practically never gets kept, because every new feature takes priority over catching up on old debt.
Organizational incentives compound the problem: the QA team gets measured on the number of bugs found before release, not on developer velocity, which structurally favors manual and E2E tests. Leadership sees a green check on the E2E pipeline as proof of quality, without questioning how much of the actual logic is really covered. Code reviews rarely ask explicitly, "where's the unit test for this function?", so the gap widens with every merge.
4. Why E2E tests feel "more real" than unit tests
E2E tests intuitively feel more trustworthy because they follow exactly the path a real user would take: open the browser, click, type, see the result. This psychological closeness to reality creates a false sense of security along the lines of "if the E2E suite is green, everything works," even though E2E tests are notoriously full of gaps around edge cases, since every additional case costs another full run.
Unit tests, by contrast, feel more abstract: they require isolating dependencies, building mocks, and thinking about a function detached from the overall system. For poorly structured code, that's harder than a click recorder like Cypress Studio, which produces a runnable script immediately with no test design required. Developers without TDD experience therefore find E2E tests the easier entry point, even though they end up being the more expensive and slower safety net in the long run.
// src/pricing/discount.js
// Business logic extracted into a pure, framework-free function
export function applyLoyaltyDiscount(cartTotal, discountRate = 0.1) {
if (cartTotal < 0) {
throw new Error('Cart total cannot be negative');
}
return Math.round(cartTotal * (1 - discountRate) * 100) / 100;
}
// src/pricing/discount.test.js
// Vitest/Jest unit test: runs in milliseconds, no browser, no network
import { describe, it, expect } from 'vitest';
import { applyLoyaltyDiscount } from './discount.js';
describe('applyLoyaltyDiscount', () => {
it('applies the default 10% discount and rounds to two decimals', () => {
expect(applyLoyaltyDiscount(199.90)).toBe(179.91);
});
it('supports a custom discount rate', () => {
expect(applyLoyaltyDiscount(100, 0.25)).toBe(75);
});
it('rejects a negative cart total', () => {
expect(() => applyLoyaltyDiscount(-5)).toThrow('Cart total cannot be negative');
});
});
5. The cost of the anti-pattern in numbers
The direct costs are measurable: CI minutes billed by cloud runners, developer time wasted waiting between commit and feedback, and context switches, since a 60-minute run doesn't keep anyone glued to their screen. Where a unit test delivers a result in seconds, a team stuck with an E2E-heavy pipeline often waits hours for the first reliable feedback, which artificially stretches release cycles.
The indirect costs weigh even heavier: flaky tests erode trust in the pipeline until teams reflexively re-run red builds instead of investigating them. Real regressions then get dismissed as "probably just flaky again" and overlooked. Without a fast unit test net as the first line of defense, the same classes of bugs keep recurring, because nobody has built a targeted, durable regression safety net for the actual logic.
# .github/workflows/test-pyramid-health.yml
# Tracks runtime and count per test tier so pyramid health is visible over time
name: Test Pyramid Health
on: [push, pull_request]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- name: Run unit tests with timing
run: npm run test:unit -- --reporter=json --outputFile=reports/unit.json
- name: Report unit-tier metrics
run: node scripts/report-tier-metrics.js unit reports/unit.json
integration:
runs-on: ubuntu-latest
needs: unit
steps:
- uses: actions/checkout@v4
- run: npm ci
- name: Run integration tests with timing
run: npm run test:integration -- --reporter=json --outputFile=reports/integration.json
- name: Report integration-tier metrics
run: node scripts/report-tier-metrics.js integration reports/integration.json
e2e:
runs-on: ubuntu-latest
needs: integration
steps:
- uses: actions/checkout@v4
- run: npm ci
- name: Run Cypress E2E suite with timing
run: npx cypress run --reporter json --reporter-options "output=reports/e2e.json"
- name: Report e2e-tier metrics
run: node scripts/report-tier-metrics.js e2e reports/e2e.json
publish-pyramid-report:
runs-on: ubuntu-latest
needs: [unit, integration, e2e]
steps:
- name: Combine tier metrics into pyramid ratio
run: node scripts/publish-pyramid-ratio.js
6. Diagnosing your own test portfolio
Before migrating, the current state needs to be measurable: how many tests exist per tier, how long does each tier take to run, and what ratio does that produce? A simple counting script across the test directories already provides the base numbers. What matters is not just counting, but classifying what a test actually verifies, since many E2E specs, in truth, verify pure calculation logic that could just as well be tested without a browser.
An audit pass flags exactly these candidates: specs that assert on concrete computed values are almost always candidates for an accompanying or replacement unit test. Specs that check pure UI behavior, like visibility or navigation, on the other hand, rightfully belong at the E2E tier. This classification produces the priority list for the actual migration.
#!/usr/bin/env bash
# audit-cypress-suite.sh - classify existing Cypress specs by what they actually verify
set -euo pipefail
SPEC_DIR="cypress/e2e"
declare -A tier_count=([ui-behavior]=0 [business-logic]=0 [smoke]=0 [unclear]=0)
for spec in $(find "$SPEC_DIR" -name "*.cy.js"); do
# Heuristic: specs asserting on computed numbers often hide business logic
if grep -qE "expect\(.*\)\.to\.(equal|eq)\([0-9]" "$spec"; then
tier_count[business-logic]=$((tier_count[business-logic] + 1))
echo "[CANDIDATE FOR UNIT TEST] $spec"
elif grep -qE "cy\.get\(.*\)\.should\('be.visible'\)" "$spec"; then
tier_count[ui-behavior]=$((tier_count[ui-behavior] + 1))
elif grep -qE "@smoke" "$spec"; then
tier_count[smoke]=$((tier_count[smoke] + 1))
else
tier_count[unclear]=$((tier_count[unclear] + 1))
echo "[NEEDS REVIEW] $spec"
fi
done
echo "--- Cypress suite audit summary ---"
for tier in "${!tier_count[@]}"; do
echo "$tier: ${tier_count[$tier]}"
done
7. A practical migration path back to the pyramid
The proven approach is a strangler-fig migration rather than a big-bang rewrite: from now on, every pull request that introduces new business logic requires an accompanying unit test, no exceptions. In parallel, a git-churn analysis identifies the most frequently changed or bug-prone modules in the legacy code, since that's where retrofitting unit tests pays off the most per hour invested.
The second, often-neglected step: actively shrinking the E2E suite. Once a unit or integration test reliably covers the same logic, the redundant E2E test gets deleted or downgraded to a pure smoke test that only checks whether the critical path loads at all. Separate npm scripts per tier make this structure visible and enforceable, both locally and in the CI pipeline.
{
"scripts": {
"test:unit": "vitest run --dir src",
"test:unit:watch": "vitest --dir src",
"test:integration": "vitest run --dir tests/integration --pool=forks",
"test:e2e:smoke": "cypress run --env grepTags=@smoke",
"test:e2e:full": "cypress run",
"test:ci": "npm run test:unit && npm run test:integration && npm run test:e2e:smoke",
"test:pyramid-ratio": "node scripts/publish-pyramid-ratio.js"
}
}
8. Anchoring the culture shift in the team
Technical migration alone won't stick if the team culture doesn't change alongside it. The definition of done should explicitly include "unit test present for new logic," and code reviews should actively ask that question instead of skipping past it. Pairing or mob-programming sessions, where experienced developers pass on mocking patterns and test design to colleagues, lower the barrier to entry noticeably faster than documentation alone.
Visibility keeps the change alive: a dashboard showing the ratio of unit to E2E tests and the CI runtime per tier over time makes progress tangible and turns it into a recurring topic in retrospectives. When a shrinking E2E runtime gets celebrated as a team win instead of treated as a footnote, the migration becomes a shared goal instead of one person's pet project.
9. Anti-pattern versus healthy pyramid, compared
The following overview sets the typical metrics of an ice-cream-cone anti-pattern against the values of a healthy test pyramid, as concrete targets for your own migration.
| Criterion | Ice-cream cone (anti-pattern) | Healthy pyramid | Practical lever |
|---|---|---|---|
| Test distribution | 70% E2E, 20% integration, 10% unit | 70% unit, 20% integration, 10% E2E | Weight test types by risk and speed |
| CI runtime | 45-90 minutes per run | 5-12 minutes per run | Unit tests run in parallel within milliseconds |
| Flakiness rate | 15-30% unstable runs | under 2% unstable runs | Fewer network and timing dependencies |
| Feedback speed | Hours until a result | Seconds to minutes | Catch failures before the commit, not after deploy |
| Onboarding new developers | Weeks to understand the Cypress suite | Days, since unit tests document the logic | Use tests as living documentation |
In practice, these metrics reinforce each other: a high flakiness rate doesn't just slow CI runtime, it also destroys confidence for new developers, who experience the unstable suite as the normal state of things. Teams that consistently work on the test distribution automatically improve runtime, stability, and onboarding time together, not in isolation.
Mironsoft
Test strategy, test pyramid migration, and CI/CD tooling for development teams
Ready to get your test portfolio back on healthy footing?
We analyze your existing Cypress or Playwright suite, classify every test by what it actually verifies, and guide you through a step-by-step migration back to a fast, reliable test pyramid.
Test portfolio diagnosis
Audit the existing E2E suite and classify tests by tier
Migration plan to the pyramid
A prioritized roadmap by git churn and risk, using a strangler-fig approach
Building a unit test culture
Pairing sessions, definition of done, and CI reporting per test tier
10. Summary
The ice-cream-cone anti-pattern rarely emerges from a deliberate decision, it creeps in: a missing unit test culture, historically grown code, and the deceptive closeness of E2E tests to the real user experience add up over years to sluggish, expensive, unstable test suites. The symptoms are measurable, long CI runs, high flakiness, late bug feedback, and the path back is just as measurable.
A practical migration path combines an honest diagnosis of the existing portfolio, the strangler-fig approach for new and critical legacy code, a deliberate shrinking of the E2E suite, and a culture shift that makes unit tests a natural part of every pull request. None of these steps require a full rewrite, they require consistency sustained over several months.
The Ice-Cream-Cone Anti-Pattern - Key Takeaways
What the anti-pattern is
An inverted test pyramid: many slow E2E and manual tests at the top, barely any unit tests at the bottom, named after the ice-cream-cone shape it forms in a diagram.
Diagnosis
Count tests per tier, measure runtime, and identify E2E specs that, in truth, only verify business logic.
Migration path
Strangler-fig approach: mandatory unit tests for new code, prioritized retrofitting of legacy modules by git churn, and actively shrinking the E2E suite.
Culture shift
Unit tests in the definition of done, pairing sessions for test design, pyramid ratio visible in CI reports and retros.