Why does a Cypress spec fail to build when it imports your server's database module?
answer
- Compiled in Node, executed in the browser
- The failure happens before any test runs
- Node built-ins have no browser equivalent
- Everything imported joins the bundle
- A handler in Node is the door
basics
~20 sA spec is bundled for the browser before it runs, and a database module depends on Node built-ins the bundler cannot resolve for a browser target. Move that code into a Node handler and call it from the test.
solid answer
~40 sCypress preprocesses your spec and support files before a single test runs: it compiles and bundles them **in Node** and then serves the bundle to the browser, where the test actually executes. Everything the spec imports is pulled into that bundle. A database driver, an ORM, `fs`, or any server SDK reaches for Node built-ins that have no browser equivalent, so the bundler either fails to resolve them or drags in heavy shims. The import is not a runtime problem you can work around with a try/catch — it fails while the spec is being built. Server-side work belongs in the **Node half** instead: register a handler in `setupNodeEvents` and reach it from the test with `cy.task()`, so the code runs in Node and is never bundled for the browser at all.
code
javascript · 11 lines// cypress/e2e/statements.cy.js
// import { pool } from '../../server/src/db' <- would break the browser bundle
describe('statement list', () => {
it('opens the latest statement period', () => {
cy.task('latestStatementPeriod', 'ACC-77').then((period) => {
cy.visit(`/statements/${period}`)
cy.get('[data-cy=statement-row]').should('have.length', 12)
})
})
})go deeper
Recall that test code runs in the browser, so a spec can only import things a browser can run. Being able to say that much about a failing import is enough.
Explain the preprocessing step: the spec and its whole import graph are bundled in Node for a browser target, which is why the failure appears before any test executes.
An interviewer expects the redesign, not just the diagnosis. Show where you would move the server-side code, what crosses the boundary as data, and how you keep the support file's bundle small.
Own the boundary as a rule the team can follow: what a suite is allowed to reach for directly, and how you stop server helpers from creeping into shared test code over time.
## A spec is compiled for the browser, not for Node It is easy to read `cypress/e2e/statements.cy.js` as a Node script: it sits in your repo next to server code, it uses `import`, and you started it from a terminal. It is not. Cypress preprocesses each spec and the support file — compiling TypeScript and JSX, then bundling the whole module graph — and serves the result to the browser, where the test runs in the same event loop as the application under test. The bundling step is where the import fails. By the time any of your test code executes, the bundle either exists or it does not, so this is a **build** error and not something a runtime guard can catch. ## Why the database module cannot come along - A Postgres or MySQL driver opens **TCP sockets**. There is no browser API for that. - ORMs and server SDKs pull in Node built-ins — `fs`, `net`, `tls`, `child_process` — that a browser bundle has no implementation for. - The default preprocessor is webpack-based, and modern webpack does not silently polyfill Node core modules. It fails to resolve them, or you configure heavy shims that bloat the bundle without making the code work. - Even a module that *does* resolve may drag its whole dependency graph in. A barrel file that re-exports your service layer will pull server code into the browser bundle just because you wanted one formatter from it. ## What can be imported safely | Import in a spec | Outcome | |---|---| | `import fs from 'fs'` | Fails: a Node built-in with no browser equivalent | | `import { pool } from '../../server/src/db'` | Fails or shims: the driver underneath needs sockets | | `import { formatAmount } from '../../src/money'` | Fine, if it is pure JavaScript or TypeScript | | `import dayjs from 'dayjs'` | Fine: an npm package that runs in a browser | | `import { api } from '../../src/index'` | Risky: a barrel can pull server modules in behind it | The rule of thumb is simple: if the module would not run in a browser tab, it will not run in a Cypress spec either. ## Where the code should live instead 1. Move the server-side work into the **Node half** — a handler registered in `setupNodeEvents` under the `task` event, in or beside the Cypress config file, which *is* Node code and may import anything Node can load. 2. Call it from the test with `cy.task(name, arg)`, which crosses the boundary and yields the handler's return value to the next command. 3. Keep the payload in both directions serializable, since it is `JSON.stringify()`-shaped on the way across; a connection handle or a function does not survive the trip, only the data you extracted from it. For the bank statement app that means the spec never speaks to Postgres. It asks the Node half for the latest statement period, gets a string back, and carries on driving the page. ## The support file multiplies the cost `cypress/support/e2e.js` is bundled into the run for every spec, so an import added there is paid on every spec startup, not once. Two habits keep it cheap: - Import the specific module you need rather than a barrel that re-exports a whole directory. - Keep server-side helpers out of it entirely; if a helper is only meaningful in Node, it belongs behind a task. ## Why the constraint exists at all None of this is an oversight. Cypress runs your test code inside the browser precisely so that it has native access to the application's DOM, timers and window — no wire protocol, no object serialization between the test and the app. The price of that placement is that the test is browser code, and JavaScript running in a browser is all it can ever be. The Node half exists to give that browser code a controlled door back to the machine, and `cy.task()` is that door. ## The other direction is not symmetric Node code has no such restriction, and that asymmetry is the design. A handler registered in the config file may import a driver, an ORM, `fs`, or your service's own modules, because it is evaluated by Node. What it may not do is hand any of that *back*: - Return data, not handles. A connection, a stream or a function does not survive the crossing; extract the value you need and return that. - Whatever comes back arrives in the browser as ordinary JSON-shaped data, so a `Date` returns as a string unless you convert it yourself on one side or the other. ## A related failure that looks the same One more build-time error is worth recognising, because it produces the same "module not found" wording for a completely different reason: **path aliases**. If a spec imports through an alias such as `@/fixtures/statement`, the bundler has to be told how to resolve it. The default preprocessor does not read aliases from your `package.json`, and it does not automatically apply TypeScript's `compilerOptions.paths` from `tsconfig.json` — those are a type-checking feature, not a bundler feature. The fix there is a resolver configuration on the preprocessor, not a move into Node, so distinguishing the two saves a lot of time: ask whether the module is *unresolvable* or merely *unfindable*.
- Can a Cypress spec import anything from node_modules, or is that blocked too?It can, as long as the package works in a browser. A date library or an assertion helper bundles fine. What breaks is a package that needs Node built-ins such as sockets or the filesystem, because the bundler has nothing to resolve those to.
- If the code is TypeScript, why does the compiler not catch the problem first?Type checking and bundling are different steps. Types for a Node module resolve happily, so the editor stays quiet; the failure appears when the preprocessor tries to produce a browser bundle and cannot resolve the runtime dependency underneath it.
saying these in an interview costs you the question
- Thinks a spec is a Node script because it uses import
- Tries a try/catch around a failing import
- Expects the bundler to polyfill Node built-ins silently
- Adds server helpers to the support file for convenience
- Cannot name where server-side code should live instead