← Back to DevBytes

Testing Vitest Components: From Unit to E2E Tests

Introduction to Testing with Vitest

Vitest is a blazing-fast, modern testing framework built specifically for Vite-powered projects. It offers a Jest-compatible API, native ES module support, and out-of-the-box TypeScript handling. Whether you are building a small component library or a large-scale application, Vitest provides the tooling you need to validate your UI components from isolated unit tests all the way to end-to-end (E2E) scenarios.

In this tutorial, we will walk through the full testing spectrum: unit tests, component tests, integration tests, and E2E tests. By the end, you will have a practical, reusable testing strategy for your Vitest-based projects.

Why a Multi-Layer Testing Strategy Matters

Each layer of testing serves a distinct purpose. Relying on only one type leaves blind spots in your application. A balanced strategy ensures confidence without sacrificing speed.

This layered approach, often called the testing pyramid, gives you fast feedback on small pieces while ensuring the whole system behaves as expected.

Setting Up Vitest in Your Project

If you already use Vite, adding Vitest is straightforward. Install it as a development dependency along with the testing utilities you will need.

npm install -D vitest @vue/test-utils @testing-library/vue jsdom

Next, configure Vitest in your vite.config.ts file. The test configuration lives inside the test property.

import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  test: {
    environment: 'jsdom',
    globals: true,
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html'],
    },
  },
})

The jsdom environment simulates a browser DOM so your components can mount and interact with the document. Setting globals: true lets you use describe, it, and expect without importing them in every file.

Add a script to your package.json to run tests:

{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage"
  }
}

Writing Unit Tests

Unit tests focus on pure logic. Let us start with a simple utility function and test it thoroughly.

Create a file src/utils/format.ts:

export function formatCurrency(amount: number, currency = 'USD'): string {
  if (typeof amount !== 'number' || isNaN(amount)) {
    throw new Error('Amount must be a valid number')
  }
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency,
  }).format(amount)
}

export function pluralize(count: number, singular: string, plural?: string): string {
  const word = count === 1 ? singular : (plural ?? `${singular}s`)
  return `${count} ${word}`
}

Now write the corresponding test in src/utils/format.test.ts:

import { describe, it, expect } from 'vitest'
import { formatCurrency, pluralize } from './format'

describe('formatCurrency', () => {
  it('formats a positive number as USD by default', () => {
    expect(formatCurrency(1234.5)).toBe('$1,234.50')
  })

  it('supports different currencies', () => {
    expect(formatCurrency(100, 'EUR')).toBe('€100.00')
  })

  it('throws an error for invalid input', () => {
    expect(() => formatCurrency(NaN)).toThrow('Amount must be a valid number')
    expect(() => formatCurrency('100' as unknown as number)).toThrow()
  })
})

describe('pluralize', () => {
  it('returns singular form for count of 1', () => {
    expect(pluralize(1, 'item')).toBe('1 item')
  })

  it('returns plural form by appending s', () => {
    expect(pluralize(3, 'item')).toBe('3 items')
  })

  it('uses custom plural when provided', () => {
    expect(pluralize(2, 'child', 'children')).toBe('2 children')
  })
})

Run the tests with npm run test:run. Vitest executes them almost instantly, giving you immediate feedback.

Testing Vue Components

Component tests verify rendering and user interaction. We will use @vue/test-utils for mounting components and @testing-library/vue for user-centric queries. Let us build a simple counter component.

Create src/components/Counter.vue:

<template>
  <div class="counter">
    <p data-testid="count">Count: {{ count }}</p>
    <button data-testid="increment" @click="increment">+</button>
    <button data-testid="decrement" @click="decrement" :disabled="count === 0">-</button>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const count = ref(0)

function increment() {
  count.value++
}

function decrement() {
  if (count.value > 0) {
    count.value--
  }
}
</script>

Now write the component test in src/components/Counter.test.ts:

import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'

describe('Counter', () => {
  it('renders initial count of 0', () => {
    const wrapper = mount(Counter)
    expect(wrapper.find('[data-testid="count"]').text()).toBe('Count: 0')
  })

  it('increments the count when the plus button is clicked', async () => {
    const wrapper = mount(Counter)
    await wrapper.find('[data-testid="increment"]').trigger('click')
    expect(wrapper.find('[data-testid="count"]').text()).toBe('Count: 1')
  })

  it('decrements the count when the minus button is clicked', async () => {
    const wrapper = mount(Counter)
    await wrapper.find('[data-testid="increment"]').trigger('click')
    await wrapper.find('[data-testid="increment"]').trigger('click')
    await wrapper.find('[data-testid="decrement"]').trigger('click')
    expect(wrapper.find('[data-testid="count"]').text()).toBe('Count: 1')
  })

  it('disables the decrement button when count is 0', () => {
    const wrapper = mount(Counter)
    const decrementBtn = wrapper.find('[data-testid="decrement"]')
    expect(decrementBtn.attributes('disabled')).toBeDefined()
  })
})

Using Testing Library for User-Centric Tests

Testing Library encourages testing components the way a user interacts with them. This leads to more resilient tests that do not break on implementation changes.

import { describe, it, expect } from 'vitest'
import { render, fireEvent } from '@testing-library/vue'
import Counter from './Counter.vue'

describe('Counter with Testing Library', () => {
  it('allows a user to increment and see the updated value', async () => {
    const { getByTestId } = render(Counter)

    expect(getByTestId('count').textContent).toBe('Count: 0')

    await fireEvent.click(getByTestId('increment'))
    await fireEvent.click(getByTestId('increment'))

    expect(getByTestId('count').textContent).toBe('Count: 2')
  })
})

Mocking Dependencies and APIs

Components often depend on external APIs or composables. Vitest provides powerful mocking utilities. Let us test a component that fetches data.

Create src/composables/useUser.ts:

import { ref, onMounted } from 'vue'

export interface User {
  id: number
  name: string
  email: string
}

export function useUser(userId: number) {
  const user = ref<User | null>(null)
  const loading = ref(true)
  const error = ref<string | null>(null)

  onMounted(async () => {
    try {
      const response = await fetch(`/api/users/${userId}`)
      if (!response.ok) throw new Error('Failed to fetch user')
      user.value = await response.json()
    } catch (e) {
      error.value = (e as Error).message
    } finally {
      loading.value = false
    }
  })

  return { user, loading, error }
}

Create src/components/UserProfile.vue:

<template>
  <div class="user-profile">
    <div v-if="loading" data-testid="loading">Loading...</div>
    <div v-else-if="error" data-testid="error">{{ error }}</div>
    <div v-else-if="user" data-testid="user-info">
      <h3>{{ user.name }}</h3>
      <p>{{ user.email }}</p>
    </div>
  </div>
</template>

<script setup lang="ts">
import { useUser } from '../composables/useUser'

const props = defineProps<{ userId: number }>()
const { user, loading, error } = useUser(props.userId)
</script>

Now test it with a mocked fetch:

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, waitFor } from '@testing-library/vue'
import UserProfile from './UserProfile.vue'

describe('UserProfile', () => {
  beforeEach(() => {
    vi.restoreAllMocks()
  })

  it('displays user info after successful fetch', async () => {
    const mockUser = { id: 1, name: 'Jane Doe', email: 'jane@example.com' }

    vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
      ok: true,
      json: () => Promise.resolve(mockUser),
    }))

    const { getByTestId } = render(UserProfile, {
      props: { userId: 1 },
    })

    await waitFor(() => {
      expect(getByTestId('user-info')).toBeTruthy()
    })

    expect(getByTestId('user-info').textContent).toContain('Jane Doe')
    expect(getByTestId('user-info').textContent).toContain('jane@example.com')
  })

  it('displays an error message when the fetch fails', async () => {
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
      ok: false,
      status: 404,
    }))

    const { getByTestId } = render(UserProfile, {
      props: { userId: 999 },
    })

    await waitFor(() => {
      expect(getByTestId('error')).toBeTruthy()
    })

    expect(getByTestId('error').textContent).toBe('Failed to fetch user')
  })
})

Integration Testing Multiple Components

Integration tests verify that components work together correctly. Let us build a small shopping cart feature and test the interaction between its parts.

Create src/components/ProductList.vue:

<template>
  <ul data-testid="product-list">
    <li v-for="product in products" :key="product.id" data-testid="product-item">
      {{ product.name }} - ${{ product.price }}
      <button data-testid="add-to-cart" @click="$emit('add', product)">Add</button>
    </li>
  </ul>
</template>

<script setup lang="ts">
defineProps<{ products: Array<{ id: number; name: string; price: number }> }>()
defineEmits<{ add: [product: { id: number; name: string; price: number }] }>()
</script>

Create src/components/ShoppingCart.vue:

<template>
  <div data-testid="cart">
    <h3>Cart ({{ totalItems }})</h3>
    <ul>
      <li v-for="item in items" :key="item.id" data-testid="cart-item">
        {{ item.name }} x{{ item.quantity }} = ${{ item.price * item.quantity }}
      </li>
    </ul>
    <p data-testid="cart-total">Total: ${{ total }}</p>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue'

const props = defineProps<{
  items: Array<{ id: number; name: string; price: number; quantity: number }>
}>()

const totalItems = computed(() =>
  props.items.reduce((sum, item) => sum + item.quantity, 0)
)

const total = computed(() =>
  props.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
</script>

Create a parent Shop.vue that wires them together:

<template>
  <div class="shop">
    <ProductList :products="products" @add="addToCart" />
    <ShoppingCart :items="cartItems" />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import ProductList from './ProductList.vue'
import ShoppingCart from './ShoppingCart.vue'

interface Product {
  id: number
  name: string
  price: number
}

const products: Product[] = [
  { id: 1, name: 'Widget', price: 10 },
  { id: 2, name: 'Gadget', price: 25 },
  { id: 3, name: 'Gizmo', price: 15 },
]

const cartItems = ref<Array<Product & { quantity: number }>>([])

function addToCart(product: Product) {
  const existing = cartItems.value.find((item) => item.id === product.id)
  if (existing) {
    existing.quantity++
  } else {
    cartItems.value.push({ ...product, quantity: 1 })
  }
}
</script>

Now write the integration test:

import { describe, it, expect } from 'vitest'
import { render, fireEvent } from '@testing-library/vue'
import Shop from './Shop.vue'

describe('Shop integration', () => {
  it('adds products to the cart and updates the total', async () => {
    const { getAllByTestId, getByTestId } = render(Shop)

    const addButtons = getAllByTestId('add-to-cart')
    expect(addButtons).toHaveLength(3)

    await fireEvent.click(addButtons[0])
    await fireEvent.click(addButtons[0])
    await fireEvent.click(addButtons[1])

    const cartItems = getAllByTestId('cart-item')
    expect(cartItems).toHaveLength(2)

    expect(getByTestId('cart-total').textContent).toBe('Total: $45')
  })
})

This test verifies that clicking "Add" in the product list correctly updates the cart display and total. It exercises the real interaction between three components without mocking their internal communication.

End-to-End Testing with Playwright

While Vitest handles unit, component, and integration tests, E2E tests require a real browser. Playwright is the most popular choice for Vite projects and pairs naturally with Vitest-based applications.

Install Playwright:

npm install -D @playwright/test
npx playwright install

Create playwright.config.ts:

import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  use: {
    baseURL: 'http://localhost:5173',
    trace: 'on-first-retry',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:5173',
    reuseExistingServer: !process.env.CI,
  },
})

Write an E2E test in e2e/shop.spec.ts:

import { test, expect } from '@playwright/test'

test('user can browse products and add them to cart', async ({ page }) => {
  await page.goto('/')

  await expect(page.getByTestId('product-list')).toBeVisible()
  await expect(page.getByTestId('cart-total')).toHaveText('Total: $0')

  const addButtons = page.getByTestId('add-to-cart')
  await addButtons.first().click()
  await addButtons.first().click()
  await addButtons.nth(1).click()

  await expect(page.getByTestId('cart-total')).toHaveText('Total: $45')
  await expect(page.getByTestId('cart')).toContainText('Widget x2')
  await expect(page.getByTestId('cart')).toContainText('Gadget x1')
})

test('cart persists across page navigation', async ({ page }) => {
  await page.goto('/')
  await page.getByTestId('add-to-cart').first().click()
  await expect(page.getByTestId('cart-total')).toHaveText('Total: $10')

  await page.reload()
  // If state is persisted via localStorage or a store, verify it here
  await expect(page.getByTestId('cart-total')).toHaveText('Total: $10')
})

Add an E2E script to package.json:

{
  "scripts": {
    "test:e2e": "playwright test",
    "test:e2e:ui": "playwright test --ui"
  }
}

Best Practices for Vitest Component Testing

Prefer Behavior Over Implementation Details

Test what the user sees and does, not internal state or private methods. Use data-testid attributes or accessible queries (like getByRole or getByText) instead of querying by CSS class or component instance properties. This keeps your tests resilient to refactoring.

Keep Tests Isolated and Independent

Each test should set up its own state and not depend on another test running first. Use beforeEach and afterEach hooks to reset mocks, clear the DOM, and restore global state.

import { beforeEach, afterEach, vi } from 'vitest'

beforeEach(() => {
  vi.clearAllMocks()
})

afterEach(() => {
  vi.restoreAllMocks()
})

Use Snapshot Testing Sparingly

Snapshots can catch unexpected changes, but they are easy to update blindly. Use them for stable output like serialized configuration or rendered markup of presentational components. For interactive components, prefer explicit assertions.

import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Badge from './Badge.vue'

describe('Badge', () => {
  it('matches snapshot', () => {
    const wrapper = mount(Badge, { props: { label: 'New' } })
    expect(wrapper.html()).toMatchSnapshot()
  })
})

Leverage Vitest's Watch Mode During Development

Vitest's watch mode is extremely fast because it only re-runs tests affected by your changes. Keep it running in a terminal while you develop to get instant feedback.

npm run test

Coverage as a Guide, Not a Goal

Use coverage reports to identify untested code paths, but do not chase 100% coverage at the expense of meaningful tests. A well-tested critical path is more valuable than exhaustive coverage of trivial getters.

Organize Tests Alongside Source Files

Keep test files next to the code they test, using the .test.ts or .spec.ts suffix. This makes it easy to find and maintain tests as your project grows.

Conclusion

Testing with Vitest gives you a fast, modern, and flexible foundation for validating your components at every level. Unit tests catch logic errors early, component tests ensure your UI renders and behaves correctly, integration tests verify that pieces work together, and E2E tests with Playwright confirm the entire application delivers the experience your users expect. By combining these layers with thoughtful mocking, user-centric queries, and disciplined test isolation, you build a safety net that catches regressions without slowing down development. Start with unit tests for your core logic, add component tests for interactive UI, and introduce E2E tests for your most critical user flows. Over time, this layered strategy will give you the confidence to ship features quickly and refactor fearlessly.

— Ad —

Google AdSense will appear here after approval

← Back to all articles