Recognizing and Reversing the Ice-Cream-Cone Anti-Pattern
AI generated
PASS
expect()
Testing · E2E · Test Pyramid · Cypress · Playwright
Recognizing and Reversing the Ice-Cream-Cone Anti-Pattern
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.

12 min read Anti-pattern · Test pyramid · Test strategy Cypress · Playwright · Testing culture

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.

11. FAQ: The ice-cream-cone anti-pattern

1What's the difference between the ice-cream-cone anti-pattern and the classic test pyramid?
The classic pyramid relies on many unit tests at the base and few E2E tests at the top. The anti-pattern inverts that: many E2E tests at the top, barely any unit tests at the bottom.
2How do I tell whether my team is stuck in the anti-pattern?
CI runs over 30 minutes, hundreds of Cypress/Playwright specs, barely any unit tests, high flakiness, and fear of refactoring without the full E2E suite.
3Do I have to delete all E2E tests to fix the pyramid?
No. Critical user journeys stay E2E-tested. Only redundant E2E tests that check pure business logic get replaced with unit tests.
4How do I get started when we have almost no unit tests?
Strangler-fig approach: make unit tests mandatory for every new code change, retrofit legacy modules by priority using git-churn analysis.
5Which tools fit each of the three test tiers?
Jest/Vitest for unit tests, the same runners with real module boundaries or Testcontainers for integration tests, Cypress/Playwright for critical E2E journeys.
6How long does the migration realistically take?
Three to six months for a mid-sized project with consistent execution. A full reversal of the pyramid often takes longer.
7How do I convince management?
With numbers: compare CI runtime, flakiness rate, and time-to-feedback before/after the migration. Cost and speed arguments are more convincing than code quality alone.
8Integration tests vs. E2E tests: what's the difference?
Integration tests verify how several modules work together without a full browser. E2E tests run in a real browser against the complete application.
9How do I prevent slipping back into old patterns?
Make the pyramid ratio visible as a metric in CI reports and discuss it in retros, anchored as part of the definition of done.
10Are snapshot tests part of the solution or part of the problem?
Both. Targeted snapshot tests for stable components are valuable, blanket snapshots of entire pages mask missing real assertions.