Skip to content
Deep diveTesting

What to test in a Vue app (and what to skip)

A test suite is not measured by its size. It is measured by how often it catches a real mistake and how rarely it breaks for a reason nobody cares about. Here is where each kind of test earns its place.

vuejs.school team16 Sep 20257 min readVitest 3.2 · Playwright 1.55

Every Vue project reaches the moment where someone asks "should this have a test?", and the honest answer is "it depends". Mostly it depends on what kind of code it is. Pure logic, components and whole user flows each suit a different kind of test, and some code needs no test of yours at all. Once you know which is which, the follow-up question, "how many tests is enough?", has a better answer than a coverage number.

The tools are the ones most Vue projects already have: Vitest 3.2 with Vue Test Utils and a simulated DOM (happy-dom or jsdom) for unit and component tests, and Playwright 1.55 for end-to-end tests. The code uses a Nuxt 4 layout, with shared/utils for pure logic and app/components for components, but nothing here is Nuxt-specific.

What unit, component and end-to-end tests each prove

A unit test calls a function and checks what it returns. It runs in milliseconds, needs no DOM and no browser, and when it fails the cause is in that one function. Its weakness is that it proves nothing about the wiring: the function can be perfect and never be called.

A component test mounts one component in a simulated DOM, interacts with it the way a user would and checks what it renders and emits. It takes tens of milliseconds, catches the wiring inside the component, and fails for reasons close to what a user would notice.

An end-to-end test drives a real browser against a running build of the app. It takes seconds, needs the server up, and when it fails the cause can be anywhere, from a CSS change that hides a button to a database that is down. It is also the only one that checks the whole app working together.

Cost rises down that list, and so does the confidence one test gives you. The sensible shape is therefore many unit tests, a reasonable number of component tests, and a handful of end-to-end tests. That shape is often drawn as the testing pyramid: not a rule to obey, just the natural outcome of putting each check where it is cheapest to run and clearest when it fails.

Pure logic gets unit tests

Anything that computes without touching the DOM or a store belongs in a plain module, and plain modules are where unit tests shine. Pricing, date arithmetic, validation, sorting, formatting, the function that decides what comes next: write them as functions in shared/utils and test them with plain calls. A cart total is a typical case:

shared/utils/cart.ts
export type Line = { price: number; quantity: number }

export function cartTotal(lines: Line[], discountCode?: string) {
  const subtotal = lines.reduce((sum, line) => sum + line.price * line.quantity, 0)
  const discount = discountCode === 'STUDENT' ? Math.round(subtotal * 0.1) : 0
  return { subtotal, discount, total: subtotal - discount }
}
tests/unit/cart.test.ts
import { describe, expect, test } from 'vitest'
import { cartTotal } from '../../shared/utils/cart'

describe('cartTotal', () => {
  test('sums price times quantity', () => {
    expect(cartTotal([{ price: 500, quantity: 2 }]).subtotal).toBe(1000)
  })

  test.for([
    { code: 'STUDENT', discount: 100 },
    { code: 'student', discount: 0 },
    { code: undefined, discount: 0 },
  ])('discount for code $code is $discount', ({ code, discount }) => {
    expect(cartTotal([{ price: 500, quantity: 2 }], code).discount).toBe(discount)
  })
})

Prices are in pence, whole numbers, so there is no floating point to argue with. test.for runs one case per object and fills $code and $discount into each test's name, so the report shows three named tests instead of one loop.

The habit this builds is worth more than the tests: when you find yourself wanting to test a computation that lives inside a component, move it out. The component gets simpler and the logic gets a cheap test.

Components get behaviour tests

A component test should read like a short description of what a user does and sees. Mount it, click what they would click, assert what they would read. Take a quantity picker, a number between minus and plus buttons, bound with v-model:

app/components/QuantityPicker.vue
<script setup lang="ts">
const props = defineProps<{ max?: number }>()
const quantity = defineModel<number>({ default: 1 })

function increment() {
  if (props.max !== undefined && quantity.value >= props.max) return
  quantity.value++
}

function decrement() {
  if (quantity.value > 1) quantity.value--
}
</script>

<template>
  <div>
    <button type="button" aria-label="Decrease quantity" :disabled="quantity <= 1" @click="decrement">
      -
    </button>
    <output>{{ quantity }}</output>
    <button
      type="button"
      aria-label="Increase quantity"
      :disabled="max !== undefined && quantity >= max"
      @click="increment"
    >
      +
    </button>
  </div>
</template>

defineModel declares the modelValue prop and the update:modelValue event in one line. When no parent binds v-model, as in a test that mounts the picker on its own, it keeps the value locally and still emits the event.

Three things here are worth checking, because a user or the parent component can observe them:

  • Clicking plus counts up.
  • The count stops at max, and the plus button says so by being disabled.
  • The parent is told of each change through update:modelValue.

Function names, internal state and CSS classes are invisible to both, so they get no test.

Rewrite a test that depends on internals

Here is the test people write first:

tests/unit/QuantityPicker.spec.tsBreaks
import { mount } from '@vue/test-utils'
import { expect, test } from 'vitest'
import QuantityPicker from '../../app/components/QuantityPicker.vue'

test('increment', async () => {
  const wrapper = mount(QuantityPicker, { props: { max: 3 } })
  await (wrapper.vm as any).increment()
  expect((wrapper.vm as any).quantity).toBe(2)
  expect(wrapper.findAll('button')[1].classes()).not.toContain('is-disabled')
})

It passes, and it is wrong in four ways:

  1. It calls increment() directly, so it would pass even if the button were not wired to it.
  2. It reads quantity off the instance, so renaming the ref breaks the test while the component still works.
  3. It picks the second button by index, so reordering the template breaks it.
  4. It checks for a class name the component does not even use, so that line passes for the wrong reason.

A <script setup> component is closed by default: a parent holding a template ref to it sees none of its functions or refs unless the component calls defineExpose. Vue Test Utils lifts that limit for tests, so wrapper.vm reads everything declared in <script setup>. The as any casts were the warning sign: without them, type checking reports that increment and quantity do not exist on the component.

The rewrite talks to the component only through what it renders:

tests/unit/QuantityPicker.spec.tsWorks
import { mount } from '@vue/test-utils'
import { expect, test } from 'vitest'
import QuantityPicker from '../../app/components/QuantityPicker.vue'

test('counts up to the maximum and no further', async () => {
  const wrapper = mount(QuantityPicker, { props: { max: 3 } })
  const plus = wrapper.get('button[aria-label="Increase quantity"]')

  await plus.trigger('click')
  await plus.trigger('click')

  expect(wrapper.get('output').text()).toBe('3')
  expect(plus.attributes('disabled')).toBeDefined()
  expect(wrapper.emitted('update:modelValue')).toEqual([[2], [3]])

  await plus.trigger('click')
  expect(wrapper.emitted('update:modelValue')).toHaveLength(2)
})

Every assertion maps to something a user or the parent can observe:

  • The button is found by its accessible label, the same string a screen reader announces, so the test also fails if someone removes the label.
  • The value is read from the output element, as a user reads it.
  • The limit is checked through the disabled attribute and through the absence of a third emit.

The internals can change freely as long as the behaviour holds.

Prefer wrapper.get to wrapper.find for elements that must exist. get throws at once with the selector and the rendered HTML in the message. find returns an empty wrapper, and the next call on it fails with a vaguer message such as Cannot call trigger on an empty DOMWrapper.

End-to-end tests for the flows that must not break

End-to-end tests are expensive, so spend them on the flows where a failure costs money or trust: sign up, log in, the checkout, saving the thing the app exists to save. Write one test per flow, against roles and visible text:

e2e/checkout.spec.ts
import { expect, test } from '@playwright/test'

test('a visitor can buy one item', async ({ page }) => {
  await page.goto('/products/notebook')
  await page.getByRole('button', { name: 'Add to basket' }).click()
  await page.getByRole('link', { name: 'Basket' }).click()
  await expect(page.getByRole('heading', { name: 'Your basket' })).toBeVisible()
  await page.getByRole('button', { name: 'Pay now' }).click()
  await expect(page.getByText('Thank you for your order')).toBeVisible()
})

getByRole finds elements the way assistive technology does, by role and accessible name. The test survives markup changes that keep the role and the name, and it doubles as a basic accessibility check.

Run these against a production build rather than the dev server, because the build is what ships: nuxt build and nuxt preview (or your generated site) in Nuxt, vite build and vite preview in a Vite app. Keep the suite small enough to run on every pull request; a suite that takes twenty minutes gets skipped, and a skipped test is worth exactly nothing.

What not to test

Do not test Vue. v-if removes elements, v-for renders a list, a prop with a default has that default: these are the framework's tests, and they already pass. A test that mounts a component, passes a prop and asserts that the prop appears in the output is testing interpolation.

Do not test third-party libraries. That Pinia stores state, that VueUse's useDebounceFn waits, that Zod's min(1) rejects an empty string: those libraries have their own suites. Test your use of them, meaning the store action you wrote and the schema you defined, and only through their results.

Do not test implementation details. If an assertion mentions a method name, a ref name, an internal class or wrapper.vm, it is coupled to the structure of the code rather than its behaviour, and it will break on the next refactor while catching nothing. The same goes for snapshot tests of whole pages, which fail on every change and get approved without being read.

Do not test looks in a simulated DOM. Layout, colours, hover states and animation timing belong to a browser: happy-dom and jsdom do no layout and paint nothing. If they matter enough to test, test them end to end, or accept that a human looks at them.

How many tests are enough

Coverage tells you which lines ran during the tests. It does not tell you whether anything was asserted about them, so a coverage target produces tests that execute code without checking it. Treat the report as a map of what you have not looked at, not as a score.

A better measure is behaviour:

  • For a utility module, every branch of its logic has a case, and every bug ever found in it has a test that would have caught it.
  • For a component, every behaviour a user can observe has one test, and every prop that changes behaviour has a case.
  • For the app, every path that would be an incident if it broke has an end-to-end test.

When a bug reaches production, write the test that would have caught it before writing the fix; over a year that one rule shapes the suite better than any number.

And when a test fails and the fix is to change the test rather than the code, ask whether the test was ever telling you anything. The point was never to have tests. It was to be able to change the code on a Friday afternoon.

Check it stuck3 questions from the Vitest and Vue Test Utils quiz.
Practice now
Was this helpful?

Keep reading