Andrew Goldis
Andrew Goldis

What does "skipped" mean - test statuses review across popular JavaScript runners

JavaScript test runners share status words and disagree about what the words mean. Playwright and Cypress use "skipped" for opposite cases. Jest carries 7 statuses but not "flaky". This guide translates each runner's vocabulary and shows how runners derive flakiness from attempts.

What does "skipped" mean - test statuses review across popular JavaScript runners

Your CI report says 340 passed and 27 skipped. Skipped through a deliberate it.skip(), or by the runner because a broken hook took a block of tests down with it? The report will not tell you. The answer depends on which runner wrote it.

We build test reporting for Cypress and Playwright at Currents, and we normalize results from Jest, Vitest and mocha along the way. That work taught us that teams misread statuses constantly. The statuses themselves are not complicated. The problem is that different runners assigned the same five or six English words to different concepts, and nobody gets to rename them now.

TL;DR
  • A test attempt is one execution of a test; the test's outcome is derived from all attempts together. Most confusion comes from mixing these two levels.
  • Cypress reports it.skip() as pending. Cypress reports a test that a failed beforeEach hook prevented from running as skipped. The alarming word is skipped.
  • Playwright reverses the convention: test.skip() produces skipped, and a halted run produces interrupted.
  • No runner observes "flaky" on a single attempt. Runners compute flakiness across attempts. Playwright and Vitest give it a name; Cypress, mocha and Cucumber keep enough data for you to derive it; Jest and Bun hide it in their default output.
  • node:test has no status strings at all: just a passed boolean plus independent skip/todo flags, mirroring TAP.
  • Cucumber ties Jest for the largest vocabulary (seven values), including UNDEFINED and AMBIGUOUS, statuses that describe the glue code rather than the test.

Playwright

Playwright has the richest status model of the nine runners, and it is the only runner that separates "what happened" from "was that expected".

Attempt statuses (TestResult.status, type definition):

  • passed: the test body and its beforeEach/afterEach hooks completed without error.
  • failed: an assertion failed, or the test threw an exception.
  • timedOut: the attempt exceeded the configured timeout, and Playwright terminated it. timedOut is separate from failed for a reason: a timeout usually signals a hang or a missing element rather than a wrong assertion, and you can filter for it.
  • skipped: Playwright did not execute the test. Usually you asked for that: test.skip(), test.fixme(), or a conditional skip. The remaining tests in a worker also become skipped when the worker dies before reaching them.
  • interrupted: the attempt started (or was queued), and the run halted before the attempt finished. Ctrl+C produces interrupted, and so does hitting maxFailures.

A worker crash is the surprising case. Playwright marks the test that was running as failed, with a "worker process exited unexpectedly" error, not as interrupted. Only the tests that never started come back as skipped.

Outcome (TestCase.outcome()) is the derived level:

  • expected: the result matches expectedStatus. A passing test is expected, and so is a test marked test.fail() that fails.
  • unexpected: the result does not match expectedStatus.
  • skipped: you excluded the test on purpose.
  • flaky: some attempts matched expectedStatus and some did not.

expectedStatus is the piece most people miss. Playwright does not ask "did the test pass". Playwright asks "did the test do what you declared it would do". That question makes test.fail() work cleanly: a known-broken test that fails stays green, and it turns red the day someone accidentally fixes it.

Cypress (and mocha, its parent)

Cypress inherited its status model from mocha, and the inheritance explains nearly every quirk.

Mocha has exactly three states: passed, failed and pending. The first two mean the test ran. pending means the test was never going to run, and mocha decides that at declaration time:

  • it.skip() or xit()
  • a test with no body
  • anything inside a describe.skip()

Mocha's docs put it plainly: anything skipped "will be marked as pending". Mocha never executes a pending test's body. The runner sees the mark and moves on.

So in mocha, pending means "you never intended this test to run in this session". Mocha has no word at all for the opposite case: a test that was meant to run and did not.

Cypress needed that word. A failing beforeEach hook takes down every remaining test in the block, and mocha cannot describe what happened to those tests. So Cypress added a fourth status on top of mocha's three:

  • passed: the test completed, and all assertions held.
  • failed: an assertion or command failed.
  • pending: you deliberately excluded the test: it.skip(), xit(), no test body, or a test configured for a different browser.
  • skipped: Cypress meant to run the test and could not, because a failing before, beforeEach or afterEach hook took the suite down.

The Cypress docs list exactly these four.

Here is the most important rule for reading a Cypress report: pending is fine, and skipped is a problem. A rising pending count means someone commented tests out. A rising skipped count means your hooks are breaking, and the suite is silently losing coverage. Those tests did not pass. They never ran.

Now put Cypress next to Playwright, and the collision is plain.

Two columns contrasting deliberate exclusion with tests that were meant to run but could not, showing which status each runner reports for each case
Two columns contrasting deliberate exclusion with tests that were meant to run but could not, showing which status each runner reports for each case

The word skipped means "I chose not to run this" in Playwright and "something broke and this never ran" in Cypress. If you migrate between the two, or feed both into one dashboard, translate before you compare.

Jest

Jest carries seven values in the Status union of its test results, tied with Cucumber for the largest vocabulary here, for very different reasons:

type Status =
  | 'passed'
  | 'failed'
  | 'skipped'
  | 'pending'
  | 'todo'
  | 'disabled'
  | 'focused';

You will rarely see all seven. The default jest-circus runner emits only passed, failed, pending and todo. The other three come from the legacy jest-jasmine2 runner, the Jasmine lineage Jest grew out of. Three values matter day to day:

  • passed / failed: what you expect.
  • pending: the status you get for a deliberately excluded test (test.skip, xit, or a test that test.only elsewhere in the file filtered out). The union contains skipped, but circus never emits it: internally circus marks the test 'skip', then reports it as pending. Two words for one idea, and only one of them reaches you, which is a large part of why Jest reporters and downstream tooling disagree with each other.
  • todo: from test.todo('name'), a placeholder for a test you plan to write. Jest counts todos separately (numTodoTests), so they read as intent rather than as a gap.

Jest has no notion of interruption or flakiness at the test level. If you cancel a run, Jest records the cancellation on the aggregate result (wasInterrupted), not per test. And retries, via jest.retryTimes(), overwrite the previous result. The overwriting is why Jest cannot report flaky tests; more in the flakiness section below.

Vitest

Vitest ships two status models, the internal task state and the newer reported API, and the two models use overlapping words for different things. Keep them apart.

The internal task state is a union of the run mode and the result:

type RunMode = "run" | "skip" | "only" | "todo" | "queued"
type TaskState = RunMode | "pass" | "fail"

A task starts out carrying its collection mode. When the task finishes, its state flips to pass or fail. So skip and todo are declarations that survive into the results, while pass and fail are outcomes.

The reported API (TestCase.result()) is cleaner: four states and one genuine trap:

  • passed, failed, skipped: what you expect.
  • pending: "The test was collected, but didn't finish running yet."

In mocha and Cypress, pending means "deliberately not run, forever". In Vitest, pending means "not done yet, check back". Same word, opposite meanings. Unlike the skipped collision, the pending collision bites during live reporting rather than at the end of a run.

Bun

bun test is deliberately Jest-compatible at the API level, and its vocabulary is correspondingly small: pass, fail, skip, and todo.

Two of Bun's modifiers behave differently from their Jest equivalents:

  • test.todo(): Bun does not execute a todo test unless you pass --todo. With the flag, a todo test that passes is reported as a failure. The reasoning: a test you marked as unwritten now passes, so your annotation is stale. TAP had the same idea decades earlier and reported a passing TODO as a bonus.
  • test.failing(): the "expected to fail" modifier, like Playwright's test.fail() and Vitest's test.fails(). Unlike todo, test.failing() always runs.

On flakiness, Bun shares Jest's blind spot. Bun ships a --retry flag, and the docs state the consequence outright: "If a test fails and then passes on a subsequent attempt, it is reported as passing." No flaky status, and the earlier failure never reaches the default output.

Bun also offers --rerun-each, which re-runs each test file N times to surface non-determinism. --rerun-each is a hunting tool, not a reporting feature: it helps you find flaky tests locally, and it does not report which of your CI tests were flaky.

Jasmine

Jest grew out of Jasmine, and several of Jest's stranger status values are inherited rather than designed.

A Jasmine spec ends in one of four states:

  • passed / failed: what you expect.
  • pending: declared with no body, or marked xit/pending().
  • excluded: left out of the selected set, either because a different spec used fit/fdescribe (Jasmine's focus mechanism) or because a spec filter did not match. The spec is fine. Nobody chose it.

excluded is the interesting one: it is the ancestor of Jest's focused and disabled statuses, and it records a cause the other runners collapse into "skipped": a filter excluded the test; the test did not exclude itself. Same non-execution, different cause.

The whole run has a status of its own, separate from any spec. jasmineDone reports an overallStatus of passed, failed or incomplete. The last one means focus filters were left in the suite, or no specs matched at all. incomplete looks like a spec status, but no spec ever carries it.

node:test

Node's built-in runner (stable since Node 20) ships with the runtime, so every modern Node project can use it without installing anything. Its status model is the smallest of the nine, and it is the only model here with no status string at all.

A completed test emits a 'test:complete' event whose payload carries:

// 'test:complete' event data
{
  name: string,
  details: {
    passed: boolean,     // the entire pass/fail verdict
    duration_ms: number,
    error: Error | undefined,
  },
  // two independent flags, next to `details` (not inside it):
  skip: string | boolean | undefined,
  todo: string | boolean | undefined,
}

That is the whole model: one boolean plus two independent flags. There is no enum to disagree about, because there is no enum.

The shape mirrors TAP, the Test Anything Protocol, where a test line is ok/not ok with optional # SKIP and # TODO directives, and Node ships a TAP reporter that prints exactly that format. TAP is a useful reference point for the rest of the article: the other eight runners' vocabularies are named reasons layered on top of the same boolean, and the disagreements are all about how to name the reasons.

The tradeoff: a skipped test still reports passed: true (verified on Node 24.14). The skip is a separate flag, and you have to check it. A reporter that reads only the boolean counts skipped tests as passing.

Cucumber

Cucumber is a BDD runner rather than a plain test runner, and it matches Jest's seven values. The difference: Cucumber defines them once, in the shared cucumber/messages schema that every language implementation uses:

enum TestStepResultStatus {
  UNKNOWN, PASSED, SKIPPED, PENDING, UNDEFINED, AMBIGUOUS, FAILED
}

Three of the seven have no equivalent anywhere else in this article, and all three describe the glue code rather than the test (reference):

  • UNDEFINED: the Gherkin step has no matching step definition. You wrote Given I am logged in, and nothing implements it.
  • AMBIGUOUS: the step matches two or more step definitions, so Cucumber cannot decide which to run.
  • PENDING: a step definition exists and explicitly signals "not implemented yet".

Cucumber's PENDING is closer to mocha's than to Vitest's: deliberate, not "in progress".

These statuses apply to steps. Cucumber derives a scenario's status from its steps by an explicit severity ordering:

UNKNOWN < PASSED < SKIPPED < PENDING < UNDEFINED < AMBIGUOUS < FAILED

The declaration order is the severity order, and @cucumber/query picks the worst step by its ordinal. The scenario takes the status of its worst step: the same attempt-to-outcome aggregation Playwright performs, but across steps instead of across retries.

Attempts vs outcome

Most arguments about statuses dissolve once you separate two levels that runners report: the attempt and the outcome.

When a runner executes a test, the runner records an attempt. Playwright calls the attempt a TestResult; Cypress calls it an attempt; Vitest tracks it as a retry count. If retries are enabled, one test can produce several attempts. The runner then derives the test's outcome from all attempts together.

The two-level model explains why "flaky" behaves unlike every other status. No single attempt is flaky. Flakiness is a property of the sequence.

Diagram showing how individual test attempts (passed, failed, timedOut) are aggregated into a final test outcome (expected, flaky, unexpected)
Diagram showing how individual test attempts (passed, failed, timedOut) are aggregated into a final test outcome (expected, flaky, unexpected)

Playwright models the two levels most explicitly. Its type definitions keep them apart:

// one entry per attempt
result.status: 'passed' | 'failed' | 'timedOut' | 'skipped' | 'interrupted'

// one per test, derived from all attempts
test.outcome(): 'skipped' | 'expected' | 'unexpected' | 'flaky'

Keep the two-level model in mind: flakiness, next, lives entirely on the second level.

How each runner reports flakiness

"Flaky" is the one status that is not a status.

A flaky test gives different results for the same code. To catch one in the act, a runner must do two things: run the test more than once, and remember the earlier attempts instead of discarding them. Retries produce the second result. Record-keeping preserves the evidence. Whether flakiness is visible in a given runner comes down to those two requirements, not to the quality of the tool.

One caveat before the details: where this section describes how a runner computes flakiness, the description reflects the source of the versions linked in each section. Docs pages linked without a pinned version reflect their content as of August 2026. No runner documents these mechanics as a stable contract.

RunnerRetriesFlaky signalRule
Playwrightbuilt inoutcome() === 'flaky'first attempt fails, a retry passes
Vitestbuilt indiagnostic().flakypassed after a retry
Cucumber--retrywillBeRetriedsignalled in real time
Cypressbuilt inderived, not a statuspassed + more than one attempt
Mochathis.retries()derived, not a statuspassed + currentRetry() > 0
Jestopt-in (retryTimes)none, retry overwrites the resultflakiness is hidden
Bun--retrynone, recovered test reports as passingflakiness is hidden
Jasminenonenothing to compareneeds an external wrapper
node:testnonenothing to compareneeds an external wrapper

Runners that keep the evidence

Playwright gives a fail-then-pass test its own outcome. With retries configured, outcome() returns 'flaky', and the run summary counts flaky tests separately from passed and failed. The docs keep it short ("Test that passes on a second retry is 'flaky'"), but the implementation compares against expectedStatus, not against "passed": a test is flaky when some attempts did what you declared and some did not. That definition composes cleanly with test.fail(): even a known-broken test can be flaky about how it breaks.

Vitest reports the same fact as a boolean rather than a status. A test that recovered on retry finishes as passed, and its diagnostics carry the history:

readonly retryCount: number
readonly flaky: boolean

The docs describe flaky as "If test passed on a second retry". Do not read "second" literally: the implementation in v4.1.10 reduces to retryCount > 0 && state === 'pass', so any retry counts.

Cypress is one step more indirect. The test finishes as plain passed (Cypress has no flaky status among its four), but the runner keeps every attempt, and the reporter derives the signal from them:

get hasRetried () {
  return this.state === 'passed' && this.hasMultipleAttempts
}

Passed, but it took more than one attempt. Reporting tools turn that condition into a Flaky badge on top of the green checkmark.

Mocha, where Cypress got the idea, supports the same rule via this.retries(), though nothing in mocha's output says so. Its three states have no room for "passed, eventually", but the retry count survives on the test object, and the runner emits a retry event as each retry happens. The Cypress rule works one layer down:

// in a custom reporter
runner.on(EVENT_TEST_END, (test) => {
  if (test.state === 'passed' && test.currentRetry() > 0) {
    // flaky
  }
})

The signal exists; mocha never surfaces it. And that is the line that matters: not whether a runner prints the word "flaky", but whether the runner keeps enough for you to derive it.

Cucumber has no flaky status either, but it does something none of the others do: it narrates retries as they happen. With --retry, every TestCaseFinished message carries a willBeRetried boolean. A reporter watching the stream can tell "this failure is about to be retried" from "this failure is final". TestCaseFinished alone does not say how the attempt went; to rebuild the attempt history, a reporter joins it with the test case's other messages in the stream.

Runners that retry, but discard the evidence

Jest runs the same sequence (fail, retry, pass) and then reports: passed. Nothing else appears in the default output. jest.retryTimes() re-runs the test, but each attempt replaces the previous result in the reported status, and reporters get no signal while it happens: no willBeRetried equivalent, no flaky status out of the box. The gap is a long-standing open request.

A partial workaround exists, and it rests on implementation behavior rather than documented contract. AssertionResult declares two optional fields: invocations and retryReasons. The jest-circus runner always fills invocations, and it copies the errors from failed attempts into retryReasons only when logErrorsBeforeRetry is set, and the docs describe that option only as console logging. A custom reporter can read those fields and establish, after the fact, that a green test took more than one try; treat them as implementation detail, not contract. Unless you do that work, a flaky test reports as a passing test.

Bun has the same shape. bun test --retry re-runs failures, and the docs state the outcome plainly: "If a test fails and then passes on a subsequent attempt, it is reported as passing." The default output keeps no record of the earlier failure, and Bun's docs describe no equivalent of Jest's diagnostics. Bun's --rerun-each runs every test file N times: a tool for hunting flakiness locally, not for recording which of today's CI tests were flaky.

Runners that never retry

Jasmine and node:test have no built-in retry, so a flaky test fails the run on its first and only attempt. There is no attempt two. With a single result there is nothing to compare, and no way to express "sometimes". Retrying above the runner (re-running the suite from CI, say) does not fix the problem: the runner still sees each run as the first and only attempt, so whatever did the re-running has to reconstruct the flakiness signal itself.

Across the nine runners covered here, flakiness is derived, never observed directly. Flakiness is a fact about a set of attempts, not about any single one. Runners that keep the set can surface it; runners that overwrite the set cannot; runners that never produce a second attempt have nothing to derive from. The pattern is also why reconstructing a derived status like flaky requires storing every attempt, not only the final verdict.

The summary table

The tables below group statuses by what they mean, not by what they are called.

The five concepts every runner expresses

Every runner has to express these five concepts somehow, and here the vocabulary collides:

ConceptPlaywrightCypressMochaJestVitestBunJasminenode:testCucumber
Test succeededpassedpassedpassedpassedpassedpasspassedpassed: true, no flagsPASSED
Test failedfailedfailedfailedfailedfailedfailfailedpassed: falseFAILED
Ran out of timetimedOutfailedfailedfailedfailedfailfailedpassed: falseFAILED
Deliberately not runskippedpendingpendingpendingskippedskippendingskip flagSKIPPED
Placeholder / not writtenskipped (via test.fixme)pendingpendingtodotodotodopendingtodo flagPENDING

node:test has no status strings: it reports a passed boolean with skip/todo as separate flags, which is why its column reads differently from the rest. A skipped test reports passed: true, and so does a todo test whose body passed; a todo test whose body failed keeps passed: false. Count a node:test success only when the boolean is true and neither flag is set.

Read the "deliberately not run" row twice, and set it against "meant to run, never did" in the next table. Cypress uses skipped for the second case; Playwright uses skipped for the first. Between those two cells sits most of the confusion in this article and, in practice, a fair amount of silently lost coverage.

Statuses only one or two runners have

The rest of the vocabulary is runner-specific. Each status below exists because one runner needed to express something the others do not model, which is exactly why these statuses do not survive translation between tools.

ConceptCauseRunnerStatus
Meant to run, never didRun halted: Ctrl+C, maxFailuresPlaywrightinterrupted
Never started, worker died firstPlaywrightskipped
A shared hook failed, taking the block downCypressskipped
Steps after a failing, undefined or pending stepCucumberSKIPPED
Excluded by a filterAnother test used .onlyJest (jest-jasmine2 only; circus reports pending)focused / disabled
Same idea, via fit / fdescribeJasmineexcluded
Not finished yetCollected but still runningVitestpending
No matching implementationA Gherkin step with nothing behind itCucumberUNDEFINED
Matches several implementationsA step matching two or more definitionsCucumberAMBIGUOUS
Expected to failFolded into the outcome via expectedStatusPlaywrighttest.fail()
A modifier, not a recorded statusJest / Buntest.failing
A modifier, not a recorded statusVitesttest.fails
An option, folded into the passed booleannode:testexpectFailure

The last group deserves a separate note: the "expected to fail" entries are modifiers, not statuses. A modifier changes how the runner interprets a result, not what the runner records. Only Playwright folds the expectation into the reported outcome.

What this means in practice

Status words mean different things in different runners:

  • skipped flips meaning between runners. In Cypress it means a hook failed and these tests never ran. In Playwright it means someone wrote test.skip().
  • The quiet statuses need the most attention. Cypress skipped and Playwright interrupted mark tests that produced no signal at all. A failure is loud; these are not.
  • Retries decide what you can learn later. Playwright, Vitest, Cypress, mocha and Cucumber keep the failed attempts, so tooling can derive flakiness from them. Jest and Bun report the passing retry as a plain green test, so a flaky test there looks stable unless you add custom reporting.

This vocabulary collision is why Currents normalizes every runner's statuses into one canonical set on its dashboard. Currents stores every attempt with its own status and artifacts, then derives a single outcome per test, so a Playwright run, a Cypress run and a Jest run read the same way:

  • passed: the test ran and did what it declared it would do, on every attempt. A test.fail() test that failed as declared counts as passed.
  • failed: no attempt produced the expected result: an assertion failed, an exception was thrown, or the attempt timed out.
  • flaky: the attempts disagree: some matched the expected status and some did not. The test eventually recovered, but it is not stable.
  • ignored: the test was deliberately not run. Playwright skipped, Cypress and mocha pending, Jest pending and todo, Vitest skipped: every runner's flavor of intentional exclusion maps here.
  • interrupted: the test was meant to run and never completed: the run was halted mid-flight (Ctrl+C, maxFailures) or a failed hook took the block down (Cypress skipped). A worker crash is not in this bucket: as the Playwright section showed, the test that was running reports failed, and the tests that never started report skipped, which the runner's collapse forces into ignored.

Keeping ignored and interrupted apart is what makes this useful: runners collapse both into one word, which hides the tests that were meant to run but never did. We describe how we model test statuses in our docs.


Scale your Playwright tests with confidence.
Join hundreds of teams using Currents.
Learn More

Trademarks and logos mentioned in this text belong to their respective owners.

Recent Posts