Testing Vue 3 components: what deserves a test and what does not
How to test Vue 3 components with Vitest without ending up with a brittle suite: what to cover, what to ignore, and why composables belong in their own tests.
Testing Vue 3 components: what deserves a test and what does not
Nearly every brittle test suite I have inherited shows the same symptom: a CSS change breaks twenty tests. You rename a class, move a div, and suddenly CI is red without a single piece of behaviour having broken.
The cause is always the same. Tests like this one:
// ❌ Anti-pattern
it('renders the button', () => {
const wrapper = mount(ContactForm)
expect(wrapper.find('.btn.btn--primary.form__submit').exists()).toBe(true)
expect(wrapper.vm.isLoading).toBe(false)
})
That test does not check that the form works. It checks that an element carries three specific classes and that an internal variable happens to be called isLoading. Both are decisions you could change tomorrow without breaking anything for a user — and the test will break anyway.
A test that fails while the code still works is not protecting you. It is charging you rent.
The rule that settles everything
Before writing a test, there is only one question worth asking: would a user notice if this broke?
If yes, it deserves a test. If no, you are testing implementation.
| Test this | Do not test this |
|---|---|
| What renders for a given set of props | CSS class names |
| What the component emits on interaction | Internal state (wrapper.vm.loading) |
| Loading, error and empty states | DOM structure |
| Accessible behaviour (roles, labels) | That a method was called |
| Business logic in composables | That Vue works |
That last row is the biggest generator of pointless tests. You do not need to verify that v-if hides an element, or that a computed recomputes. The Vue team tests that.
The minimum setup
Vitest plus happy-dom is enough for components. happy-dom is considerably faster than jsdom and covers what a typical component needs:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'happy-dom',
globals: true,
},
resolve: {
alias: { '~': resolve(__dirname, '.') },
},
})
The alias matters more than it looks. Without it, every ~/utils/something import inside a component fails under test even though it works in the app, and you end up rewriting imports to please the runner. That is letting the test dictate production code.
Test behaviour, not structure
The same form from the opening, tested for what it does:
// tests/ContactForm.test.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import ContactForm from '~/components/ContactForm.vue'
describe('ContactForm', () => {
it('emits the payload when submitted with valid data', async () => {
const wrapper = mount(ContactForm)
await wrapper.find('input[name="name"]').setValue('Ana García')
await wrapper.find('input[name="email"]').setValue('ana@example.com')
await wrapper.find('textarea[name="message"]').setValue('Hello')
await wrapper.find('form').trigger('submit')
expect(wrapper.emitted('submit')).toHaveLength(1)
expect(wrapper.emitted('submit')![0][0]).toEqual({
name: 'Ana García',
email: 'ana@example.com',
message: 'Hello',
})
})
it('does not emit when the email is invalid', async () => {
const wrapper = mount(ContactForm)
await wrapper.find('input[name="email"]').setValue('not-an-email')
await wrapper.find('form').trigger('submit')
expect(wrapper.emitted('submit')).toBeUndefined()
})
})
Look at what gets selected: input[name="email"], form. Attributes that are part of the form's contract, not of its appearance. You can rewrite every line of CSS and redo the layout — these tests keep passing, because what they verify has not changed.
When an element has no natural semantic selector, the escape hatch is data-testid, not a class:
<button data-testid="submit" class="btn btn--primary">Send</button>
await wrapper.find('[data-testid="submit"]').trigger('click')
A data-testid states "a test depends on this" out loud. A CSS class says nothing, and the next person refactoring styles will delete it without knowing anything relied on it.
Composables get their own tests
This is the single change that most reduces how many component tests you need.
When logic lives inside the component, the only way to test it is to mount the component, simulate interactions and inspect the DOM. That is slow, and it gives poor failure messages: when it breaks, you cannot tell whether the calculation or the rendering failed.
Extracted into a composable, the logic is testable directly:
// composables/useContactForm.ts
import { contactSchema } from '~/utils/contactSchema'
export function useContactForm() {
const form = reactive({ name: '', email: '', message: '' })
const errors = ref<Record<string, string>>({})
function validate(): boolean {
const result = contactSchema.safeParse(form)
errors.value = result.success
? {}
: Object.fromEntries(
result.error.issues.map(issue => [issue.path[0], issue.message]),
)
return result.success
}
return { form, errors, validate }
}
// tests/useContactForm.test.ts
import { it, expect } from 'vitest'
import { useContactForm } from '~/composables/useContactForm'
it('collects one error per invalid field', () => {
const { form, errors, validate } = useContactForm()
form.email = 'not-an-email'
expect(validate()).toBe(false)
expect(errors.value.email).toBe('Invalid email address')
expect(errors.value.name).toBe('Missing name')
})
Nothing mounted, no DOM, milliseconds. The component is left with a single test verifying that it paints the errors the composable produces — which is its entire responsibility.
Note: composables that use
onMounted,provideorinjectneed a live component instance. For those, awithSetuphelper — mounting a minimal component that only calls the composable — is more honest than forcing Vue's API to run out of context.
Validation schemas: the best ratio in the project
If you use Zod, schemas are the most profitable thing you can test. They are pure, dependency-free, and each test covers a rule that would otherwise be verified by hand in a browser.
// tests/contactSchema.test.ts
import { it, expect } from 'vitest'
import { contactSchema } from '~/utils/contactSchema'
const VALID = {
name: 'Ana García',
email: 'ana@example.com',
message: 'Hi, I would like to talk about a project.',
}
it('rejects malformed emails', () => {
for (const bad of ['notanemail', 'missing@', '@nodomain.com', 'a @b.com']) {
const result = contactSchema.safeParse({ ...VALID, email: bad })
expect(result.success, `expected "${bad}" to fail`).toBe(false)
}
})
Two details I always use. The VALID object with a spread makes what changes explicit in each case: the test reads as "this is valid except the email". And the second argument to expect is the failure message — inside a loop, without it you read expected true to be false and have no idea which of the four cases broke.
Async, without setTimeout
The usual mistake when testing loading states is waiting on timers:
// ❌ Anti-pattern
await new Promise(resolve => setTimeout(resolve, 100))
expect(wrapper.text()).toContain('Sent')
That is slow when it passes and flaky when CI is under load. flushPromises drains the microtask queue and waits for Vue's update cycle, without guessing at durations:
// tests/ContactForm.test.ts
import { it, expect, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
it('shows a confirmation after a successful submit', async () => {
const send = vi.fn().mockResolvedValue({ ok: true })
const wrapper = mount(ContactForm, { props: { send } })
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain('Sent')
})
Here the form receives the submit function as a send prop, rather than emitting the event and waiting for a parent to resolve it. Injecting it is not a testing trick: it is what makes the component testable without mocking an entire network module. If a component is hard to test, it almost always has a dependency it should receive rather than import.
How much to test
| Scenario | Approach |
|---|---|
| Presentational component | One render test with props; nothing else |
| Form or interaction | Behaviour: emitted events and visible states |
| Business logic | Composable tested on its own, nothing mounted |
| Validation schemas | Cover every rule — cheap and high value |
| Layouts and wrappers | Usually none |
Where component tests end
None of the above covers the real journey. A component test mounts the form in isolation: there is no server, the router does not navigate, and the network is mocked. It verifies that the component reacts correctly to what it receives — not that what it receives is correct.
That is what an E2E test is for, with Playwright or Cypress: open the page, fill the form, submit, and check the confirmation appears with the real API answering.
| What to verify | Where |
|---|---|
| An invalid field shows its error | Component |
| The schema rejects a malformed email | Unit |
| The submission reaches the API and comes back | E2E |
| The page is served and the form works in a browser | E2E |
The ratio matters. E2E tests are slow and break for reasons unrelated to your code — network, data, timing — so keep one or two flows per product: the one that makes money and the one that would embarrass you if it broke. Everything else moves down a level.
And the other way around: having E2E coverage is what lets component tests stay few. If the full flow is already covered above, mounting the same form five times to walk through validation variants is duplicated work — those variants are a three-line schema test.
How to build those flows without turning them into your CI bottleneck deserves its own article. It is the next one on my list.
Conclusion
What I follow on every project:
- A test that fails while nothing is broken is debt, not coverage. Delete it.
- Select by role, attribute or
data-testid. Never by CSS class. - Move logic into composables and test it there. Component tests shrink on their own.
- Validation schemas are the best effort-to-value ratio in any suite.
flushPromises, notsetTimeout.- If something is hard to test, the design is the problem, not the test.
Coverage is not the goal. A 90% figure made of tests asserting class names is worse than 40% covering what the user touches — because the first one also slows you down every time you refactor.
Questions, or want to see a specific case? Write to me at hola@miguel-jimenez.dev.