Testing
The testing adapter supplies initial query state and records URL changes without a router mock. Codec helpers verify custom codec round trips.
Both live at dedicated subpaths, so they are never pulled into your app bundle:
import { createTestingAdapter, withVuqsTestingAdapter } from '@vuqs/core/adapters/testing'
import { isCodecBijective } from '@vuqs/core/testing'Testing composables
A composable reads query and navigate from the adapter in scope. createTestingAdapter gives you one backed by an in-memory ref: pass the initial query, install it on a throwaway app, and run the composable in that app's injection context.
import { codecs, installQueryAdapter, useQueryState } from '@vuqs/core'
import { createTestingAdapter } from '@vuqs/core/adapters/testing'
import { describe, expect, it } from 'vitest'
import { createApp } from 'vue'
it('reads the initial value', () => {
const adapter = createTestingAdapter({ searchParams: '?count=42' })
const app = createApp({})
installQueryAdapter(app, adapter)
const count = app.runWithContext(() => useQueryState('count', codecs.integer.withDefault(0)))
expect(count.value).toBe(42)
})Asserting on URL writes
Wire onUrlUpdate to a spy to assert what gets written. It fires once per flushed navigation, with the next query and the resolved navigation options:
import { vi } from 'vitest'
it('writes to the URL', async () => {
const onUrlUpdate = vi.fn()
const adapter = createTestingAdapter({ onUrlUpdate })
const app = createApp({})
installQueryAdapter(app, adapter)
const count = app.runWithContext(() => useQueryState('count', codecs.integer.withDefault(0)))
count.set(43, { history: 'push' })
await Promise.resolve() // let the coalesced write flush
expect(onUrlUpdate).toHaveBeenCalledOnce()
const event = onUrlUpdate.mock.calls[0][0]
expect(event.query).toEqual({ count: '43' })
expect(event.options.history).toBe('push') // the options resolved for this write
})Writes are coalesced
Writes within a tick are coalesced into a single navigation. await a microtask (or vi.advanceTimersByTimeAsync when using throttleMs) before asserting.
Adapter memory
By default the adapter is immutable: its query stays frozen at the initial searchParams, so each flushed navigation is independent and a test stays focused on one unit of behavior. The composable still sees its writes optimistically, but adapter.query.value never changes.
Pass hasMemory: true to match a router-backed adapter, where each navigation updates the query so later reads build on it:
const adapter = createTestingAdapter({ searchParams: '?count=42', hasMemory: true })
const app = createApp({})
installQueryAdapter(app, adapter)
const count = app.runWithContext(() => useQueryState('count', codecs.integer.withDefault(0)))
count.value = 43
await Promise.resolve()
expect(adapter.query.value).toEqual({ count: '43' }) // the URL caught upIsolating tests
Each adapter identity owns its update queue. Create a fresh adapter per test and pending writes cannot leak between tests. When a test intentionally reuses an adapter, call resetQueue() to discard its scheduled write:
const adapter = createTestingAdapter()
adapter.resetQueue()Testing components
For a mounted component, withVuqsTestingAdapter returns a Vue plugin you drop into @vue/test-utils' global.plugins:
import { mount } from '@vue/test-utils'
import { withVuqsTestingAdapter } from '@vuqs/core/adapters/testing'
import { vi } from 'vitest'
import CounterButton from './CounterButton.vue'
it('increments the count when clicked', async () => {
const onUrlUpdate = vi.fn()
const wrapper = mount(CounterButton, {
global: {
plugins: [withVuqsTestingAdapter({ searchParams: '?count=42', onUrlUpdate })],
},
})
expect(wrapper.text()).toContain('count is 42')
await wrapper.get('button').trigger('click')
await Promise.resolve()
expect(onUrlUpdate).toHaveBeenCalledOnce()
expect(onUrlUpdate.mock.calls[0][0].query).toEqual({ count: '43' })
})When you also need the adapter reference (to read adapter.query.value), build it with createTestingAdapter and install it yourself instead.
Initial query shapes
searchParams accepts a query string, a URLSearchParams, or a query object. Dot-notation keys nest the same way the core resolves paths, so all of these set up { filters: { sort: 'name' } }:
createTestingAdapter({ searchParams: '?filters.sort=name' })
createTestingAdapter({ searchParams: { 'filters.sort': 'name' } })
createTestingAdapter({ searchParams: { filters: { sort: 'name' } } })This matches what a router adapter delivers, so a composable bound to the filters.sort path reads its initial value in tests exactly as it would in the app. Repeated keys collapse into arrays: '?tags=a&tags=b' reads as { tags: ['a', 'b'] }.
Testing custom codecs
A custom codec must be bijective: parse and serialize round-trip in both directions. @vuqs/core/testing turns that contract into assertions. All three return true on success and throw on failure, with a message that pinpoints which side broke:
import { isCodecBijective, testParseThenSerialize, testSerializeThenParse } from '@vuqs/core/testing'
it('is bijective', () => {
// Both directions plus the exact serialized form, in one call:
expect(isCodecBijective(percent, '42', 42)).toBe(true)
// A non-bijective pair throws:
expect(() => isCodecBijective(percent, '42', 47)).toThrow()
// Or check one side at a time to isolate a failure:
expect(testSerializeThenParse(percent, 42)).toBe(true) // parse(serialize(42)) eq 42
expect(testParseThenSerialize(percent, '42')).toBe(true) // serialize(parse('42')) === '42'
})isCodecBijective(codec, serialized, input) checks everything at once: serialize(input) equals serialized, parse(serialized) equals input (by the codec's eq), and both round-trip directions hold. The codec's eq is used for value comparison, so date and array codecs compare correctly.
Use canonical serialized values
The serialized side must be the codec's canonical output. testParseThenSerialize re-serializes the parsed value and compares, so a non-canonical input like '007' (which an integer codec parses to 7 and re-serializes to '7') is reported as a mismatch by design.