skip to content

In Vue Router 5, why do unit tests and SSR server entries use createMemoryHistory, and what must happen before the first render?

level: middleimportance: should knowfreq 33%

answer

  1. no window, or a shared one
  2. starts at a location that is nowhere
  3. install navigates only when a document exists
  4. push, then await isReady
  5. a fresh router per test or request

basics

~10 s

createMemoryHistory keeps locations in memory, so the server needs no window and tests share no URL; push the starting route and await router.isReady() before rendering, because on the server nothing starts the first navigation.

solid answer

~40 s

`createMemoryHistory()` stores entries in its own array and never touches `window.location`. On the server there is no `window`, and in a jsdom test web history would read and write the one URL all tests in that environment share. It starts at an empty location that points nowhere. The router's `install` only starts the first navigation when a `document` exists, so a server entry must call `router.push(url)` and then `await router.isReady()` before rendering; skip the push and `isReady()` never settles. Under jsdom, `app.use(router)` does navigate, but to `/`, the resolved empty start location, so a test for `/users/7` should `router.push('/users/7')`, `await router.isReady()`, then mount. Each test or request should build its own router from a shared `routes` array.

code

ts · 14 lines
ts
import { test, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import { routes } from '@/router/routes'
import App from '@/App.vue'

test('opens a user from a deep link', async () => {
  const router = createRouter({ history: createMemoryHistory(), routes })
  router.push('/users/7')
  await router.isReady()

  const wrapper = mount(App, { global: { plugins: [router] } })
  expect(wrapper.text()).toContain('User 7')
})

go deeper

for a junior

Remember that tests and SSR use createMemoryHistory() and that you push a route and await isReady() before rendering.

for a middle

Explain why install navigates only when a document exists, why jsdom ends up on /, and why isReady() hangs on the server without a push.

for a senior

Build test and SSR setups that isolate router state per test and per request, and push app-relative URLs when the app sits under a prefix.

for a principal

Standardise one router factory shared by the app, the tests and the server entry, so all three exercise the same route table and guards.

## What memory history is `createMemoryHistory(base?)` is the one Vue Router 5 history that does not use the browser. It keeps a **queue of entries** and a position in an array of its own: - `push` and `replace` add or swap entries in that array. - `router.back()` and `router.go(n)` move the position within it and notify the router. - It never reads or writes `window.location`, and the browser's buttons know nothing about it. - It starts at a special **empty location** that the source describes as nowhere; the first real route has to come from a navigation. Those properties are exactly what two environments need: a **server** that has no `window`, and **tests** that should not depend on one. ## Who starts the first navigation The router's `install` hook, run by `app.use(router)`, pushes the history's current location as the initial navigation, **but only when a `document` exists** and nothing has navigated yet. That single condition explains both environments: | Environment | `document`? | First navigation | What you do | |---|---|---|---| | SSR in Node | no | never started by the router | `router.push(requestUrl)`, then `await router.isReady()` | | Test under jsdom | yes | started by `app.use()`, to `/` | push the route the test needs, then `await router.isReady()` | | Test with the Node environment | no | never started | same as SSR | Under jsdom the start location is the empty string, and resolving an empty location against the start route gives `/`. So a test that never pushes renders the `/` route, which is rarely the one under test. ## The server entry For each request: 1. Create a new app and a new router with `createMemoryHistory()`, from a shared `routes` array. 2. `app.use(router)`. 3. `router.push(url)` with the request's path. If the app lives under a prefix such as `/admin/`, the pushed location must be app-relative, just like a `RouterLink` target. 4. `await router.isReady()`. Without step 3 the promise never settles and the request hangs. 5. Render, and inspect `router.currentRoute.value` if you want to answer 404 for the catch-all route. A new router per request matters because a router holds the current route; sharing one across concurrent requests would mix their locations. The render functions themselves belong to Vue's SSR API. ## The unit test - Export the **route records** separately from the app's router instance, so each test can build its own router and no test inherits another's current route. - Push the location under test **before** mounting and await `isReady()`. Because a navigation has already happened, the install hook skips its own push to `/`. - If you must navigate after mounting, `await router.push(...)`, then let the DOM update before asserting. - Guards, redirects and lazy route components run exactly as in the browser, so a test exercises the real route table. ## The base in memory history `createMemoryHistory()` takes a base like the other factories, but it only affects the `href` values the router generates, for example in `RouterLink` or `router.resolve()`. The locations you push stay app-relative. A test that asserts a link renders `/admin/users/7` therefore needs `createMemoryHistory('/admin/')`; a test that only checks which view renders can leave the base out. ## Common mistakes - Using `createWebHistory()` in tests: it works under jsdom but reads and writes the shared `window.location`, so the URL one test leaves behind becomes the next test's start location. - Mounting without `await router.isReady()`: the first assertion runs against an empty `RouterView` or against `/`. - Expecting the router to navigate on the server by itself: the hook is browser-only, so nothing happens until you push. - Pushing a URL that still contains the deployment prefix into a router whose records are app-relative: it matches nothing, or only the catch-all. - Reaching for memory history in production to avoid configuring a server: it disconnects the address bar and the back button. ## A note on the docs The history-mode guide says memory mode does not trigger the initial navigation automatically. That holds in Node; in a DOM environment such as jsdom, the install hook does push the empty start location, which becomes `/`. The source is what your tests run.

  • Can a test drive the back button with memory history?
    Yes, at the router level. `router.back()` and `router.go(-1)` move the position inside memory history's own queue and notify the router, which runs a normal navigation with guards. They return nothing to await, so wait for the resulting navigation and the DOM update before asserting. The browser's real buttons are not involved.
  • Why does the server entry build a new router for every request instead of reusing one?
    A router holds mutable state: the current route, the pending navigation and the history queue. Two concurrent requests sharing one router would overwrite each other's location, and one could render the other's page. The route records can be shared; the router instance cannot.

saying these in an interview costs you the question

  • app.use(router) starts the first navigation on the server as it does in the browser.
  • Memory history reads jsdom's window.location at install time.
  • createWebHistory() throws under jsdom, so tests are forced onto memory history.
  • Memory history skips navigation guards, which is why tests run faster.
  • One router instance can safely serve every SSR request.