Vue Adapter

August 10, 2026 · View on GitHub

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

bun install @schmock/vue

Basic Usage

import { createApp } from 'vue'
import { schmock } from '@schmock/core'
import { schmockPlugin } from '@schmock/vue'

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

const app = createApp(App)
app.use(schmockPlugin, { mock })
app.mount('#app')

schmockPlugin patches globalThis.fetch in the browser when the plugin is installed and restores it when the app unmounts. Calls from your code, Pinia actions, or other clients are intercepted only when they use globalThis.fetch; clients using another transport are not intercepted.

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

Releasing interception

Unmounting the app releases its lease. For an app that never reaches an unmount — one that is never mounted, or whose mount() throws — release it explicitly:

import { restoreSchmockInterception } from '@schmock/vue'

const app = createApp(App)
app.use(schmockPlugin, { mock })

// ...never mounted, or torn down some other way
restoreSchmockInterception(app)

restoreSchmockInterception(app) is idempotent and safe for an app that never intercepted. A mount() that throws releases the lease before rethrowing, so a failed startup does not leave globalThis.fetch patched.

Several apps may share one mock: each app.use(schmockPlugin, { mock }) takes its own lease, and the newest one is consulted first. A manual mock.intercept() stacks the same way, and globalThis.fetch is restored only once the last lease of any kind is released.

Server-side rendering

With no document — SSR, or any server render — the plugin does not patch globalThis.fetch. A server's fetch is shared by every concurrent request, so patching it would leak one render's mock into another's. app.provide still runs, so useSchmock() works during SSR; only fetch interception is skipped. Mock your data layer directly on the server, or intercept at the transport your server actually uses.

Options

app.use(schmockPlugin, {
  mock,
  interceptOptions: {
    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(),
    }),
  },
})

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.

errorFormatter

errorFormatter(error) 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' }].

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.

useSchmock Composable

Access the mock instance from any component via Vue's injection system:

import { useSchmock } from '@schmock/vue'

const mock = useSchmock()
console.log(mock.callCount())

Throws if used outside an app with schmockPlugin installed.

Testing with @vue/test-utils

import { mount, flushPromises } from '@vue/test-utils'
import { schmock } from '@schmock/core'
import { schmockPlugin } from '@schmock/vue'

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

  const wrapper = mount(UserList, {
    global: {
      plugins: [[schmockPlugin, { mock }]],
    },
  })

  await flushPromises()

  expect(wrapper.text()).toContain('Alice')
  expect(mock.called('GET', '/api/users')).toBe(true)

  wrapper.unmount()
})

Test isolation

Create a fresh mock per test to avoid shared state:

describe('UserList', () => {
  let mock: ReturnType<typeof schmock>

  beforeEach(() => {
    mock = schmock()
  })

  it('renders users', async () => {
    mock('GET /api/users', [{ id: 1, name: 'Alice' }])
    const wrapper = mount(UserList, {
      global: { plugins: [[schmockPlugin, { mock }]] },
    })
    await flushPromises()
    expect(wrapper.text()).toContain('Alice')
    wrapper.unmount()
  })

  it('shows empty state', async () => {
    mock('GET /api/users', [])
    const wrapper = mount(UserList, {
      global: { plugins: [[schmockPlugin, { mock }]] },
    })
    await flushPromises()
    expect(wrapper.text()).toContain('No users')
    wrapper.unmount()
  })
})

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]
})

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())