Testing a Next.js + Expo monorepo: four layers, one CI pipeline

Storyie Engineering Team
8 min read

How we structured Jest and Playwright across shared packages, a Next.js web app, and an Expo mobile app in a single pnpm monorepo — the config traps we hit, the mock discipline that saved us, and the smoke-test loop that catches production regressions before users do.

Storyie runs as a pnpm monorepo: a Next.js web app, an Expo mobile app, and a set of shared packages that both consume. The code-sharing is the whole point — we write subscription logic once in @storyie/subscription, ship it to both surfaces, and trust that one test suite covers both. But that arrangement immediately raises the question of where tests live, which environment they run in, and how to wire them all into CI without it becoming a maintenance burden.

This post covers the four-layer structure we settled on, the Jest configuration traps specific to monorepos, how we keep Expo tests manageable, and the Playwright setup that catches production regressions before users do.

TL;DR

  • Shared packages run under testEnvironment: "node" with 80% coverage enforced — this is the highest-leverage layer.
  • Web tests use ts-jest + jsdom; monorepo package paths are resolved via moduleNameMapper so nothing needs to be pre-built.
  • Expo tests use the jest-expo preset; transformIgnorePatterns needs a long allowlist and will need growing over time.
  • Mock at boundaries only — native APIs, external services, storage. Run real application code everywhere else.
  • E2E splits into production smoke tests (scheduled daily) and functional write tests (PR-gated, with a production safety block).

Layer

Tool

Target

When it runs

Shared package unit tests

Jest (ts-jest / node)

subscription, jobs, etc.

CI + local

Web unit + integration

Jest (ts-jest / jsdom)

API logic, service layer, components

CI + local

Expo unit + integration

Jest (jest-expo / babel-jest)

Context, hooks, services, sync flow

CI + local

Web E2E

Playwright

Auth flow, CRUD, smoke tests

CI (self-hosted)

The key design decision is that each layer gets the right testEnvironment. The shared packages run in Node because they have to be platform-agnostic; the web layer uses jsdom; the mobile layer uses jest-expo's React Native preset. Mixing these up produces either false positives or tests that will never pass.

Shared packages: enforcing platform-agnostic at the test level

Cross-platform packages like @storyie/subscription have a hard rule: no "use client", no "use server", no next/*, no expo-*. The test configuration enforces this mechanically.

// packages/subscription/jest.config.js
module.exports = {
  preset: "ts-jest",
  testEnvironment: "node",
  coverageThreshold: {
    global: { branches: 80, functions: 80, lines: 80, statements: 80 },
  },
};

Running under node means any accidental import of a browser or React Native API will fail immediately. If a future PR tries to import window.localStorage from a shared utility, the test suite tells you before review does.

The 80% coverage threshold on these packages is non-negotiable. This is the logic that both web and mobile trust — plan limits, billing period calculations, entitlement checks. A bug here affects both platforms simultaneously, so we treat shared package tests as the highest-leverage layer in the stack.

Web tests: moduleNameMapper and the ESM allowlist

The web Jest config uses ts-jest + jsdom. The monorepo-specific problem is package path resolution.

// apps/web/jest.config.js
moduleNameMapper: {
  "^@/(.*)$": "<rootDir>/$1",
  "^@storyie/lexical-common$": "<rootDir>/../../packages/lexical-common/src/index.ts",
  "^@storyie/lexical-editor$": "<rootDir>/../../packages/lexical-editor/src/index.ts",
},

Pointing @storyie/* imports directly at TypeScript source means tests run against uncompiled packages — no build step required, and changes to shared package code are reflected immediately in web tests without a rebuild cycle.

The second piece of configuration that bites everyone eventually is transformIgnorePatterns. Jest skips transpilation for everything inside node_modules by default. ESM-only packages — rehype, remark, unified, and a growing list of others — break under that default.

transformIgnorePatterns: [
  "node_modules/(?!(@storyie/lexical-common|rehype.*|remark.*|unified|nanoid)/)",
],

Every time we add an ESM dependency, tests break in CI until the allowlist gets updated. We have accepted this as the normal maintenance rhythm: CI catches the breakage, the fix is adding one package name to the pattern, and the knowledge that new ESM deps need this treatment is baked into our team conventions.

Test directory layout

Web tests are organized by concern, not by file location:

apps/web/tests/
├── api/              # route-level validation and business logic
├── lib/              # utility functions
├── services/         # tenant, notification, comment, subscription services
├── integration/      # tests that span multiple modules
└── pages/            # SEO metadata and server component rendering

The service layer gets the most coverage. tenantService, commentService, and subscriptionService concentrate the business logic that matters most to test, and mocking below them (at the database call level) means UI changes don't invalidate the tests.

Expo tests: jest-expo and the mock discipline question

Expo has the most involved configuration of the three layers. React Native's module ecosystem mixes CommonJS and ESM across a lot of packages, and transformIgnorePatterns requires a long allowlist from day one:

// apps/expo/jest.config.js
preset: "jest-expo",
transformIgnorePatterns: [
  "node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|react-navigation|@react-navigation/.*|@storyie|@revenuecat)",
],

Where to put mocks

The global setup file handles native APIs that every test needs:

// __tests__/setup.ts
jest.mock("expo-secure-store");
jest.mock("expo-local-authentication");
jest.mock("@react-native-async-storage/async-storage");

Individual test files mock only what their specific target depends on:

// syncFlow.test.ts
jest.mock("../../services/cacheStorageService", () => ({
  getItemSync: jest.fn(),
  setItemSync: jest.fn(),
}));

jest.mock("../../services/diaryService", () => ({
  createOrUpdateDiary: jest.fn(),
  getDiaryById: jest.fn(),
}));

The discipline here matters more than the configuration does. In the sync flow tests, we mock the cache storage service and the network call — but the queue processing, retry logic, and state transitions run against real code. This catches bugs in the actual orchestration logic. Over-mocking that layer produces a test suite where every test passes and the sync flow still breaks in production.

The rule we follow: mock at the boundary (native APIs, external services, storage), run real code everywhere inside that boundary.

Expo test structure

apps/expo/__tests__/
├── context/          # TagFilterContext and other React contexts
├── features/         # UI feature tests (NotesList, etc.)
├── hooks/            # usePlanFeatures and similar
├── integration/      # sync flow, draft restoration
└── services/         # subscriptionService, proStatusService

Hook tests use @testing-library/react-hooks with the React Native renderer. Context tests wrap the component under test in a custom render helper that provides the necessary providers — standard React Testing Library practice, but worth noting since the provider tree in Expo is deep.

E2E with Playwright: two modes with different purposes

The web E2E suite splits into two categories with different triggers and different risk profiles.

Daily smoke tests against production

apps/web/e2e/smoke/
├── static-pages.spec.ts   # landing page, terms, privacy
└── dynamic-pages.spec.ts  # public diary pages

These run every morning at 9 AM via a scheduled GitHub Actions workflow:

on:
  schedule:
    - cron: "0 9 * * *"

When a smoke test fails, the workflow automatically opens a GitHub issue. The practical effect is that the first thing we see when we open GitHub in the morning is whether anything broke in production overnight. No Slack alerting, no dashboards — just an issue that either exists or doesn't.

Functional write tests on PRs

apps/web/e2e/functional/
├── authenticated.spec.ts  # basic post-login flow
└── diary-crud.spec.ts     # create, view, edit

These run on push and PR. They create a real test user in Supabase programmatically and save the authenticated session to storage so subsequent tests skip the login flow.

The safety mechanism for write tests is explicit and mandatory:

test.beforeAll(async ({}, testInfo) => {
  const baseURL = testInfo.project.use?.baseURL ?? "";
  if (/storyie\.com/.test(baseURL) && !process.env.E2E_ALLOW_PRODUCTION) {
    throw new Error("Write tests blocked on production");
  }
});

This guard runs before every write spec. It detects the production URL and throws unless E2E_ALLOW_PRODUCTION is explicitly set. That variable appears in no CI configuration, which means the only way write tests reach production is a deliberate manual override.

CI: self-hosted runner, path filters, sequential steps

The test workflows run on a self-hosted runner. The main benefit is dependency caching: node_modules persists across runs on the same machine, so pnpm install is fast unless the lockfile changed.

Path filters limit which tests run:

on:
  push:
    branches: [main, develop]
    paths:
      - "apps/web/**"
      - "packages/**"

Shared package changes trigger web (and separately Expo) tests because a bug in a shared package can surface on either platform. Changes scoped to apps/web only trigger the web suite. This keeps CI time proportional to the scope of the commit.

The step order in the web workflow matters:

  1. pnpm install --frozen-lockfile
  2. pnpm build:packages — generates type information the tests depend on
  3. pnpm --filter @storyie/web type-check
  4. pnpm format
  5. pnpm --filter @storyie/web test

Running the type check before tests means type errors surface with a clearer diagnostic than a Jest import failure. The format check before tests avoids the situation where tests pass but the commit would fail linting on push.

Lessons from running this in practice

Shared package coverage is the highest-leverage investment. Application-level tests break when UI changes; shared package tests guard the logic that both platforms depend on. Keeping that layer at 80% coverage has caught real bugs that would have shipped silently to mobile.

Don't front-load transformIgnorePatterns. Trying to predict every ESM dependency in advance wastes time. Add packages to the allowlist when CI breaks. Two or three iterations is normal when pulling in a new remark plugin or unified transform.

Mock only the boundary. Native APIs, external services, and storage are legitimate mock targets. Application orchestration logic — queues, retries, state machines — should run as written. If your tests pass but your production sync flow breaks, the mocks are too deep.

Smoke tests are not a coverage metric. The morning smoke tests exist for one purpose: to find out quickly if something stopped working in production. They cover the happy path on the pages that matter most. Comprehensive coverage is what the unit and integration layers are for.

Environment separation is the architecture. Node for shared packages, jsdom for web, jest-expo for mobile. Each environment enforces constraints that match the platform the code actually runs on. Getting this right up front removes an entire class of "the tests pass but it breaks on device" surprises.

Related Posts

Try Storyie

Storyie is live at storyie.com and on the iOS App Store. The test infrastructure described here is what keeps a diary written on web opening correctly on mobile — the same nodes, the same formatting, no silent data loss.