skip to content

How strictly should a Cypress suite keep every cy.task() payload to plain JSON?

level: principalimportance: should knowfreq 36%

answer

  1. The serialisation is not optional
  2. Decide where the loss stays visible
  3. Plain payloads read straight in the log
  4. A revive layer is a second contract
  5. Prefer boring until pain repeats

basics

~20 s

Strictly, in most suites. The seam already serialises, so the only real choice is whether that loss is explicit in a small set of plain payload shapes, or hidden behind a revive layer somebody has to keep honest.

solid answer

~50 s

The seam is not negotiable — Cypress serialises whatever crosses `cy.task()` — so the decision is only where the loss is made visible. The cheap standard is to declare that every task takes and returns plain JSON: strings, numbers, booleans, arrays, plain objects, with dates as ISO strings. It costs a little conversion in the Node handler and buys payloads you can read straight out of the Command Log and assert on without unwrapping. The alternative is a revive layer on both sides that restores `Date`, `Map` and class instances. It buys ergonomics for a suite with genuinely rich domain objects, and it costs a second contract that drifts, a failure mode where the two sides disagree about a shape, and log output that no longer matches what the test sees. Prefer the plain contract until a specific, repeated pain justifies the layer.

go deeper

for a junior

Know that Cypress task payloads are serialised, and that keeping them to plain JSON is the default most teams settle on rather than a limitation to fight.

for a middle

Be able to say what a revive layer would have to do on both sides of the seam, and which value types make one tempting in the first place.

for a senior

Argue the trade-off against a concrete failure: what a drifting payload shape costs when the assertion that breaks is three layers away from the boundary.

for a principal

Own the standard and its enforcement — where it is written down, who reviews a new task handler against it, and what evidence would make you revisit the choice.

## The choice the seam forces Nothing about the boundary is configurable. `cy.task()` serialises the argument on the way out and the return value on the way back, so a `Date` becomes a string, a `Map` becomes `{}`, a function becomes `null`, and a class instance loses its prototype. The question is not whether the loss happens. It is whether the suite acknowledges it in the shape of its payloads, or papers over it with a conversion layer. Both are defensible. The wrong answer is the unexamined one: a suite where some tasks hand back domain objects, some hand back plain rows, and every new test discovers by experiment what shape it is dealing with. ## What the plain-JSON standard buys The standard is a sentence: *every task takes one plain-JSON object and returns plain JSON or `null`; dates are ISO strings, identifiers are strings, enums are strings.* - **Assertions read straight.** `.its('createdAt').should('eq', '2026-09-01T00:00:00.000Z')` is checking the value that actually exists, not one a layer rebuilt. - **The Command Log matches reality.** What the log shows a task yielded is what the next command receives, so debugging a failure does not require knowing about a transformation. - **The handler is the only place conversion happens.** The Node side, which has the real objects, decides the wire shape once — instead of every spec re-deriving it. - **New tasks are cheap to review.** "Is this plain JSON?" is a question a reviewer can answer by looking. - **It survives a refactor.** When a Node-side library swaps its return type, the handler still has to produce the same JSON, so the change is caught at the seam. The cost is real but small: a few JavaScript `toISOString()` calls, an `Object.fromEntries()` on a map, and the mild indignity of a spec working with strings where the domain has richer types. ## What a revive layer buys, and what it costs A revive layer is a paired serialiser and deserialiser — hand-written, or a library that tags values so the far side can rebuild them — installed on both sides of the seam. - **It buys ergonomics.** A suite that constantly compares dates, ranges or sets stops writing conversions in the spec. - **It buys uniformity** when many tasks return the same rich types, so the shape is defined once rather than per handler. - **It costs a second contract.** Two codebases must agree on the tagging, and they are versioned together only by convention. - **It costs a failure mode.** When the two sides disagree, the spec builds a wrong-but-plausible value and the failure surfaces in an assertion far from the boundary. - **It costs legibility.** The Command Log shows the wire form; the test sees the revived form. The two no longer match, and the person debugging at 6pm has to know that. - **It hides drift.** A renamed or dropped field can be silently defaulted by the reviver instead of failing where the shape actually changed. ## How to decide Ask these in order, and stop at the first honest "no": 1. **Is the pain repeated?** Two or three conversions in a suite is not a pattern. Twenty of the same conversion is. 2. **Is it the same types every time?** A layer pays off for a small closed set — dates, decimals, sets. It does not pay off for arbitrary domain classes. 3. **Would the spec actually use the behaviour?** If the spec only reads fields, restoring a class buys nothing; the prototype was never needed. 4. **Who owns it a year from now?** A layer at the seam is infrastructure. If nobody owns the test infrastructure, do not add more of it. 5. **Can the same benefit come from types instead?** In a TypeScript project, sharing a JSON-only type between the spec and `setupNodeEvents` catches a `Date` in a payload before the run — the loss is prevented rather than reversed. ## The middle position that usually holds Most suites land here, and it is worth naming as its own answer rather than a compromise: - Payloads stay plain JSON, enforced by review and, where the project has types, by the shared type. - Conversion lives in **one** helper on the Node side per task family, not scattered across handlers. - The spec gets small read helpers where a repeated conversion is genuinely noisy — `asDate(tenant.createdAt)` — which is local, obvious, and deletes cleanly. That keeps the boundary visible where it is, which is the property worth protecting. A test suite exists to make failures legible; a layer whose whole job is to make a boundary invisible is working against that, and it should have to earn its place. ## What to write down Whatever the team chooses, put it where a new task handler is written, not in a wiki nobody opens: a short comment at the top of `setupNodeEvents`, and one example task that models the shape. The decision that matters most is not plain-versus-revive — it is that every task in the suite answers the question the same way.

  • What does a revive layer hide when a Cypress task's payload shape changes?
    The spec keeps constructing a value from whatever arrives, so a renamed or dropped field surfaces as a wrong result deep inside an assertion rather than as a failure at the boundary. A plain contract fails where the shape is actually wrong.
  • How would you enforce a plain-JSON task contract in a TypeScript Cypress project?
    Declare each task's argument and result as JSON-only types and share them between the spec and `setupNodeEvents`. A `Date` or a `Map` in a payload is then rejected before the run, which puts the failure at the place the loss would have happened.

saying these in an interview costs you the question

  • Says the seam can be made lossless with enough effort
  • Adds a revive layer before any repeated pain exists
  • Treats the task payload shape as an undocumented detail
  • Passes domain objects across and debugs the fallout per test
  • Insists every task must return a single scalar value