Skipped Tests Distort Every Rate You Report

Most teams treat a skipped test as a non-event — it didn't run, so it didn't fail, so it doesn't count. That logic is exactly how you end up reporting a 98% pass rate to leadership while 15% of your suite is quietly excluded from the denominator. The signal you're missing isn't in the red; it's in the grey.

The problem compounds across every derived metric: pass rate, failure rate, flake rate, and coverage delta. When skips are excluded from rate calculations without being tracked as a first-class signal, you're doing arithmetic on a biased sample. Your suite composition is already skewing the numbers before a single assertion fires.

By the end of this article you'll understand exactly how skips corrupt each rate, how to normalize them in SQL and Python, and what a correctly structured test insights report looks like when skips are treated as data rather than noise.

Find a Gift They’ll Actually Remember

Shop personalized, funny, and unique gifts for birthdays, holidays, couples, kids, and every hard-to-shop-for person.

Learn more

The Three Ways Skips Enter Your Results — and Why They're Not Equivalent

A skipped test in JUnit XML or pytest carries a status="skipped" attribute, but that single label conflates at least three distinct causes: intentional deselection (a @pytest.mark.skip or assumeTrue), conditional skip (environment guard, feature flag, platform filter), and infrastructure skip (test runner couldn't acquire a resource — browser, DB fixture, service dependency — and bailed before execution). Each has a different risk profile. An infrastructure skip on a payment-flow test is a near-miss failure. An intentional deselection on a deprecated endpoint is maintenance debt. Treating them identically in your metrics is a category error.

In a modern test architecture, skips should be routed through the same ingestion pipeline as passes and failures — into ClickHouse, BigQuery, or a PostgreSQL results store — with a skip_reason field parsed from the XML message attribute. Without that field, your test insights report is missing the one dimension that separates signal from noise. The skip count alone is meaningless; the skip reason distribution tells you whether your suite is shrinking by design or eroding under you.

Normalizing Skips in Your Metrics Pipeline: SQL, Python, and Dashboard Config

Start at the denominator. Most pass-rate queries look like this:

-- Naive: skips silently excluded
SELECT
  run_id,
  COUNTIF(status = 'passed') / COUNTIF(status IN ('passed','failed')) AS pass_rate
FROM test_results
GROUP BY run_id;

That query produces a number that rises every time you add a @pytest.mark.skipif. The corrected version makes the choice explicit — either include skips in the denominator or track them as a parallel metric:

-- Explicit: three-way split, skips surface as their own rate
SELECT
  run_id,
  COUNTIF(status = 'passed')  AS passed,
  COUNTIF(status = 'failed')  AS failed,
  COUNTIF(status = 'skipped') AS skipped,
  ROUND(
    COUNTIF(status = 'passed') /
    NULLIF(COUNTIF(status IN ('passed','failed','skipped')), 0),
  4) AS execution_rate,          -- passes over everything attempted
  ROUND(
    COUNTIF(status = 'passed') /
    NULLIF(COUNTIF(status IN ('passed','failed')), 0),
  4) AS conditional_pass_rate    -- passes over tests that actually ran
FROM test_results
GROUP BY run_id;

Report both. execution_rate tells you how much of your intended suite ran. conditional_pass_rate tells you how healthy the portion that ran actually is. Reporting only the latter to stakeholders without the former is how a suite that's 40% skipped looks like a 97% pass rate. A team that wired both metrics into their Grafana dashboard cut their postmortem prep time from 22 minutes per incident to under 5 — the skip-rate panel immediately flagged that a broken Docker network was causing 60 Selenium tests to infrastructure-skip rather than fail.

On the ingestion side, parse skip reasons at collection time. In Python with a JUnit XML parser:

import xml.etree.ElementTree as ET
from dataclasses import dataclass
from typing import Optional

@dataclass
class TestRecord:
    name: str
    status: str
    skip_reason: Optional[str] = None
    skip_category: Optional[str] = None  # 'intentional' | 'conditional' | 'infra'

INFRA_PATTERNS = ("fixture", "resource", "timeout acquiring", "browser")

def parse_junit(path: str) -> list[TestRecord]:
    records = []
    for tc in ET.parse(path).iter("testcase"):
        skip = tc.find("skipped")
        if skip is not None:
            reason = skip.get("message", "")
            category = (
                "infra" if any(p in reason.lower() for p in INFRA_PATTERNS)
                else "conditional" if reason.startswith("Condition")
                else "intentional"
            )
            records.append(TestRecord(tc.get("name"), "skipped", reason, category))
        elif tc.find("failure") is not None:
            records.append(TestRecord(tc.get("name"), "failed"))
        else:
            records.append(TestRecord(tc.get("name"), "passed"))
    return records

Store skip_category in your results table. A Grafana time-series panel grouped by skip_category will immediately surface infrastructure degradation as a rising infra skip line — days before those tests start appearing in failure counts, because the runner is still bailing before assertion. This is the same early-warning pattern that makes broader CI observability metrics worth the instrumentation effort.

Where Even Experienced Teams Get the Accounting Wrong

Mistake one: skip-rate trending without normalization for suite size. If your suite grows from 800 to 1,200 tests over a quarter and skips go from 40 to 60, the raw count looks stable. The skip rate is identical. But if 50 of those new 400 tests are immediately marked skip-on-CI because they're "not ready yet," you've institutionalized a pattern where new tests default to invisible. Track skips as a percentage of total registered tests, not just of tests that ran — and alert when that percentage crosses a threshold (8% is a reasonable starting point for most suites).

Mistake two: counting retried-then-skipped tests as passes. Some runners, particularly in Playwright and pytest-retry configurations, will skip a test on the final retry attempt when a fixture teardown fails. That test lands in results as skipped but was actually a failure that ran out of retry budget. This intersects directly with how retry counts inflate pass rate — skips are another escape hatch the same way retries are. Check your runner's behavior explicitly; don't assume the status field is semantically clean.

Test Insights Reports That Ignore Skips Are Measuring the Wrong Suite

Myth: a high pass rate means the suite is healthy. If 30% of your suite is skipped on every run, you have no data on 30% of your coverage surface. A 99% pass rate on 70% of your tests is not a 99% healthy system — it's a 69% sampled pass rate with a silent coverage hole. This matters acutely for defect escape rate: bugs that live in the untested 30% will escape to production and never show up in your CI metrics until a customer finds them. The pass rate looks fine right up until the postmortem.

Myth: skipped tests are a test-code problem, not an observability problem. Teams assign skip cleanup to the SDET who owns the test file and move on. But infrastructure skips caused by flaky fixtures, environment provisioning failures, or resource contention are platform problems. They need to be routed to the team that owns the CI infrastructure, not the team that owns the test. A correctly structured test insights report separates skip categories by owning team — the same way you'd route a P2 alert to the right on-call. Without that routing, infra skips accumulate silently until the suite is 25% grey and nobody remembers why.

The fix is unglamorous: add skip_reason and skip_category to your results schema, update your rate queries to make the denominator choice explicit, and add a skip-rate panel to whatever dashboard your team already watches. If you're building out the broader metrics layer, the next natural stop is understanding how flake rate averages obscure your riskiest tests — the same denominator bias applies there, and the fix is structurally identical.

Note: This article is for informational purposes only and is not a substitute for professional advice. If you need guidance on specific situations described in this article, consider consulting a qualified professional.

Understanding how systems actually work is the first step toward navigating them effectively.

Browse all articles