Search code examples
typescriptvuejs3vitevue-composition-apivue-test-utils

Vue3 Composition API unit tests failing to update modelValue


I'm new to Vue and currently writing unit tests for a search component that I am using in my project. Simply, when the user types in the input field and small X icon appears to the right of the input field. Clicking the X will reset the value of the field back to an empty string.

The component is using the composition API and working as intended, and I can watch the emits and payloads using Vue dev tools, however I have been unable to see these events using Vitest. The majority of the tests are failing, and I am wondering where the mistake is in my logic.

For this question I recreated the component with some scoped style to make it easy to mount if necessary. Here it is using the Vue3 Comp Api, TypeScript, Vite, Vitest and vue-test-utils.

Here is the component:

<template>
  <div class="searchBar">
    <input
      :value="modelValue"
      class="searchInput"
      @input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
      autocomplete="off"
      data-test="searchInput"
    />

    <button
      v-if="modelValue"
      @click="clear($event)"
      class="clearIcon"
      ariaLabel="Clear Search"
      data-test="clearIcon"
    >
      <i class="fa fa-times"></i>
    </button>
  </div>
</template>

<script lang="ts">
import {
  defineComponent,
  watch,
} from "vue";

export default defineComponent({
  name: "SOComponent",
  props: {
    modelValue: {
      type: [String, Number],
    },
  },
  emits: [
    "update:modelValue",
    "search",
    "clear",
  ],
  setup(props, { emit }) {

    function clear(event: Event) {
      emit("clear", event);
      emit("update:modelValue", "");
    }

    watch(
      () => props.modelValue,
      (newValue, oldValue) => {
        if (newValue !== oldValue) {
          emit("search", newValue);
        }
      }
    );

    return {
      clear,
    };
  },
});
</script>

<style scoped>
  .searchBar {
    display: flex;
    justify-content: space-between;
    align-items: center;
    background-color: white;
    border: 2px solid black;
    border-radius: 1rem;
  }

  .searchInput {
    border: none;
    width: 100%;
    outline: none;
    color: black;
    font-size: 1rem;
    padding: 1rem;
    background-color: transparent;
  }

  .clearIcon {
    display: flex;
    align-items: center;
    justify-content: center;
    margin-right: 1rem;
    background-color: red;
    border: none;
    color: white;
    border-radius: 1rem;
    padding: 6.5px 9px;
    font-size: 1rem;
  }

  .clearIcon:hover {
    background-color: darkred;
  }
</style>

Here are the unit tests:

import { describe, it, expect, vi, afterEach } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import SOComponent from '../StackOverflowComponent.vue'

describe('SOComponent Component Tests', () => {

    // Wrapper Factory
    let wrapper: any

    function createComponent() {
        wrapper = shallowMount(SOComponent, {
            attachTo: document.body
        })
    }

    afterEach(() => {
        wrapper.unmount()
    })

    // Helper Finder Functions
    const searchInput = () => wrapper.find('[data-test="searchInput"]')
    const clearIcon = () => wrapper.find('[data-test="clearIcon"]')

    describe('component rendering', () => {

        it('component renders as intended when created', () => {
            createComponent()
            expect(searchInput().exists()).toBe(true)
            expect(clearIcon().exists()).toBe(false)
        })

        it('clear icon is displayed when input field has value', async () => {
            createComponent()
            await searchInput().setValue('render X')
            expect(clearIcon().exists()).toBe(true)
        })

        it('clear icon is not displayed when input field has no value', async () => {
            createComponent()
            await searchInput().setValue('')
            expect(clearIcon().exists()).toBe(false)
        })
    })

    describe('component emits and methods', () => {

        it('update:modelValue emits input value', async () => {
            createComponent()
            await searchInput().setValue('emit me')
            expect(wrapper.emitted('update:modelValue')).toBeTruthy()
            expect(wrapper.emitted('update:modelValue')![0]).toEqual(['emit me'])
        })

        it('clear icon click calls clear method', async () => {
            createComponent()
            await searchInput().setValue('call it')
            const clearSpy = vi.spyOn(wrapper.vm, 'clear')
            await clearIcon().trigger('click')
            expect(clearSpy).toHaveBeenCalled()
        })

        it('clear icon click resets input field value', async () => {
            createComponent()
            await searchInput().setValue('clear me')
            await clearIcon().trigger('click')
            expect((searchInput().element as HTMLInputElement).value).toBe('')
        })

        it('search is emitted when input gains value', async () => {
            createComponent()
            await searchInput().setValue('emit me')
            expect(wrapper.emitted('search')).toBeTruthy()
            expect(wrapper.emitted('search')![0]).toEqual(['emit me'])
        })

        it('clear is emitted when clear icon is clicked', async () => {
            createComponent()
            await searchInput().setValue('emit me')
            await clearIcon().trigger('click')
            expect(wrapper.emitted('clear')).toBeTruthy()
        })

        it('update:modelValue is emitted when clear icon is clicked', async () => {
            createComponent()
            await searchInput().setValue('clear me')
            await clearIcon().trigger('click')
            expect(wrapper.emitted('update:modelValue')).toBeTruthy()
            expect(wrapper.emitted('update:modelValue')![1]).toEqual([''])
        })
    })
})

At this point I feel like I must be missing something fundamental about Vue3 reactivity since I am unable to test conditional renders attached to v-model. Honestly any help, solutions or advice would be very appreciated!

Thank you :)


Solution

  • From my understanding, it sounds like the 2 way binding for V-Model is not included in vue-test-utils. The fix I found was to set a watcher in props to track update:modelValue, and this will update the modelValue prop.

    function createComponent() {
        wrapper = shallowMount(Component, {
            attachTo: document.body,
            props: {
                'onUpdate:modelValue': async (modelValue: any) => await wrapper.setProps({ modelValue })
            }
        })
    }
    

    Solution: https://github.com/vuejs/test-utils/discussions/279