---
title: "What does \"skipped\" mean - test statuses review across popular JavaScript runners"
description: "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."
canonical: "https://currents.dev/posts/test-status-translation-guide"
updated: "2026-07-31"
---

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

Published 2026-07-31 by Andrew Goldis.

Canonical URL: https://currents.dev/posts/test-status-translation-guide

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.

---


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.

<Note>

<strong>TL;DR</strong>

- 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.

</Note>

## 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`](https://playwright.dev/docs/api/class-testresult#test-result-status), [type definition](https://github.com/microsoft/playwright/blob/v1.62.1/packages/playwright/types/testReporter.d.ts#L725)):

- `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()`](https://playwright.dev/docs/test-annotations), 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()`](https://playwright.dev/docs/api/class-testcase#test-case-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`](https://playwright.dev/docs/api/class-testcase#test-case-expected-status) 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](https://github.com/mochajs/mocha/blob/v11.7.7/lib/runnable.js#L434-L457): `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"](https://mochajs.org/declaring/inclusive-tests/). 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](https://docs.cypress.io/app/core-concepts/writing-and-organizing-tests#Pending) 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.

<Image
  src="/img/posts/test-statuses/skipped-collision.png"
  width={2400}
  height={1240}
  alt="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](https://github.com/jestjs/jest/blob/f49721c78e195558b40913977c9230f5b7f559d8/packages/jest-types/src/TestResult.ts#L8-L15) of its test results, tied with Cucumber for the largest vocabulary here, for very different reasons:

```ts
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**](https://github.com/vitest-dev/vitest/blob/v4.1.10/packages/runner/src/types/tasks.ts#L6-L7) is a union of the run mode and the result:

```ts
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()`](https://vitest.dev/api/advanced/test-case)) 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`](https://bun.com/docs/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()`](https://bun.com/docs/test/writing-tests): 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()`](https://bun.com/docs/test/writing-tests): the "expected to fail" modifier, like Playwright's `test.fail()` and Vitest's [`test.fails()`](https://vitest.dev/api/#test-fails). Unlike `todo`, `test.failing()` always runs.

On flakiness, Bun shares Jest's blind spot. Bun ships a [`--retry` flag](https://bun.com/docs/cli/test), 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`](https://bun.com/docs/cli/test), 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](https://jasmine.github.io/), and several of Jest's stranger status values are inherited rather than designed.

A Jasmine spec ends in one of [four states](https://jasmine.github.io/api/6.3/global.html#SpecDoneEvent):

- `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`](https://jasmine.github.io/api/6.3/global.html#JasmineDoneInfo) 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](https://nodejs.org/docs/latest-v24.x/api/test.html) (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'`](https://nodejs.org/docs/latest-v24.x/api/test.html#event-testcomplete) event whose payload carries:

```js
// '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](https://testanything.org/tap-specification.html), 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](https://cucumber.io/docs/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`](https://github.com/cucumber/messages/blob/943c43728166aa14cd83e1b2369c971e945a6b7e/javascript/src/messages.ts#L461-L469) schema that every language implementation uses:

```ts
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](https://cucumber.io/docs/cucumber/api/)):

- `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`](https://github.com/cucumber/query/blob/2520fb00e9e9b8f890e49c97d240adf6146f99c6/javascript/src/helpers.ts#L3-L13) 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.

<Image
  src="/img/posts/test-statuses/attempts-vs-outcome.png"
  width={2400}
  height={1200}
  alt="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:

```ts
// 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.

| Runner | Retries | Flaky signal | Rule |
|---|---|---|---|
| Playwright | built in | `outcome() === 'flaky'` | first attempt fails, a retry passes |
| Vitest | built in | `diagnostic().flaky` | passed after a retry |
| Cucumber | `--retry` | `willBeRetried` | signalled in real time |
| Cypress | built in | derived, not a status | `passed` + more than one attempt |
| Mocha | `this.retries()` | derived, not a status | `passed` + `currentRetry() > 0` |
| Jest | opt-in (`retryTimes`) | none, retry overwrites the result | flakiness is hidden |
| Bun | `--retry` | none, recovered test reports as passing | flakiness is hidden |
| Jasmine | none | nothing to compare | needs an external wrapper |
| node:test | none | nothing to compare | needs an external wrapper |

### Runners that keep the evidence

**Playwright** gives a fail-then-pass test its own outcome. With [`retries`](https://playwright.dev/docs/test-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](https://vitest.dev/api/advanced/test-case) carry the history:

```ts
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](https://github.com/vitest-dev/vitest/blob/v4.1.10/packages/vitest/src/node/reporters/reported-tasks.ts#L227) 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](https://github.com/cypress-io/cypress/blob/c57bfe0b3b763d4983d5a2e133e2e5ec5d9b90e8/packages/reporter/src/test/test-model.ts#L137-L139) derives the signal from them:

```ts
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()`](https://mochajs.org/declaring/retrying-tests/), 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:

```js
// 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`](https://github.com/cucumber/cucumber-js/blob/v13.2.1/docs/retry.md), every [`TestCaseFinished`](https://github.com/cucumber/messages/blob/943c43728166aa14cd83e1b2369c971e945a6b7e/jsonschema/messages.md#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](https://github.com/jestjs/jest/issues/16134).

A partial workaround exists, and it rests on implementation behavior rather than documented contract. [`AssertionResult`](https://github.com/jestjs/jest/blob/f49721c78e195558b40913977c9230f5b7f559d8/packages/jest-types/src/TestResult.ts#L34-L35) declares two optional fields: `invocations` and `retryReasons`. The `jest-circus` runner always fills `invocations`, and it [copies the errors from failed attempts into `retryReasons`](https://github.com/jestjs/jest/blob/f49721c78e195558b40913977c9230f5b7f559d8/packages/jest-circus/src/eventHandler.ts#L211-L218) only when [`logErrorsBeforeRetry`](https://jestjs.io/docs/jest-object#jestretrytimesnumretries-options) 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](https://bun.com/docs/cli/test) 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:

| Concept | Playwright | Cypress | Mocha | Jest | Vitest | Bun | Jasmine | node:test | Cucumber |
|---|---|---|---|---|---|---|---|---|---|
| Test succeeded | `passed` | `passed` | `passed` | `passed` | `passed` | `pass` | `passed` | `passed: true`, no flags | `PASSED` |
| Test failed | `failed` | `failed` | `failed` | `failed` | `failed` | `fail` | `failed` | `passed: false` | `FAILED` |
| Ran out of time | `timedOut` | `failed` | `failed` | `failed` | `failed` | `fail` | `failed` | `passed: false` | `FAILED` |
| Deliberately not run | `skipped` | `pending` | `pending` | `pending` | `skipped` | `skip` | `pending` | `skip` flag | `SKIPPED` |
| Placeholder / not written | `skipped` (via `test.fixme`) | `pending` | `pending` | `todo` | `todo` | `todo` | `pending` | `todo` flag | `PENDING` |

`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.

| Concept | Cause | Runner | Status |
|---|---|---|---|
| Meant to run, never did | Run halted: `Ctrl+C`, `maxFailures` | Playwright | `interrupted` |
| | Never started, worker died first | Playwright | `skipped` |
| | A shared hook failed, taking the block down | Cypress | `skipped` |
| | Steps after a failing, undefined or pending step | Cucumber | `SKIPPED` |
| Excluded by a filter | Another test used `.only` | Jest (`jest-jasmine2` only; circus reports `pending`) | `focused` / `disabled` |
| | Same idea, via `fit` / `fdescribe` | Jasmine | `excluded` |
| Not finished yet | Collected but still running | Vitest | `pending` |
| No matching implementation | A Gherkin step with nothing behind it | Cucumber | `UNDEFINED` |
| Matches several implementations | A step matching two or more definitions | Cucumber | `AMBIGUOUS` |
| Expected to fail | Folded into the outcome via `expectedStatus` | Playwright | `test.fail()` |
| | A modifier, not a recorded status | Jest / Bun | `test.failing` |
| | A modifier, not a recorded status | Vitest | `test.fails` |
| | An option, folded into the `passed` boolean | node:test | `expectFailure` |

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](https://docs.currents.dev/dashboard/tests/test-status) in our docs.


---

## About the author

**Andrew Goldis**, Founder. Andrew Goldis is the founder of Currents. He has spent years building tooling for engineering teams that run large end-to-end suites in CI, and writes about test orchestration, flakiness and keeping pipelines fast as they grow.

LinkedIn: https://www.linkedin.com/in/agoldis/
