← Back to DevBytes

Testing Vite Components: From Unit to E2E Tests

Testing Vite Components: From Unit to E2E Tests

Vite has rapidly become one of the most popular build tools for modern web development, thanks to its lightning-fast dev server and optimized build process. But building components is only half the battle — ensuring they work correctly across the entire application lifecycle is what separates prototypes from production-ready software. In this tutorial, we'll walk through a complete testing strategy for Vite components, covering unit tests, integration tests, and end-to-end (E2E) tests.

Why Testing Vite Components Matters

Testing is not just about catching bugs; it's about building confidence. When you write tests for your Vite components, you gain several key benefits:

Vite's architecture makes testing particularly pleasant because of its native ES module support, fast transformations, and plugin ecosystem. Let's explore how to leverage these advantages across the testing pyramid.

Setting Up the Testing Environment

Before diving into tests, we need to configure our Vite project with the right testing tools. We'll use Vitest for unit and integration testing because it's built specifically for Vite and shares the same configuration and transformation pipeline.

Installing Dependencies

Start by installing Vitest and its companion libraries. For Vue components, we'll also install Vue Test Utils. For React, we'd use Testing Library. Let's focus on a Vue example, but the concepts apply broadly.

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

Next, update your vite.config.js or vite.config.ts file to include the test configuration:

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

export default defineConfig({
  plugins: [vue()],
  test: {
    globals: true,
    environment: 'jsdom',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: ['node_modules/', 'src/**/*.d.ts']
    }
  }
})

The globals: true option allows you to use describe, it, and expect without importing them. The environment: 'jsdom' setting simulates a browser environment so your components can render properly.

Add a test script to your package.json:

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

Unit Testing Components

Unit tests focus on testing individual components in isolation. They should be fast, focused, and independent of external dependencies. Let's create a simple component and write comprehensive unit tests for it.

Creating a Sample Component

Here's a counter component that we'll use throughout our unit testing examples:

<!-- src/components/Counter.vue -->
<template>
  <div class="counter">
    <h2>{{ title }}</h2>
    <p class="count">Current count: {{ count }}</p>
    <button class="increment" @click="increment">Increment</button>
    <button class="decrement" @click="decrement" :disabled="count <= 0">
      Decrement
    </button>
    <p v-if="count >= 10" class="warning">Maximum reached!</p>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const props = defineProps({
  title: {
    type: String,
    default: 'Counter'
  },
  initialCount: {
    type: Number,
    default: 0
  }
})

const emit = defineEmits(['change'])

const count = ref(props.initialCount)

const increment = () => {
  if (count.value < 10) {
    count.value++
    emit('change', count.value)
  }
}

const decrement = () => {
  if (count.value > 0) {
    count.value--
    emit('change', count.value)
  }
}
</script>

Writing the Unit Tests

Now let's write thorough unit tests for this component:

// tests/unit/Counter.spec.js
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from '@/components/Counter.vue'

describe('Counter.vue', () => {
  // Test default rendering
  it('renders with default props', () => {
    const wrapper = mount(Counter)
    expect(wrapper.find('h2').text()).toBe('Counter')
    expect(wrapper.find('.count').text()).toBe('Current count: 0')
  })

  // Test custom props
  it('renders with custom title and initial count', () => {
    const wrapper = mount(Counter, {
      props: {
        title: 'My Counter',
        initialCount: 5
      }
    })
    expect(wrapper.find('h2').text()).toBe('My Counter')
    expect(wrapper.find('.count').text()).toBe('Current count: 5')
  })

  // Test increment functionality
  it('increments count when button is clicked', async () => {
    const wrapper = mount(Counter)
    await wrapper.find('.increment').trigger('click')
    expect(wrapper.find('.count').text()).toBe('Current count: 1')
  })

  // Test decrement functionality
  it('decrements count when button is clicked', async () => {
    const wrapper = mount(Counter, {
      props: { initialCount: 5 }
    })
    await wrapper.find('.decrement').trigger('click')
    expect(wrapper.find('.count').text()).toBe('Current count: 4')
  })

  // Test decrement is disabled at zero
  it('disables decrement button when count is zero', () => {
    const wrapper = mount(Counter)
    expect(wrapper.find('.decrement').attributes('disabled')).toBeDefined()
  })

  // Test maximum limit
  it('shows warning when count reaches 10', async () => {
    const wrapper = mount(Counter, {
      props: { initialCount: 9 }
    })
    await wrapper.find('.increment').trigger('click')
    expect(wrapper.find('.warning').exists()).toBe(true)
    expect(wrapper.find('.warning').text()).toBe('Maximum reached!')
  })

  // Test emit event
  it('emits change event with new value on increment', async () => {
    const wrapper = mount(Counter, {
      props: { initialCount: 3 }
    })
    await wrapper.find('.increment').trigger('click')
    expect(wrapper.emitted('change')).toBeTruthy()
    expect(wrapper.emitted('change')[0]).toEqual([4])
  })

  // Test that increment stops at 10
  it('does not increment beyond 10', async () => {
    const wrapper = mount(Counter, {
      props: { initialCount: 10 }
    })
    await wrapper.find('.increment').trigger('click')
    expect(wrapper.find('.count').text()).toBe('Current count: 10')
  })
})

Testing Component Methods and Computed Properties

For more complex components, you may need to test methods, computed properties, and watchers directly. Here's an example with a component that has computed logic:

<!-- src/components/TodoList.vue -->
<template>
  <div class="todo-list">
    <input v-model="newTodo" @keyup.enter="addTodo" placeholder="Add todo" />
    <ul>
      <li v-for="todo in filteredTodos" :key="todo.id" :class="{ done: todo.done }">
        <input type="checkbox" v-model="todo.done" />
        {{ todo.text }}
      </li>
    </ul>
    <button @click="filter = 'active'">Active</button>
    <button @click="filter = 'completed'">Completed</button>
    <button @click="filter = 'all'">All</button>
    <p>{{ remainingCount }} items remaining</p>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'

const todos = ref([])
const newTodo = ref('')
const filter = ref('all')
let nextId = 1

const filteredTodos = computed(() => {
  if (filter.value === 'active') return todos.value.filter(t => !t.done)
  if (filter.value === 'completed') return todos.value.filter(t => t.done)
  return todos.value
})

const remainingCount = computed(() => todos.value.filter(t => !t.done).length)

const addTodo = () => {
  if (newTodo.value.trim()) {
    todos.value.push({ id: nextId++, text: newTodo.value.trim(), done: false })
    newTodo.value = ''
  }
}
</script>
// tests/unit/TodoList.spec.js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import TodoList from '@/components/TodoList.vue'

describe('TodoList.vue', () => {
  it('adds a new todo on enter', async () => {
    const wrapper = mount(TodoList)
    const input = wrapper.find('input[type="text"], input:not([type])')
    await input.setValue('Buy groceries')
    await input.trigger('keyup.enter')
    expect(wrapper.findAll('li')).toHaveLength(1)
    expect(wrapper.findAll('li')[0].text()).toContain('Buy groceries')
  })

  it('filters active todos', async () => {
    const wrapper = mount(TodoList)
    const input = wrapper.find('input[type="text"], input:not([type])')

    await input.setValue('Task 1')
    await input.trigger('keyup.enter')
    await input.setValue('Task 2')
    await input.trigger('keyup.enter')

    // Mark first todo as done
    const checkboxes = wrapper.findAll('input[type="checkbox"]')
    await checkboxes[0].setValue(true)

    // Filter active
    const buttons = wrapper.findAll('button')
    await buttons[0].trigger('click') // 'Active' button

    expect(wrapper.findAll('li')).toHaveLength(1)
    expect(wrapper.findAll('li')[0].text()).toContain('Task 2')
  })

  it('displays remaining count correctly', async () => {
    const wrapper = mount(TodoList)
    const input = wrapper.find('input[type="text"], input:not([type])')

    await input.setValue('Task 1')
    await input.trigger('keyup.enter')
    await input.setValue('Task 2')
    await input.trigger('keyup.enter')

    expect(wrapper.text()).toContain('2 items remaining')

    const checkboxes = wrapper.findAll('input[type="checkbox"]')
    await checkboxes[0].setValue(true)

    expect(wrapper.text()).toContain('1 items remaining')
  })
})

Mocking Dependencies in Unit Tests

Real components often depend on external services, API calls, or other modules. To keep unit tests fast and isolated, you need to mock these dependencies. Vitest provides powerful mocking utilities built on top of its module system.

Mocking API Calls

Let's say you have a component that fetches user data from an API:

<!-- src/components/UserProfile.vue -->
<template>
  <div class="user-profile" v-if="user">
    <h2>{{ user.name }}</h2>
    <p>{{ user.email }}</p>
    <p>Member since: {{ formatDate(user.joinedAt) }}</p>
  </div>
  <div v-else-if="loading" class="loading">Loading...</div>
  <div v-else-if="error" class="error">{{ error }}</div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { fetchUser } from '@/api/users'
import { formatDate } from '@/utils/date'

const user = ref(null)
const loading = ref(false)
const error = ref(null)

onMounted(async () => {
  loading.value = true
  try {
    user.value = await fetchUser(1)
  } catch (err) {
    error.value = 'Failed to load user'
  } finally {
    loading.value = false
  }
})
</script>
// tests/unit/UserProfile.spec.js
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'

// Mock the API module
vi.mock('@/api/users', () => ({
  fetchUser: vi.fn()
}))

// Mock the date utility
vi.mock('@/utils/date', () => ({
  formatDate: vi.fn((date) => `Formatted: ${date}`)
}))

import UserProfile from '@/components/UserProfile.vue'
import { fetchUser } from '@/api/users'
import { formatDate } from '@/utils/date'

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

  it('displays loading state initially', () => {
    fetchUser.mockReturnValue(new Promise(() => {})) // Never resolves
    const wrapper = mount(UserProfile)
    expect(wrapper.find('.loading').exists()).toBe(true)
  })

  it('displays user data after successful fetch', async () => {
    const mockUser = {
      name: 'Jane Doe',
      email: 'jane@example.com',
      joinedAt: '2023-01-15'
    }
    fetchUser.mockResolvedValue(mockUser)

    const wrapper = mount(UserProfile)
    await flushPromises()

    expect(wrapper.find('h2').text()).toBe('Jane Doe')
    expect(wrapper.find('p').text()).toBe('jane@example.com')
    expect(formatDate).toHaveBeenCalledWith('2023-01-15')
  })

  it('displays error message on fetch failure', async () => {
    fetchUser.mockRejectedValue(new Error('Network error'))

    const wrapper = mount(UserProfile)
    await flushPromises()

    expect(wrapper.find('.error').exists()).toBe(true)
    expect(wrapper.find('.error').text()).toBe('Failed to load user')
  })
})

Mocking Composables

If your component uses composables, you can mock those as well. This is especially useful when composables interact with browser APIs or external state:

// src/composables/useAuth.js
import { ref } from 'vue'

export function useAuth() {
  const user = ref(null)
  const isAuthenticated = ref(false)

  async function login(credentials) {
    // API call logic here
  }

  function logout() {
    user.value = null
    isAuthenticated.value = false
  }

  return { user, isAuthenticated, login, logout }
}
// tests/unit/ProtectedComponent.spec.js
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'

vi.mock('@/composables/useAuth', () => ({
  useAuth: vi.fn(() => ({
    user: { value: { name: 'Test User' } },
    isAuthenticated: { value: true },
    login: vi.fn(),
    logout: vi.fn()
  }))
}))

import ProtectedComponent from '@/components/ProtectedComponent.vue'
import { useAuth } from '@/composables/useAuth'

describe('ProtectedComponent.vue', () => {
  it('renders content when authenticated', () => {
    const wrapper = mount(ProtectedComponent)
    expect(wrapper.text()).toContain('Welcome, Test User')
  })

  it('calls logout when button is clicked', async () => {
    const { logout } = useAuth()
    const wrapper = mount(ProtectedComponent)
    await wrapper.find('[data-testid="logout-btn"]').trigger('click')
    expect(logout).toHaveBeenCalled()
  })
})

Integration Testing

Integration tests verify that multiple components work together correctly. They sit between unit tests and E2E tests in the testing pyramid. These tests are valuable for catching issues that arise from component interactions, shared state, and routing.

Testing Component Interactions

Let's test a parent-child component interaction. Suppose we have a product list and a product card component:

// tests/integration/ProductList.spec.js
import { describe, it, expect, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import ProductList from '@/components/ProductList.vue'
import ProductCard from '@/components/ProductCard.vue'

vi.mock('@/api/products', () => ({
  fetchProducts: vi.fn(() => Promise.resolve([
    { id: 1, name: 'Widget', price: 9.99, inStock: true },
    { id: 2, name: 'Gadget', price: 19.99, inStock: false },
    { id: 3, name: 'Gizmo', price: 14.99, inStock: true }
  ]))
}))

describe('ProductList integration', () => {
  let wrapper

  beforeEach(async () => {
    wrapper = mount(ProductList, {
      global: {
        plugins: [createTestingPinia()]
      }
    })
    await flushPromises()
  })

  it('renders a ProductCard for each product', () => {
    const cards = wrapper.findAllComponents(ProductCard)
    expect(cards).toHaveLength(3)
  })

  it('passes correct data to each ProductCard', () => {
    const cards = wrapper.findAllComponents(ProductCard)
    expect(cards[0].props('product').name).toBe('Widget')
    expect(cards[1].props('product').name).toBe('Gadget')
    expect(cards[2].props('product').name).toBe('Gizmo')
  })

  it('adds product to cart when ProductCard emits add event', async () => {
    const cards = wrapper.findAllComponents(ProductCard)
    await cards[0].vm.$emit('add', { id: 1, name: 'Widget' })
    expect(wrapper.vm.cart).toHaveLength(1)
    expect(wrapper.vm.cart[0].name).toBe('Widget')
  })
})

Testing with Vue Router

When testing components that use routing, you need to set up a router in your test environment:

// tests/integration/Navigation.spec.js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'
import Navigation from '@/components/Navigation.vue'
import HomeView from '@/views/HomeView.vue'
import AboutView from '@/views/AboutView.vue'

describe('Navigation integration with router', () => {
  const routes = [
    { path: '/', name: 'home', component: HomeView },
    { path: '/about', name: 'about', component: AboutView }
  ]

  const router = createRouter({
    history: createWebHistory(),
    routes
  })

  const mountNav = () => mount(Navigation, {
    global: {
      plugins: [router]
    }
  })

  it('renders navigation links', () => {
    const wrapper = mountNav()
    const links = wrapper.findAll('a')
    expect(links).toHaveLength(2)
    expect(links[0].text()).toBe('Home')
    expect(links[1].text()).toBe('About')
  })

  it('navigates to about page when link is clicked', async () => {
    const wrapper = mountNav()
    await router.isReady()
    await wrapper.findAll('a')[1].trigger('click')
    await router.isReady()
    expect(router.currentRoute.value.name).toBe('about')
  })
})

Snapshot Testing

Snapshot testing is a useful technique for catching unintended UI changes. Vitest stores a serialized version of your component's output and compares it on subsequent test runs.

// tests/unit/Header.spec.js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Header from '@/components/Header.vue'

describe('Header.vue snapshot', () => {
  it('matches the snapshot', () => {
    const wrapper = mount(Header, {
      props: {
        title: 'My App',
        user: { name: 'Jane', avatar: '/img/jane.png' }
      }
    })
    expect(wrapper.html()).toMatchSnapshot()
  })

  it('matches snapshot when user is not logged in', () => {
    const wrapper = mount(Header, {
      props: {
        title: 'My App',
        user: null
      }
    })
    expect(wrapper.html()).toMatchSnapshot()
  })
})

When you first run this test, Vitest creates a __snapshots__ directory with the saved output. If the output changes later, the test fails, and you can update the snapshot with vitest run -u if the change is intentional.

End-to-End Testing with Playwright

While unit and integration tests verify individual pieces, end-to-end tests verify the entire application from the user's perspective. Playwright is an excellent choice for E2E testing Vite applications because it's fast, reliable, and supports all major browsers.

Installing Playwright

npm install -D @playwright/test
npx playwright install

Create a playwright.config.js file at the root of your project:

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

export default defineConfig({
  testDir: './tests/e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:5173',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure'
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] }
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] }
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] }
    }
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:5173',
    reuseExistingServer: !process.env.CI,
    timeout: 30000
  }
})

The webServer configuration automatically starts your Vite dev server before running tests and stops it afterward, which is incredibly convenient.

Writing E2E Tests

Let's write E2E tests for a login flow and a shopping cart scenario:

// tests/e2e/auth.spec.js
import { test, expect } from '@playwright/test'

test.describe('Authentication flow', () => {
  test('user can log in successfully', async ({ page }) => {
    await page.goto('/login')

    // Fill in the login form
    await page.fill('[data-testid="email-input"]', 'user@example.com')
    await page.fill('[data-testid="password-input"]', 'password123')
    await page.click('[data-testid="login-button"]')

    // Verify redirect to dashboard
    await expect(page).toHaveURL('/dashboard')
    await expect(page.locator('h1')).toHaveText('Welcome back!')
    await expect(page.locator('[data-testid="user-name"]')).toHaveText('John Doe')
  })

  test('shows error on invalid credentials', async ({ page }) => {
    await page.goto('/login')

    await page.fill('[data-testid="email-input"]', 'wrong@example.com')
    await page.fill('[data-testid="password-input"]', 'wrongpass')
    await page.click('[data-testid="login-button"]')

    await expect(page.locator('[data-testid="error-message"]')).toBeVisible()
    await expect(page.locator('[data-testid="error-message"]')).toHaveText(
      'Invalid email or password'
    )
    await expect(page).toHaveURL('/login')
  })

  test('user can log out', async ({ page }) => {
    // Log in first
    await page.goto('/login')
    await page.fill('[data-testid="email-input"]', 'user@example.com')
    await page.fill('[data-testid="password-input"]', 'password123')
    await page.click('[data-testid="login-button"]')
    await expect(page).toHaveURL('/dashboard')

    // Log out
    await page.click('[data-testid="logout-button"]')
    await expect(page).toHaveURL('/login')
  })
})
// tests/e2e/shopping-cart.spec.js
import { test, expect } from '@playwright/test'

test.describe('Shopping cart', () => {
  test.beforeEach(async ({ page }) => {
    // Log in before each test
    await page.goto('/login')
    await page.fill('[data-testid="email-input"]', 'user@example.com')
    await page.fill('[data-testid="password-input"]', 'password123')
    await page.click('[data-testid="login-button"]')
    await expect(page).toHaveURL('/dashboard')
  })

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

    // Add two products
    await page.click('[data-testid="product-card"]:nth-child(1) [data-testid="add-to-cart"]')
    await page.click('[data-testid="product-card"]:nth-child(2) [data-testid="add-to-cart"]')

    // Check cart badge
    await expect(page.locator('[data-testid="cart-count"]')).toHaveText('2')

    // Go to cart page
    await page.click('[data-testid="cart-link"]')
    await expect(page).toHaveURL('/cart')
    await expect(page.locator('[data-testid="cart-item"]')).toHaveCount(2)
  })

  test('user can remove items from cart', async ({ page }) => {
    await page.goto('/products')
    await page.click('[data-testid="product-card"]:nth-child(1) [data-testid="add-to-cart"]')
    await page.goto('/cart')

    await expect(page.locator('[data-testid="cart-item"]')).toHaveCount(1)
    await page.click('[data-testid="cart-item"]:nth-child(1) [data-testid="remove-item"]')
    await expect(page.locator('[data-testid="cart-item"]')).toHaveCount(0)
    await expect(page.locator('[data-testid="empty-cart-message"]')).toBeVisible()
  })

  test('cart total is calculated correctly', async ({ page }) => {
    await page.goto('/products')
    await page.click('[data-testid="product-card"]:nth-child(1) [data-testid="add-to-cart"]')
    await page.click('[data-testid="product-card"]:nth-child(2) [data-testid="add-to-cart"]')
    await page.goto('/cart')

    const item1Price = await page
      .locator('[data-testid="cart-item"]:nth-child(1) [data-testid="item-price"]')
      .textContent()
    const item2Price = await page
      .locator('[data-testid="cart-item"]:nth-child(2) [data-testid="item-price"]')
      .textContent()

    const expectedTotal = parseFloat(item1Price.replace('$', '')) + parseFloat(item2Price.replace('$', ''))
    const actualTotal = await page.locator('[data-testid="cart-total"]').textContent()

    expect(parseFloat(actualTotal.replace('$', ''))).toBeCloseTo(expectedTotal, 2)
  })
})

Testing Visual Regression

Playwright also supports visual regression testing, which captures screenshots and compares them to baseline images:

// tests/e2e/visual.spec.js
import { test, expect } from '@playwright/test'

test.describe('Visual regression', () => {
  test('homepage looks correct', async ({ page }) => {
    await page.goto('/')
    await expect(page).toHaveScreenshot('homepage.png')
  })

  test('product page looks correct', async ({ page }) => {
    await page.goto('/products/1')
    await expect(page).toHaveScreenshot('product-page.png', {
      maxDiffPixelRatio: 0.01,
      threshold: 0.2
    })
  })

  test('mobile layout looks correct', async ({ page }) => {
    await page.setViewportSize({ width: 375, height: 667 })
    await page.goto('/')
    await expect(page).toHaveScreenshot('homepage-mobile.png')
  })
})

Testing Vite Plugins and Custom Transformations

If you're building Vite plugins or custom transformations, you'll want to test those as well. Vitest integrates seamlessly with Vite's plugin API for this purpose:

// tests/unit/myPlugin.spec.js
import { describe, it, expect } from 'vitest'
import { build } from 'vite'
import myPlugin from '@/plugins/myPlugin'

describe('myPlugin', () => {
  it('transforms code correctly', async () => {
    const result = await build({
      logLevel: 'silent',
      plugins: [myPlugin()],
      build: {
        write: false,
        rollupOptions: {
          input: 'tests/fixtures/sample.js'
        }
      }
    })

    const output = result.output[0].code
    expect(output).toContain('expected transformation')
    expect(output).not.toContain('original code that should be replaced')
  })
})

Best Practices for Testing Vite Components

1. Follow the Testing Pyramid

Structure your tests as a pyramid: many unit tests at the base, fewer integration tests in the middle, and a small number of E2E tests at the top. This ensures fast feedback and maintainable test suites.

2. Test Behavior, Not Implementation

Focus on what the component does, not how it does it. This makes your tests more resilient to refactoring:

// ❌ Bad: Testing implementation details
it('sets isLoading to true on mount', () => {
  const wrapper = mount(Component)
  expect(wrapper.vm.isLoading).toBe(true)
})

// ✅ Good: Testing observable behavior
it('shows loading spinner on mount', () => {
  const wrapper = mount(Component)
  expect(wrapper.find('[data-testid="spinner"]').exists()).toBe(true)
})

3. Use Data Attributes for Selectors

Use data-testid attributes for test selectors. This decouples tests from CSS classes and HTML structure, making them more maintainable:

<button data-testid="submit-button" @click="handleSubmit">Submit</button>

4. Keep Tests Independent

Each test should be able to run independently and in any order. Use beforeEach and afterEach hooks to reset state between tests:

describe('Component', () => {
  beforeEach(() => {
    // Reset mocks, clear stores, etc.
    vi.clearAllMocks()
    localStorage.clear()
  })

  it('test one', () => { /* ... */ })
  it('test two', () => { /* ... */ })
})

5. Write Meaningful Test Descriptions

Test descriptions should read like specifications. Use descriptive describe and it blocks that explain the expected behavior:

describe('Counter', () => {
  describe('increment', () => {
    it('increases the count by one', () => { /* ... */ })
    it('emits a change event with the new value', () => { /* ... */ })
    it('does not exceed the maximum value of 10', () => { /* ... */ })
  })
})

6. Mock at the Boundaries

Mock external dependencies like API calls, browser APIs, and third-party services. Avoid mocking internal modules unless absolutely necessary, as it can lead to tests that pass but don't catch real bugs.

7. Use Coverage Wisely

Coverage metrics are useful but shouldn't be the only goal. Aim for meaningful coverage of critical paths rather than 100% coverage of every line:

// vitest.config.js coverage thresholds
test: {
  coverage: {
    thresholds: {
      statements: 80,
      branches: 75,
      functions: 80,
      lines: 80
    }
  }
}

8. Run Tests in CI

Integrate your tests into your CI/CD pipeline. Run unit tests on every commit and E2E tests on pull requests:

# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: 18
      - run: npm ci
      - run: npm run test:run
      - run: npm run test:coverage

  e2e-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: 18
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test

9. Handle Async Operations Properly

Always await async operations in your tests. Vitest provides utilities like flushPromises and waitFor to help with this:

import { flushPromises } from '@vue/test-utils'

it('loads data asynchronously', async () => {
  const wrapper = mount(AsyncComponent)
  await flushPromises()
  expect(wrapper.find('.data').exists()).toBe(true)
})

10. Keep Test Files Organized

Maintain a clear directory structure that mirrors your source code:

tests/
  unit/
    components/
      Counter.spec.js
      TodoList.spec.js
    composables/
      useAuth.spec.js
  integration/
    ProductList.spec.js
  e2e/
    auth.spec.js
    shopping-cart.spec.js
  fixtures/
    sample-data.json

Conclusion

Testing Vite components doesn't have to be complicated, but it does require a thoughtful approach. By leveraging Vitest for unit and integration tests, you get a tool that's tightly integrated with Vite's ecosystem and

— Ad —

Google AdSense will appear here after approval

← Back to all articles