---
title: "What to test in a Vue app (and what to skip) · vuejs.school"
description: "Unit tests for pure logic, component tests for what users see, a few end-to-end runs for the paths that pay. With a coupled test and its rewrite."
canonical_url: "https://vuejs.school/community/blog/what-to-test-in-a-vue-app"
meta:
  "og:description": "Unit tests for pure logic, component tests for what users see, a few end-to-end runs for the paths that pay. With a coupled test and its rewrite."
  "og:image": "https://vuejs.school/_og/d/a_~V2hhdCB0byB0ZXN0IGluIGEgVnVlIGFwcCAoYW5kIHdoYXQgdG8gc2tpcCkgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_What%2Bto%2Btest%2Bin%2Ba%2BVue%2Bapp%2B(and%2Bwhat%2Bto%2Bskip)%2Cdescription_~VW5pdCB0ZXN0cyBmb3IgcHVyZSBsb2dpYywgY29tcG9uZW50IHRlc3RzIGZvciB3aGF0IHVzZXJzIHNlZSwgYSBmZXcgZW5kLXRvLWVuZCBydW5zIGZvciB0aGUgcGF0aHMgdGhhdCBwYXkuIFdpdGggYSBjb3VwbGVkIHRlc3QgYW5kIGl0cyByZXdyaXRlLg%2Ceyebrow_~RGVlcCBkaXZlIMK3IDcgbWluIHJlYWQ%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy93aGF0LXRvLXRlc3QtaW4tYS12dWUtYXBwIg%2Cs_ygHBypxWS4K7bdsr.png"
  "og:image:alt": "What to test in a Vue app (and what to skip) · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "What to test in a Vue app (and what to skip) · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~V2hhdCB0byB0ZXN0IGluIGEgVnVlIGFwcCAoYW5kIHdoYXQgdG8gc2tpcCkgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_What%2Bto%2Btest%2Bin%2Ba%2BVue%2Bapp%2B(and%2Bwhat%2Bto%2Bskip)%2Cdescription_~VW5pdCB0ZXN0cyBmb3IgcHVyZSBsb2dpYywgY29tcG9uZW50IHRlc3RzIGZvciB3aGF0IHVzZXJzIHNlZSwgYSBmZXcgZW5kLXRvLWVuZCBydW5zIGZvciB0aGUgcGF0aHMgdGhhdCBwYXkuIFdpdGggYSBjb3VwbGVkIHRlc3QgYW5kIGl0cyByZXdyaXRlLg%2Ceyebrow_~RGVlcCBkaXZlIMK3IDcgbWluIHJlYWQ%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy93aGF0LXRvLXRlc3QtaW4tYS12dWUtYXBwIg%2Cs_ygHBypxWS4K7bdsr.png"
  "twitter:image:alt": "What to test in a Vue app (and what to skip) · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## 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.ts**Breaks***

```
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.ts**Works***

```
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 stuck**3 questions from the Vitest and Vue Test Utils quiz.

[**Practice now**](https://vuejs.school/learn/testing/vitest-and-vue-test-utils?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Release notes · 6 min read**Vitest 5: what changed for Vue tests**](https://vuejs.school/community/blog/vitest-5-what-changed-for-vue-tests) [Release notes · 6 min read**Vitest 5 release candidate: what's coming for Vue tests**](https://vuejs.school/community/blog/vitest-5-rc-whats-coming)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/what-to-test-in-a-vue-app#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-09-16","description":"Unit tests for pure logic, component tests for what users see, a few end-to-end runs for the paths that pay. With a coupled test and its rewrite.","name":"What to test in a Vue app (and what to skip)","url":"https://vuejs.school/community/blog/what-to-test-in-a-vue-app","breadcrumb":{"@id":"https://vuejs.school/community/blog/what-to-test-in-a-vue-app#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/what-to-test-in-a-vue-app"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/what-to-test-in-a-vue-app#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-09-16","description":"Unit tests for pure logic, component tests for what users see, a few end-to-end runs for the paths that pay. With a coupled test and its rewrite.","headline":"What to test in a Vue app (and what to skip)","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~V2hhdCB0byB0ZXN0IGluIGEgVnVlIGFwcCAoYW5kIHdoYXQgdG8gc2tpcCkgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_What%2Bto%2Btest%2Bin%2Ba%2BVue%2Bapp%2B(and%2Bwhat%2Bto%2Bskip)%2Cdescription_~VW5pdCB0ZXN0cyBmb3IgcHVyZSBsb2dpYywgY29tcG9uZW50IHRlc3RzIGZvciB3aGF0IHVzZXJzIHNlZSwgYSBmZXcgZW5kLXRvLWVuZCBydW5zIGZvciB0aGUgcGF0aHMgdGhhdCBwYXkuIFdpdGggYSBjb3VwbGVkIHRlc3QgYW5kIGl0cyByZXdyaXRlLg%2Ceyebrow_~RGVlcCBkaXZlIMK3IDcgbWluIHJlYWQ%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy93aGF0LXRvLXRlc3QtaW4tYS12dWUtYXBwIg%2Cs_ygHBypxWS4K7bdsr.png","@type":["Article","BlogPosting"],"articleSection":["Deep dive"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/what-to-test-in-a-vue-app#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/what-to-test-in-a-vue-app#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/what-to-test-in-a-vue-app#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"What to test in a Vue app (and what to skip)","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~V2hhdCB0byB0ZXN0IGluIGEgVnVlIGFwcCAoYW5kIHdoYXQgdG8gc2tpcCkgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_What%2Bto%2Btest%2Bin%2Ba%2BVue%2Bapp%2B(and%2Bwhat%2Bto%2Bskip)%2Cdescription_~VW5pdCB0ZXN0cyBmb3IgcHVyZSBsb2dpYywgY29tcG9uZW50IHRlc3RzIGZvciB3aGF0IHVzZXJzIHNlZSwgYSBmZXcgZW5kLXRvLWVuZCBydW5zIGZvciB0aGUgcGF0aHMgdGhhdCBwYXkuIFdpdGggYSBjb3VwbGVkIHRlc3QgYW5kIGl0cyByZXdyaXRlLg%2Ceyebrow_~RGVlcCBkaXZlIMK3IDcgbWluIHJlYWQ%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy93aGF0LXRvLXRlc3QtaW4tYS12dWUtYXBwIg%2Cs_ygHBypxWS4K7bdsr.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~V2hhdCB0byB0ZXN0IGluIGEgVnVlIGFwcCAoYW5kIHdoYXQgdG8gc2tpcCkgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_What%2Bto%2Btest%2Bin%2Ba%2BVue%2Bapp%2B(and%2Bwhat%2Bto%2Bskip)%2Cdescription_~VW5pdCB0ZXN0cyBmb3IgcHVyZSBsb2dpYywgY29tcG9uZW50IHRlc3RzIGZvciB3aGF0IHVzZXJzIHNlZSwgYSBmZXcgZW5kLXRvLWVuZCBydW5zIGZvciB0aGUgcGF0aHMgdGhhdCBwYXkuIFdpdGggYSBjb3VwbGVkIHRlc3QgYW5kIGl0cyByZXdyaXRlLg%2Ceyebrow_~RGVlcCBkaXZlIMK3IDcgbWluIHJlYWQ%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy93aGF0LXRvLXRlc3QtaW4tYS12dWUtYXBwIg%2Cs_ygHBypxWS4K7bdsr.png"}]}
```