Testing Vue Directives: How to Verify v-ripple Usage
A developer in a Vue community I participate in recently asked: “I am using Vuetify’s ripple effect on several buttons. I need to write a unit test to ensure the v-ripple directive is actually present on the element, but wrapper.attributes() doesn’t show directives. How do I test this?”
Testing directives in Vue (especially third-party ones like v-ripple) is notoriously tricky because directives are not part of the final DOM attributes. They are instructions to the Vue compiler that disappear once the component is mounted.
The Immediate Fix
Section titled “The Immediate Fix”If you are using Vue 3 and Vue Test Utils (VTU) v2, the most reliable way to verify a directive’s presence is to mock the directive globally in your test setup and check if its lifecycle hooks were called on that specific element.
// Example using Vitest and Vue Test Utils (Vue 3 / VTU v2)import { mount } from '@vue/test-utils';import MyComponent from './MyComponent.vue';import { vi } from 'vitest';
test('button should have ripple directive applied', () => { const rippleSpy = vi.fn();
const wrapper = mount(MyComponent, { global: { directives: { // We overwrite the 'ripple' directive with a mock ripple: { mounted: rippleSpy } } } });
// Verify the directive was "mounted" expect(rippleSpy).toHaveBeenCalled();});Detailed Explanation
Section titled “Detailed Explanation”Directives like v-ripple operate by attaching event listeners and DOM manipulations behind the scenes. When you run a unit test, you aren’t typically looking for the result of the ripple (the animation), but rather the intent (that the developer didn’t forget to add the attribute).
The reason wrapper.attributes() or wrapper.classes() often fails to show the directive is that many ripple implementations don’t add a static class until the element is actually clicked or interacted with. By mocking the directive at the global level during mount, you intercept the moment Vue attempts to bind the directive logic to the DOM node.
Solution 1: Checking for DOM Side Effects (Integration Style)
Section titled “Solution 1: Checking for DOM Side Effects (Integration Style)”Most ripple directives (like Vuetify’s) add a specific property or a class to the element to indicate it is “ripple-enabled.” If you don’t want to mock the directive, you can check for these internal markers.
Illustrative example (Vuetify 3 / Vue 3):
import { mount } from '@vue/test-utils';import { createVuetify } from 'vuetify';import MyComponent from './MyComponent.vue';
const vuetify = createVuetify();
test('verifies ripple by checking internal state', () => { const wrapper = mount(MyComponent, { global: { plugins: [vuetify] } });
const btn = wrapper.find('button');
// Vuetify 3 often attaches a '_ripple' object to the HTMLElement expect(btn.element._ripple).toBeDefined(); expect(btn.element._ripple.enabled).toBe(true);});Why this works: It checks the actual implementation side-effect. However, it is “brittle” because it relies on the internal naming conventions of the library (like _ripple), which could change in a patch update.
Solution 2: Custom Wrapper Spies (Logic Style)
Section titled “Solution 2: Custom Wrapper Spies (Logic Style)”If you are writing your own custom ripple directive or want a more generic approach, you can create a test-specific mock that records which element it was attached to.
Illustrative example (Vue 3 / Vitest):
let directiveAppliedTo = null;
const wrapper = mount(MyComponent, { global: { directives: { ripple: { mounted(el) { directiveAppliedTo = el; } } } }});
const target = wrapper.find('[data-test="ripple-button"]').element;expect(directiveAppliedTo).toBe(target);Why this works: This is the most “pure” unit test. It doesn’t care what the ripple does; it only verifies that Vue’s engine successfully applied the ripple instruction to the specific DOM element you expected.
Edge Cases and Common Pitfalls
Section titled “Edge Cases and Common Pitfalls”- Conditional Ripples: If you have
v-ripple="isRippleActive", remember to test both states. Mocking the directive lifecycle hooks (as shown in Solution 2) allows you to check ifupdatedormountedwas called based on the prop value. - Shallow Mount: If you use
shallowMount, directives on child components will not be executed. You must usemountif you want to verify directives applied to elements inside the component’s template. - Vuetify Global Components: If testing a Vuetify component specifically, ensuring you’ve passed the
vuetifyinstance inglobal.pluginsis critical, otherwise the directive will simply fail to resolve, often silently or with a warning in the console.
Related Follow-up Questions
Section titled “Related Follow-up Questions”How does this change for Vue 2?
In Vue 2 and VTU v1, you can often find directives by accessing the internal VNode. You would use wrapper.vm.$vnode.data.directives. It’s much more invasive and one of the reasons the Vue 3 testing ecosystem moved toward the mocking strategy described above.
Should I test if the ripple actually animates? Generally, no. That would be testing the framework (like Vuetify or PrimeVue) rather than your code. Your responsibility is to ensure the directive is applied; the framework’s responsibility is to ensure the ripple works. If you must test the animation, you would need an E2E tool like Cypress or Playwright to handle the CSS transitions and timing.
Is there a way to check directives without mocking?
In Vue 3, there is no public wrapper.getDirective() API. This is intentional to discourage testing internal implementation details. If you find yourself needing this frequently, consider if the logic inside the directive should actually be a composable or a component, which are significantly easier to test in isolation.