Skip to content

Latest commit

 

History

History
340 lines (261 loc) · 11.9 KB

react.md

File metadata and controls

340 lines (261 loc) · 11.9 KB

React Adapter

Intercept fetch calls in React apps with Schmock. Works in both tests (Node/jsdom) and browser (dev-time).

bun install @schmock/react

Basic Usage

import { schmock } from '@schmock/core'
import { SchmockProvider } from '@schmock/react'

const mock = schmock()
mock('GET /api/users', [{ id: 1, name: 'Alice' }])
mock('POST /api/users', ({ body }) => [201, { id: 2, ...body }])

function App() {
  return (
    <SchmockProvider mock={mock}>
      <YourApp />
    </SchmockProvider>
  )
}

SchmockProvider patches globalThis.fetch while it renders and restores it on unmount. Any client that uses globalThis.fetch — including React Query or SWR when configured with fetch — is intercepted automatically. Clients using another transport are not intercepted. The installer commits before descendant layout effects, so a child may safely fetch from useLayoutEffect on its first mount. Rendering on the server installs nothing: without a DOM the provider only supplies the mock through context.

The DOM check runs when the provider renders and commits, not when the module is imported. A test that registers jsdom or happy-dom after its hoisted imports still intercepts. Environments with no document at all (React Native, react-test-renderer under Node) install nothing, by design.

If another library replaces globalThis.fetch, a later Schmock provider wraps that current implementation as its passthrough boundary. Cleanup never overwrites a third-party replacement it no longer owns.

Ownership and precedence

The provider takes an interception lease per mounted provider, and a mock can back several leases at once. Nesting a provider inside another provider for the same mock is allowed, as is renderWithSchmock({ mock }) under an outer provider.

Every lease of the same mock applies its own baseUrl and beforeRequest. The mock is then asked each distinct resulting request (method and path) once, newest lease first. An outer provider whose beforeRequest strips /api therefore keeps serving its routes under a nested provider or renderWithSchmock({ mock }). When two leases produce the same method and path, the newest lease's passthrough, beforeResponse and errorFormatter apply to it. An older passthrough: false lease does not turn a newer passthrough: true lease's miss into a 404.

Changing options (including a fresh inline beforeRequest on every render) reconfigures the existing lease in place; it does not re-register it. The provider therefore keeps the dispatch position it acquired at mount, so reconfiguring one root never promotes it above a root that mounted later. Interception is re-acquired only when the mock prop itself changes — a new owner legitimately takes a new position at the front.

Calling mock.reset() while the provider is mounted clears routes, state, history, plugins, and listeners but preserves the provider's explicit interception lease. Re-register routes on the same mock without remounting the provider.

Render-time interception

SchmockProvider takes its interception lease while it renders, so a fetch started during render is intercepted on the first mount. This covers Suspense data fetching (TanStack useSuspenseQuery, SWR with suspense, use() over a promise created in render), and renderWithSchmock gets the same guarantee.

Strict Mode: The provider holds exactly one lease under StrictMode. A render that React discards without committing (a suspended first mount, a render that throws) releases its lease at the next microtask.

One gap remains. In a time-sliced (transition) render, a fetch started in a later render slice, before the provider commits, can be missed. If you depend on that case, call mock.intercept() before the first render.

Options

<SchmockProvider
  mock={mock}
  options={{
    baseUrl: '/api',         // only intercept URLs starting with this prefix
    passthrough: true,       // pass unmatched routes to real fetch (default: true)

    beforeRequest: (request) => ({
      ...request,
      headers: { ...request.headers, 'x-tenant': 'dev' },
    }),

    beforeResponse: (response) => ({
      ...response,
      headers: { ...response.headers, 'x-mock': 'true' },
    }),

    errorFormatter: (error) => ({
      message: error.message,
      timestamp: new Date().toISOString(),
    }),
  }}
>
  <YourApp />
</SchmockProvider>

passthrough

When true (default), requests that don't match any Schmock route are forwarded to the real fetch. Set to false to return errors for unmatched requests — useful in tests to catch unexpected API calls.

baseUrl

Only intercept requests whose pathname starts with this string. Non-matching requests go straight to real fetch without being processed.

baseUrl only filters which requests are mocked. It does not strip the prefix, so register routes with the full path (GET /api/users). The Angular adapter's baseUrl strips the prefix instead.

errorFormatter

errorFormatter(error, request) formats core-marked internal exceptions — an error thrown by a route generator or a plugin — and errors thrown by the beforeRequest/beforeResponse hooks, matching the Express and Angular adapters. It does not reinterpret an ordinary user-defined 500 route response such as [500, { error: 'domain failure' }].

The second argument is the request as routed: the AdapterRequest after beforeRequest once that hook has returned, the pre-hook request when the hook threw, and the incoming request without a body when the body could not be read. A one-argument formatter still works.

On the core-marked exception path, provenance is captured before beforeResponse runs, so a hook that clones the response with { ...response } does not suppress the formatter. The post-hook status gates the replacement: a beforeResponse that rewrites an exception into a 503 (or a 200) is honoured and the formatter is not invoked. That response keeps the post-hook response headers — retry-after and friends survive — with content-type forced to application/json, and a formatter that throws, or that returns a body which cannot be serialized, yields { error: 'Internal Server Error', code: 'INTERNAL_ERROR' } without being invoked a second time.

A hook that throws is handled separately: that response inherits no headers beyond content-type: application/json, and a formatter that throws while handling it propagates, rejecting the fetch call. If the formatter returns a body the transport cannot serialize (a BigInt, a circular object, an undefined leaf), the response falls back to a 500 { error: 'Internal Server Error', code: 'INTERNAL_ERROR' }, the same fallback the core-marked exception path uses.

useSchmock Hook

Access the mock instance from any component inside the provider:

import { useSchmock } from '@schmock/react'

function DevTools() {
  const mock = useSchmock()

  return (
    <div>
      <p>Requests: {mock.callCount()}</p>
      <button onClick={() => mock.resetHistory()}>Clear</button>
    </div>
  )
}

Throws if used outside a SchmockProvider.

Seeing mocked requests in Chrome DevTools

Requests the provider mocks never reach the network, so Chrome's Network panel does not list them. Pipe devtoolsPlugin() from @schmock/devtools into the mock to log each one as a collapsed console group and a Performance-panel track entry:

import { schmock } from '@schmock/core'
import { devtoolsPlugin } from '@schmock/devtools'
import { SchmockProvider } from '@schmock/react'

const mock = schmock()
mock.pipe(devtoolsPlugin())
mock('GET /api/users', [{ id: 1, name: 'Alice' }])

function App() {
  return (
    <SchmockProvider mock={mock}>
      <YourApp />
    </SchmockProvider>
  )
}

The provider needs no extra prop: the plugin observes the lease it already takes. This is unrelated to the DevTools component above, which is your own UI reading the mock. The plugin writes to the browser's DevTools instead. mock.reset() removes the plugin while the lease stays, so pipe it again after a reset. See the DevTools guide.

To list mocked requests in the Network panel itself, XHR included, start the service-worker relay before the first render. Copy the worker script once with npx schmock-devtools init public, then:

import { createRoot } from 'react-dom/client'
import { startServiceWorkerRelay } from '@schmock/devtools'

await startServiceWorkerRelay()

createRoot(document.getElementById('root')!).render(
  <SchmockProvider mock={mock}>
    <App />
  </SchmockProvider>,
)

The relay routes every lease on the page, the provider's included, so the provider needs no new prop. Mocked requests then show as Network rows with Size (ServiceWorker). A relay that cannot start warns once, and the provider keeps answering fetch in the page. See Network panel relay.

Testing

With SchmockProvider directly

import { render, screen, waitFor } from '@testing-library/react'
import { schmock } from '@schmock/core'
import { SchmockProvider } from '@schmock/react'

it('loads users', async () => {
  const mock = schmock()
  mock('GET /api/users', [{ id: 1, name: 'Alice' }])

  render(
    <SchmockProvider mock={mock}>
      <UserList />
    </SchmockProvider>
  )

  await waitFor(() => {
    expect(screen.getByText('Alice')).toBeDefined()
  })

  expect(mock.called('GET', '/api/users')).toBe(true)
})

With renderWithSchmock shorthand

A convenience wrapper that creates the mock, registers routes, and wraps your component:

import { renderWithSchmock } from '@schmock/react/testing'

it('loads users', async () => {
  const { mock } = renderWithSchmock(<UserList />, {
    routes: [
      ['GET /api/users', [{ id: 1, name: 'Alice' }]],
    ],
  })

  await waitFor(() => {
    expect(screen.getByText('Alice')).toBeDefined()
  })

  expect(mock.callCount()).toBe(1)
})

renderWithSchmock returns the standard @testing-library/react RenderResult plus a mock property for assertions.

The testing entry imports the same provider context as the package root, so useSchmock() from @schmock/react works inside renderWithSchmock() from @schmock/react/testing.

Note: renderWithSchmock requires @testing-library/react as a peer dependency. It is exported from @schmock/react/testing (a separate entry point) so projects that don't use Testing Library are not affected.

OpenAPI specs in jsdom and happy-dom

A file-path spec passed to @schmock/openapi ('./petstore.yaml'), and any relative external $refs it has with refs: { external: true }, is read from disk in jsdom and happy-dom test environments too, not fetched relative to window.location. See OpenAPI-Based Tests.

Stateful Mocking

Combine with Schmock's state management for realistic CRUD flows:

const mock = schmock({
  state: { users: [{ id: 1, name: 'Alice' }], nextId: 2 },
})

mock('GET /api/users', ({ state }) => (state as any).users)
mock('POST /api/users', ({ body, state }) => {
  const s = state as any
  const user = { id: s.nextId++, ...body as object }
  s.users.push(user)
  return [201, user]
})
mock('DELETE /api/users/:id', ({ params, state }) => {
  const s = state as any
  s.users = s.users.filter((u: any) => u.id !== Number(params.id))
  return [204, null]
})

render(
  <SchmockProvider mock={mock}>
    <UserManager />
  </SchmockProvider>
)

Helper Functions

Response helpers are available from @schmock/core:

import { notFound, badRequest, created, noContent } from '@schmock/core'

mock('GET /api/users/:id', ({ params, state }) => {
  const user = state.users.find(u => u.id === Number(params.id))
  return user || notFound('User not found')
})

mock('POST /api/users', ({ body }) => {
  if (!body?.name) return badRequest('name is required')
  return created({ id: 3, ...body })
})

mock('DELETE /api/users/:id', () => noContent())