DEV Community

allhandsondeck
allhandsondeck

Posted on

An opinionated testing approach for VueJS

Testing in VueJS Applications

Testing in VueJS applications is often viewed through a limited lens, with tutorials primarily focusing on user interaction and template testing (integration tests). However, testing in VueJS, like any software development endeavour, benefits from a multifaceted approach, typically represented by the testing pyramid. This pyramid suggests that most tests should be unit tests, followed by component tests, and finally E2E tests. Each testing level addresses unique aspects of code reliability, ensuring faster feedback, better test coverage, and more maintainable applications.

Comparing VueJS and Angular

Having worked with Angular, I often compared its architecture, which uses services to separate logic from components, with Vue’s composables. Initially, I thought composables were solely for reusable code. However, revisiting Vue’s documentation revealed that composables also help organise and separate concerns, much like Angular’s services.

Breaking Assumptions About VueJS Component Testing

A recurring misconception in some VueJS tutorials is that unit testing of functions within components is either discouraged or overlooked. In many tutorials, the focus is solely on component tests, which typically emphasise testing user interactions, rendering, and template integration. What is often not clearly emphasised, however, is the value of separating business logic into composables and unit testing these pure functions independently.

The tendency is to assume that methods inside a Vue component are inherently tied to the component’s template and cannot/should not be tested independently. In reality, this perception stems from insufficient separation of concerns—when logic becomes complex within components, it’s harder to test, which leads to the avoidance of unit tests altogether.

By moving logic into composables, we can break down functionality into smaller, focused units that are easier to unit test. Although tutorials emphasise component testing, unit testing composables is a critical part of a robust testing strategy. This ensures we can validate functionality in isolation without worrying about the UI layer.

Component testing fits between unit tests and E2E tests on the testing pyramid. It tests how a component’s internal logic interacts with its template, acting as an integration test. While more comprehensive than unit tests, component tests are typically faster and less resource-intensive than E2E tests. This is why component tests are often mistakenly labeled as unit tests in many tutorials. Component tests verify interaction between the internal parts of a component but don’t test pure functions in isolation.

Why Do We Avoid Testing Functions in Components?

We often assume that functions inside a Vue component are solely there to support the component’s template and user interactions, making them difficult or unnecessary to unit test. However, this assumption tends to hold true only when the code within the component’s script tags is not well-separated. When the logic and rendering are mixed together in a single place, it leads to large, complex, and hard-to-test functions, creating the tendency to avoid unit testing altogether.

This is where the testing pyramid becomes particularly helpful: by separating out logic into composables as smaller pure functions and focusing on unit tests for those functions, we can ensure that individual pieces of functionality are correct and fast to test. Component tests provide a middle ground, verifying the interaction between the component and its template, while E2E tests verify the entire system in a user-facing context. Each layer of the pyramid has its own distinct benefits and should be used in conjunction with the others to maintain a balanced and effective testing strategy.

How Can We Address This?

By refactoring these functions into smaller, modular pieces — such as composables — we can isolate the logic from the template and test it independently. When we separate the logic clearly, functions become isolated units that can be easily tested without depending on the component’s template or the DOM. This approach is key in promoting testability.

Another observation is how some VueJS training materials discourage testing functions directly. These materials often claim that testing inputs and outputs of functions equates to “testing internals” and that tests should only focus on user interactions. While interaction tests are essential, unit tests for individual functions have their own distinct value, such as:

Preventing programming mistakes.

Ensuring logic correctness.

Acting as documentation for how the function is intended to work.

The documentation purpose of tests is often overlooked in these assumptions, yet it provides developers with a clear understanding of function behavior, making onboarding and maintenance smoother.

The Case for Composables in Testing Strategy

Vue’s Composition API introduces composables as a powerful tool for modularizing logic. The official documentation highlights their role in both reusability and code organisation. By moving logic to composables, we decouple business logic from the UI layer, which offers several benefits:

  1. Enhanced Testability:
    • Composables allow us to write pure, focused unit tests for business logic, separate from the component’s template or lifecycle.
    • Components remain simpler, requiring fewer or no mocks during testing.

  2. Clearer Separation of Concerns:
    • Components handle rendering and lifecycle methods, while composables manage reusable logic and state.

  3. Component-Specific Logic Encapsulation:
    • In this approach, the composable is created solely for the component it belongs to.
    • It is kept directly alongside the component for clarity, and the file name reflects its singular purpose (e.g., useHelloWorld.ts for a HelloWorld.vue component).
    • This pattern is comparable to Angular’s practice of injecting a dedicated service into a component, emphasizing encapsulation and maintainability.

  4. Component-Only Testing:
    • Moving functions into composables leaves the component with only template-related logic, such as handling events or lifecycle concerns.
    • This ensures that the remaining tests for the component focus exclusively on verifying the integration between the template and the component.

Refactoring for Better Testability: An Example

To highlight the importance of separating logic into composables, let’s consider an example. Below is the non-refactored initial state where all the logic resides inside the component. We’ll then refactor this code to extract the pure function into a composable for better modularity and testability.

Non-Refactored Initial State

HelloWorld.vue

In the initial state, we have a component where business logic is directly embedded in the template. For example, the function getTimeBasedGreeting is placed inside the component.

<script setup lang="ts">
defineProps<{
  msg: string
}>()

// Pure function that can be easily moved to a composable
const getTimeBasedGreeting = (hour: number): string => {
  if (hour >= 5 && hour < 12) return 'Good morning'
  if (hour >= 12 && hour < 17) return 'Good afternoon'
  if (hour >= 17 && hour < 22) return 'Good evening'
  return 'Good night'
}

const currentHour = new Date().getHours()
const currentGreeting = getTimeBasedGreeting(currentHour)
</script>

<template>
  <div class="greetings">
    <h1 class="green">{{ currentGreeting }}, {{ msg }}</h1>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

HelloWorld.spec.ts

In the non-refactored state, we directly test the function within the component.

import { mount } from '@vue/test-utils'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import HelloWorld from '../HelloWorld.vue'

describe('HelloWorld.vue', () => {
  it('renders proper greeting with provided message', () => {
    const wrapper = mount(HelloWorld, {
      props: {
        msg: 'World',
      },
    })

    expect(wrapper.text()).toContain('World')
  })

  it('applies correct CSS classes', () => {
    const wrapper = mount(HelloWorld, {
      props: {
        msg: 'World',
      },
    })

    expect(wrapper.find('h1').classes()).toContain('green')
    expect(wrapper.find('.greetings')).toBeTruthy()
  })

  it('renders message based on time of day', () => {
    vi.useFakeTimers()
    const times = [
      { time: '2023-10-10T05:00:00Z', expected: 'Good morning' },
      { time: '2023-10-10T12:00:00Z', expected: 'Good afternoon' },
      { time: '2023-10-10T18:00:00Z', expected: 'Good evening' },
      { time: '2023-10-10T23:00:00Z', expected: 'Good night' },
    ]

    times.forEach(({ time, expected }) => {
      vi.setSystemTime(new Date(time))
      const wrapper = mount(HelloWorld)
      expect(wrapper.text()).toContain(expected)
    })
    vi.useRealTimers()
  })
})
Enter fullscreen mode Exit fullscreen mode

Refactored State: Component and Composable

After refactoring, the getTimeBasedGreeting function is moved into a separate composable, and the component now consumes it. This leads to better separation of concerns, making the component easier to test.

HelloWorld.vue

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

defineProps<{
  msg: string
}>()

const { getTimeBasedGreeting } = useHelloWorld()
const currentHour = new Date().getHours()
const currentGreeting = getTimeBasedGreeting(currentHour)
</script>

<template>
  <div class="greetings">
    <h1 class="green">{{ currentGreeting }}, {{ msg }}</h1>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

HelloWorld.spec.ts (Component Test)

Here, we mock the composable to isolate the component’s rendering logic and test its integration with the template. The tests ensure the component correctly handles props, renders the message, and applies styles.

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

vi.mock('./useHelloWorld', () => ({
  useHelloWorld: () => ({
    getTimeBasedGreeting: vi.fn().mockReturnValue('Good morning'),
  }),
}))

describe('HelloWorld', () => {
  it('renders proper greeting with provided message', () => {
    const wrapper = mount(HelloWorld, {
      props: {
        msg: 'World',
      },
    })

    expect(wrapper.text()).toContain('Good morning, World')
  })

  it('applies correct CSS classes', () => {
    const wrapper = mount(HelloWorld, {
      props: {
        msg: 'World',
      },
    })

    expect(wrapper.find('h1').classes()).toContain('green')
    expect(wrapper.find('.greetings')).toBeTruthy()
  })
})
Enter fullscreen mode Exit fullscreen mode

useHelloWorld.ts (Composable)

The logic is moved to a composable where it can be reused in other components if needed. In this case, it’s specifically used by the HelloWorld component.

export const useHelloWorld = () => {
  const getTimeBasedGreeting = (currentHour: number): string => {
    if (currentHour >= 5 && currentHour < 12) return 'Good morning'
    if (currentHour >= 12 && currentHour < 17) return 'Good afternoon'
    if (currentHour >= 17 && currentHour < 22) return 'Good evening'
    return 'Good night'
  }

  return {
    getTimeBasedGreeting,
  }
}
Enter fullscreen mode Exit fullscreen mode

useHelloWorld.spec.ts (Unit Test for the Composable)

The unit tests for the composable now test the pure getTimeBasedGreeting function, verifying that it returns the correct greeting based on the time.

import { describe, it, expect } from 'vitest'
import { useHelloWorld } from './useHelloWorld'

describe('useHelloWorld', () => {
  const { getTimeBasedGreeting } = useHelloWorld()

  describe('getTimeBasedGreeting', () => {
    it('returns "Good morning" at the start of morning (5am)', () => {
      expect(getTimeBasedGreeting(5)).toBe('Good morning')
    })

    it('returns "Good afternoon" at the start of afternoon (12pm)', () => {
      expect(getTimeBasedGreeting(12)).toBe('Good afternoon')
    })

    it('returns "Good evening" at the start of evening (17pm)', () => {
      expect(getTimeBasedGreeting(17)).toBe('Good evening')
    })

    it('returns "Good night" at the start of night (22pm)', () => {
      expect(getTimeBasedGreeting(22)).toBe('Good night')
    })

    it('returns "Good night" at midnight', () => {
      expect(getTimeBasedGreeting(0)).toBe('Good night')
    })
  })
})
Enter fullscreen mode Exit fullscreen mode

Bridging the Gap Between Test Types

By moving functions into composables, the component is left with logic that directly relates to its template—handling representation, event-driven interactions, and lifecycle concerns. This aligns perfectly with component tests, which are often referred to as integration tests because they validate the integration between the template and the component’s logic.

With this clear separation of concerns, the terminology fits naturally into the following categories:

  1. Unit Tests
    • These are tests we perform for the pure functions in composables.
    • Focused on isolated inputs and outputs.
    • Serve as documentation for the expected behaviour of composable functions.

  2. Component/Integration Tests
    • These are the tests for components stripped of extraneous logic, focusing solely on how the component interacts with its template.
    • Test scenarios like clicking a button, rendering the correct output, or managing basic event logic.
    • Validate the component’s role as the UI layer while ensuring its integration with composables works as expected.

  3. End-to-End (E2E) Tests
    • These tests ensure the entire application flow works as intended.
    • They don’t require detailed clarification since their purpose—to test complete application behavior—is widely understood.

Final Thoughts

Testing is a fundamental part of software development, and VueJS offers a rich ecosystem to support it. By embracing composables and leveraging different types of tests, we can create robust, maintainable applications. While some resources downplay unit testing in Vue, my experience shows that testing composables and functions directly is as valuable as testing user interactions. Together, they form a comprehensive testing strategy that simplifies debugging, accelerates development, and ensures application quality.

By rethinking how we structure our components and tests, we can unlock the full potential of VueJS as a framework for building scalable, high-quality applications.

Top comments (0)