← Volver al blog

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 thisDo not test this
What renders for a given set of propsCSS class names
What the component emits on interactionInternal state (wrapper.vm.loading)
Loading, error and empty statesDOM structure
Accessible behaviour (roles, labels)That a method was called
Business logic in composablesThat 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, provide or inject need a live component instance. For those, a withSetup helper — 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

ScenarioApproach
Presentational componentOne render test with props; nothing else
Form or interactionBehaviour: emitted events and visible states
Business logicComposable tested on its own, nothing mounted
Validation schemasCover every rule — cheap and high value
Layouts and wrappersUsually 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 verifyWhere
An invalid field shows its errorComponent
The schema rejects a malformed emailUnit
The submission reaches the API and comes backE2E
The page is served and the form works in a browserE2E

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:

  1. A test that fails while nothing is broken is debt, not coverage. Delete it.
  2. Select by role, attribute or data-testid. Never by CSS class.
  3. Move logic into composables and test it there. Component tests shrink on their own.
  4. Validation schemas are the best effort-to-value ratio in any suite.
  5. flushPromises, not setTimeout.
  6. 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.