A Suite You Keep
Test behaviour rather than internals, skip what does not need testing, keep it fast, and let CI block the merge.
npm run test:unit -- --run --coverageA suite earns its place by catching real breakage and staying quiet otherwise. Most abandoned suites were not wrong; they were slow, or they failed on every harmless refactor until people stopped reading them.
Behaviour, not internals
Assert what the component shows and what it emits. Those are its contract with the rest of the app. Do not assert that a particular ref holds a particular value, that a computed was recalculated, or that a private function ran. Rename the ref and a good test still passes; an internals test fails and tells you nothing.
What not to test
Not the framework: Vue's reactivity, v-if and v-for are already tested. Not your component library's internals. Not CSS, beyond a class or attribute that carries meaning. Snapshots of whole components are tempting and become unreadable diffs that everyone approves without looking.
Speed is a feature
A suite that finishes in seconds gets run before every commit. Keep unit tests off the network and off real timers, mount the component rather than the whole app, and let a smaller number of end-to-end tests cover the journeys instead.
Make it block the merge
A test only protects you if a red result stops the code. One CI job on every pull request, running type checks and the unit suite, is enough. Vitest runs once rather than watching when it detects CI, and --coverage reports what the run touched, as long as @vitest/coverage-v8 is installed.
Example
// src/components/__tests__/InviteForm.spec.ts
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import InviteForm from '../InviteForm.vue'
describe('InviteForm', () => {
it('emits the address once the form is valid', async () => {
const wrapper = mount(InviteForm)
await wrapper.get('input[type="email"]').setValue('[email protected]')
await wrapper.get('form').trigger('submit')
expect(wrapper.emitted('invite')).toEqual([['[email protected]']])
})
it('tells the user what is wrong instead of submitting', async () => {
const wrapper = mount(InviteForm)
await wrapper.get('input[type="email"]').setValue('not-an-address')
await wrapper.get('form').trigger('submit')
expect(wrapper.emitted('invite')).toBeUndefined()
expect(wrapper.get('[role="alert"]').text()).toContain('valid email')
})
it('clears the message after a corrected submit', async () => {
const wrapper = mount(InviteForm)
const field = wrapper.get('input[type="email"]')
await field.setValue('nope')
await wrapper.get('form').trigger('submit')
await field.setValue('[email protected]')
await wrapper.get('form').trigger('submit')
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
})
})When to use it
- Decide which of a component's details are worth a test and which are noise.
- Cut a suite that takes four minutes down to one that takes twenty seconds.
- Stop a pull request that breaks a form from being merged.
- Replace a brittle full-component snapshot with assertions that survive a refactor.
More examples
The same test, written badly
Both of these pass today and both will waste someone's afternoon later. The first couples the test to a variable name and to the exact wording of a message, neither of which is a contract. The second turns every layout tweak into a failing test. Compare them with the primary example, which asserts the two things a caller depends on: what gets emitted and what the user is told. Keep snapshots for small, stable output such as a formatted date or a generated class string.
// src/components/__tests__/InviteForm.brittle.spec.ts
import { expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import InviteForm from '../InviteForm.vue'
it('sets the error ref', async () => {
const wrapper = mount(InviteForm)
await wrapper.get('input').setValue('nope')
await wrapper.get('form').trigger('submit')
// Reaches inside the component. Renaming `error` to `message` breaks this
// test while the component still works perfectly.
expect((wrapper.vm as unknown as { error: string }).error).toBe('Enter a valid email')
})
it('matches the snapshot', () => {
const wrapper = mount(InviteForm)
// Fails on every markup change, including adding a wrapper div for layout.
// The diff is long, so it gets accepted without being read.
expect(wrapper.html()).toMatchSnapshot()
})The CI job
One job, four commands, run on every pull request. npm ci installs exactly what the lockfile says, so the run is reproducible. type-check is a separate step because Vitest strips types rather than checking them, so a type error can hide behind a green test run. --run is passed explicitly even though Vitest detects CI, because it makes the command readable to whoever debugs the log. Protect the branch so this job has to pass before a merge, or the suite is only advice.
# .github/workflows/ci.yml
name: ci
on:
pull_request:
push:
branches: [main]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run type-check
- run: npm run test:unit -- --run
- run: npm run buildScripts worth having
Two scripts for two audiences. test:unit watches, so it is what you leave running while you work. test:unit:ci runs once and reports coverage, which needs the @vitest/coverage-v8 package listed alongside Vitest and matching its major version. Treat coverage as a map of untested areas rather than a target: chasing a percentage produces tests written to touch lines instead of tests that would catch a bug.
{
"scripts": {
"test:unit": "vitest",
"test:unit:ci": "vitest run --coverage",
"type-check": "vue-tsc --build"
},
"devDependencies": {
"@vitest/coverage-v8": "^4.1.11",
"@vue/test-utils": "^2.5.0",
"jsdom": "^30.0.1",
"vitest": "^4.1.11"
}
}
Discussion