docs
  1. Storefront Application
  2. Testing
  3. Unit Testing

Unit Testing

Overview

As technical complexity rises, so does the potential for side effects and bugs. It is therefore crucial to verify and test critical code. The SCAYLE Storefront Application comes with a range of preconfigured unit test setups and an ever-growing test suite to support this.

The unit test coverage of the Storefront Application is continuously extended with every release. The current focus lies on functional testing for composables and utilities. Vue component testing (integration testing) is currently not within the direct scope but will be added at a later point.

Getting Started

By leveraging the unit testing framework vitest, we can isolate and test individual functions, components, and logic within the Storefront Application codebase. This granular approach allows us to verify expected behavior, identify and address bugs early in the development lifecycle, and refactor with confidence. Ultimately, a robust suite of unit tests contributes significantly to the overall stability, maintainability, and reliability of our codebase, ensuring a smoother development experience for everyone involved.

Prerequisites

We recommend getting familiar with the testing capabilities of Vitest and Nuxt before proceeding with this guide:

Testing Environment Setup

The Storefront Application relies on vitest as its integrated unit test framework. To extend testing capabilities, it also utilizes @vue/test-utils, @nuxt/test-utils, and happy-dom. These dependencies collectively cover a wide range of potential unit test cases within the context of a Vue and Nuxt application.

@vue/test-utils

@vue/test-utils is a library for testing Vue.js components. It provides utilities to:

  • Mount components: Mount individual components in isolation or as part of a larger tree.
  • Interact with components: Trigger events, update props, and manipulate component state.
  • Assert on output: Make assertions about rendered HTML, data, and computed properties.
  • Control rendering: Choose from shallowMount, mount, or render based on testing needs.
  • Work with Slots: Test how components manage content passed through slots.
  • Simplify Async Operations: Conveniently test asynchronous actions (e.g., API calls, transitions) using async/await and nextTick.
  • Access Component Instance: Directly access the underlying Vue instance for lifecycle hooks, methods, or internal state.
  • Mock Dependencies: Easily replace external dependencies with mocks to isolate component logic.
  • Improve Test Readability: Write cleaner tests with helper methods for finding elements and triggering events.

Simple Example Test:

This test case uses shallowMount to render MyComponent without its child components. It then checks that the rendered output contains the text "Hello World", which is passed as a prop to the component.

@nuxt/test-utils

@nuxt/test-utils is specifically designed for testing Nuxt.js applications. Building upon @vue/test-utils, it provides additional functionalities tailored to Nuxt's unique features:

  • Mount Nuxt Components: Mount components while ensuring Nuxt plugins, middleware, and other configurations are correctly applied, mimicking a real Nuxt environment.
  • Test Server-Side Rendering (SSR): Test the server-side rendering behavior of your Nuxt components and pages.
  • Interact with Nuxt Context: Access and interact with the Nuxt context ($nuxt) within your tests for navigation, route data, or store interactions.
  • Mock Nuxt Modules: Easily mock the behavior of Nuxt modules to isolate functionalities or test configurations.
  • Handle Asynchronous Data Fetching: Test components and pages that rely on asynchronous data fetching methods (asyncData, fetch, nuxtServerInit).

Simple Example Test:

the @nuxt/test-utils is able to let a test run in a Nuxt application-specific environment. This enables the test to access many of the abstracted Nuxt utilities and functionalities without the need to explicitly mock them manually. For this to work, a test needs to have the *.nuxt.test.ts suffix.

happy-dom

happy-dom, as a lightweight and fast DOM implementation, pairs well with vitest. This combination offers efficient and streamlined DOM testing:

  • Speed and Efficiency: Both tools prioritize speed. Vitest utilizes native ES modules, and happy-dom's lightweight design minimizes DOM overhead, resulting in significantly faster test execution.
  • Fine-grained Control: Provides direct access to DOM APIs for precise control over component rendering, manipulation, and assertions.
  • Minimal Dependencies: Allows writing tests without relying on additional testing library abstractions, keeping project dependencies lean.

Simple Example Setup:

Simple Example Test:

Explanation:

  1. vite.config.js: Set Vitest to use happy-dom. A setup file (vitest.setup.js) is included for global configurations.
  2. vitest.setup.js: Create a global window and document using happy-dom.
  3. MyComponent.test.js:
    • Import from Vitest and your component.
    • Create a container element in beforeEach for each test.
    • Mount your Vue component to the container using createApp.
    • Wait for the next tick of the Vue update cycle using nextTick().
    • Make assertions using querySelector or other DOM APIs.

Important Considerations:

  • Timing Is Crucial: You'll need to manage when Vue updates the DOM. Using await nextTick() is essential to ensure your assertions run after Vue has had a chance to update.
  • More Verbose: You'll write more verbose assertions with DOM APIs directly compared to using testing library abstractions.
  • Vue-Specific Logic: The way you mount/render your Vue component may need to be adjusted based on your component's setup (Options API or Composition API).

While happy-dom offers fine-grained control and avoids extra dependencies, using a testing library often leads to more robust and maintainable tests. Choose the method that best suits your project's needs and complexity. Remember that while happy-dom is excellent for unit and integration tests involving DOM manipulation, it's essential to complement these with End-to-End tests in real browsers to cover all aspects of your application.

Writing Storefront Unit Tests

Test File Naming & Location Convention

When writing unit tests within a Storefront Application, it's important to consider the context of the test case. Can a certain function be tested independently of its application context, or does it require the application context to work?

  • Example: A simple string manipulation utility can be tested without an application context, while a custom Vue composable that interacts with the global application state requires the Nuxt application context.

To simplify and differentiate between these test cases, the respective test files are executed differently by vitest depending on the file ending suffix:

  • .test.ts: The test file is run in a "basic" encapsulated unit test context.
  • .nuxt.test.ts: The test file is run in a Nuxt application-specific environment context. See the section on @nuxt/test-utils for more details.

All tests should be located directly beside the composable / utility / component they test. This allows for tighter coupling and increased maintainability.

Testing Composables

To understand how unit tests for composables should ideally be structured, let's dissect a more complex test example like the useFilter composable, which handles filter behavior on the Product Listing Page.

1. Mocking Dependencies

  • Mocked Modules: The test begins by mocking external and internal dependencies of useFilter:
    • #app/composables/router: Mocks useRoute and useRouter for routing interactions.
    • #storefront/composables: Mocks useFilters which presumably provides filter data.
    • #i18n: Mocks useI18n for internationalization.
    • ~/composables: Mocks several other composables (useAppliedFilters, useTrackingEvents, useToast) used within useFilter.
  • Mocked Data: A mocks object centrally holds all the mocked values and functions, allowing easy access and manipulation of mocked behavior.

2. Test Suite: describe('useFilter', ...)

  • beforeEach Hook: This setup function runs before each individual test (it block). It ensures a clean state by:
    • Resetting mocks.useFilters.data.value.filters with a predefined set of filter data, simulating data fetched from an API or store.
    • Clearing applied filters (useAppliedFilters) and query parameters (route.query).
    • Resetting all mocked functions using vi.clearAllMocks(), guaranteeing that each test starts with a fresh mock setup.

3. Individual Test Cases (it blocks):

  • should have correct filterable values: This test focuses on the availableFilters computed property of useFilter. It checks if the computed property correctly structures and returns the filter data as expected.
  • should filter our boolean filter without products: Tests the filtering logic, particularly for boolean filters with zero product counts, ensuring those filters are excluded.
  • Nested describe blocks: The test suite further nests several describe blocks to group related test cases, enhancing readability and organization. These nested blocks include:
    • applyAttributeFilter: Tests the functionality of applying attribute-based filters, covering various scenarios like adding, merging, and removing filter values in the URL query parameters.
    • applyBooleanFilter: Specifically, tests applying boolean filters, checking if the query parameters are updated correctly.
    • applyPriceFilter: Focuses on testing the application of price range filters to the query parameters.
    • resetFilter: Tests the resetFilter function, which handles removing a specific filter from the applied filters.
    • resetPriceFilter: Similar to resetFilter, but specific to resetting price filters.
    • resetFilters: Tests the resetFilters function for clearing all applied filters.

4. Overall Structure

  • The test suite follows a clear and organized structure using describe and it blocks for better readability.
  • Mocks are effectively used to isolate the useFilter logic, making the tests more deterministic and less reliant on external factors. It's important to note that not every external dependency (e.g. functions) should be mocked, but only those that are needed and influence the execution and / or outcome within the tested functionality.
  • The beforeEach hook ensures a consistent and clean starting state for each test case.
  • The use of nested describe blocks logically groups related tests.
  • Data for assertions should be explicitly defined and used directly within the specific test block and should not be reused across multiple test cases. This allows to understand the test case quicker and avoid potential data mutations.

Best Practices for Storefront Unit Tests

  1. Focused Tests: One aspect per test case.
  2. Clarity and Conciseness: Meaningful names, comments.
  3. Deterministic Tests: Avoid randomness, ensure consistent results.
  4. Code Coverage: Aim for good coverage, use reporting tools.

Understand and Structure Your Tests Clearly

  • Arrange-Act-Assert Pattern: Structure your tests clearly by separating the setup (arrange), the action (act), and the assertions (assert).
  • Descriptive Test Names: Ensure test descriptions clearly convey what they test and the expected outcomes.
  • Logical Grouping: Organize tests logically around utils, composables or components and their functionalities.

Leverage TypeScript’s Static Typing

  • Use Strong Typing: Define interfaces and types for mocks, data sets, and components under test to avoid type-related runtime errors.
  • Utilize Type Inference: Where possible, rely on TypeScript's type inference to make code cleaner and less verbose.

Mocking and Isolation

  • Use Mocks and Stubs: Isolate units of code by replacing dependencies with mocks or stubs, which can be easily done using Vitest’s utilities like vi.fn() for mocking functions.
  • Type-Safe Mocks: Ensure that mocks adhere to the types of the dependencies they replace, maintaining type safety.
  • Selective Mocking: When mocking external dependencies in unit tests, prioritize dependencies directly impacting the control flow and output of the tested functionality. Avoid excessive mocking to prevent brittle tests and ensure meaningful insights into real-world behavior.

Component Isolation

  • Shallow Mount: Use shallow mounting when testing components to isolate them from their child components, which can be individually tested. This helps in pinpointing where issues occur.
  • Mock Dependencies: Use mocks for external dependencies and services to test components in isolation.

Reactivity and State Management

  • Test Data Reactivity: Ensure that your tests account for Vue’s reactivity system by testing changes in component data and observing the effects on the DOM.

Effective Use of Assertions

  • Choose the Right Assertions: Use appropriate assertions to make your tests readable and meaningful. Vitest provides a rich set of assertions that can handle various scenarios.
  • Test Edge Cases: Include tests for boundary conditions and unusual situations to ensure robustness.

Test Coverage Goals

  • Strive for Meaningful Coverage: Aim for high test coverage but focus on testing the logic thoroughly rather than hitting arbitrary percentage targets.
  • Use Coverage Tools: Use Vitest’s built-in coverage reporters to assess which parts of your codebase need more thorough testing.

Asynchronous Testing

  • Proper Handling of Async Code: Use async / await for testing asynchronous functions to make your tests clean and easy to follow. Ensure that tests complete all operations before assertions.
  • Timeouts and Waits: Be mindful of using timeouts and waitFor functions to handle scenarios involving delayed responses. Use Vitest’s capabilities to control and simulate timers when testing components that rely on time-based functionalities.

Parameterized Tests

  • Use test.each: When you have multiple inputs and outputs for the same piece of logic, use test.each to run the same test logic under different conditions.

Use of Snapshot Testing

  • Minimize usage: As snapshots rely on string comparison, this can lead to a noticeable performance impact during test execution. Instead we should rely on explicit selectors to check changes if possible.
  • Snapshots for UI Changes: Utilize snapshot (inline preferred) testing to capture the rendered output of components, which helps in detecting unintended UI changes.
  • Review Snapshots Carefully: Always review snapshot changes carefully during code reviews to ensure that changes are intentional and correct.

Documentation and Examples:

  • Document Test Cases: Keep documentation for test cases updated to help new developers understand the purpose and coverage of existing tests.
  • Provide Realistic Examples: Use realistic data in tests to mimic real-world scenarios as closely as possible.

Integration with Nuxt

  • Nuxt Runtime Environment: Enable the Nuxt Runtime Environment on a per test base by adding *.nuxt.* to the filename (e.g. my-storefront-function.nuxt.test.ts), this allows to test with the Nuxt application context and reduces the need for extensive dependency and functionality mocking.
  • Nuxt Specific Hooks: Test Nuxt specific hooks and functionality like fetch, asyncData, and plugins ensuring they integrate well within the component lifecycle.
  • Server-Side Rendering: Consider scenarios where components need to be pre-rendered on the server, and ensure your tests reflect both client and server-side behaviors.

Additional Tips

  1. Leverage Nuxt composables for testability
  2. Integrate testing with the CI/CD pipeline

NOTE: Remember that @nuxt/test-utils provides a powerful set of utilities for testing various Nuxt-specific aspects. Use it in conjunction with @vue/test-utils for comprehensive component testing within your Nuxt application.