Search code examples
testingvuejs3vitestvue-testing-library

Using vitest and testing-library is there a way to segregate component renders on a test by test basis?


I have a simple list component written in Vue3 that I am using to learn how to write automated test with Vitest and testing-library. However every test method seems to be rendered together, causing my getByText calls to throw the error TestingLibraryElementError: Found multiple elements with the text: foo.

This is the test I have written:

import { describe, it, expect, test } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/vue'
import TmpList from '../ui/TmpList.vue'

const listItems = ['foo', 'bar']

describe('TmpList', () => {
    // Test item-content slot rendering
    test('renders item-content slot', () => {
        const slotTemplate = `
        <template v-slot:item-content="{ item }">
            <div> {{ item }} </div>
        </template>`;

        render(TmpList, { props: { listItems }, slots: { 'item-content': slotTemplate } });
        listItems.forEach(li => {
            expect(screen.getByText(li)).toBeTruthy();
        })
    })

    // Test list item interaction
    test('should select item when clicked and is selectable', async () => {
        const slotTemplate = `
        <template v-slot:item-content="{ item }">
            <div> {{ item }} </div>
        </template>`;

        render(TmpList, { props: { listItems, selectable: true }, slots: { 'item-content': slotTemplate } });
        const firstItem = screen.getByText(listItems[0]);
        await fireEvent.click(firstItem);
        expect(firstItem.classList).toContain('selected-item')
    })
})

The component:

<template>
    <ul>
        <li v-for="(item, index) in listItems" :key="`list-item-${index}`" @click="onItemClick(index)"
            class="rounded mx-2" :class="{
                'selected-item bg-secondary-600/20 text-secondary':
                    selectedIndex == index,
                'hover:bg-zinc-200/30': selectable,
            }">
            <slot name="item-content" :item="item"></slot>
        </li>
    </ul>
</template>

<script setup lang="ts">
import { computed, ref } from "vue";
export interface Props {
    listItems: any[];
    selectable?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
    selectable: false,
});

const selectedIndex = ref<number>(-1);

const onItemClick = (index: number) => {
    if (props.selectable) {
        selectedIndex.value = index;
    }
};

</script>

This is the full error I get in the terminal:

TestingLibraryElementError: Found multiple elements with the text: foo

Here are the matching elements:

Ignored nodes: comments, script, style
<div>
  foo
</div>

Ignored nodes: comments, script, style
<div>
  foo
</div>

(If this is intentional, then use the `*AllBy*` variant of the query (like `queryAllByText`, `getAllByText`, or `findAllByText`)).

Ignored nodes: comments, script, style
<body>
  <div>
    <ul
      data-v-96593be0=""
    >

      <li
        class="rounded mx-2"
        data-v-96593be0=""
      >

        <div>
          foo
        </div>

      </li>
      <li
        class="rounded mx-2"
        data-v-96593be0=""
      >

        <div>
          bar
        </div>

      </li>

    </ul>
  </div>
  <div>
    <ul
      data-v-96593be0=""
    >

      <li
        class="rounded mx-2 hover:bg-zinc-200/30"
        data-v-96593be0=""
      >

        <div>
          foo
        </div>

      </li>
      <li
        class="rounded mx-2 hover:bg-zinc-200/30"
        data-v-96593be0=""
      >

        <div>
          bar
        </div>

      </li>

    </ul>
  </div>
</body>
 ❯ Object.getElementError node_modules/@testing-library/dom/dist/config.js:37:19
 ❯ getElementError node_modules/@testing-library/dom/dist/query-helpers.js:20:35
 ❯ getMultipleElementsFoundError node_modules/@testing-library/dom/dist/query-helpers.js:23:10
 ❯ node_modules/@testing-library/dom/dist/query-helpers.js:55:13
 ❯ node_modules/@testing-library/dom/dist/query-helpers.js:95:19
 ❯ src/components/__tests__/SUList.spec.ts:54:33
     52|
     53|         render(TmpList, { props: { listItems, selectable: true }, slots: { 'item-content': slotTemplate } });
     54|         const firstItem = screen.getByText(listItems[0]);
       |                                 ^
     55|         await fireEvent.click(firstItem);
     56|         expect(firstItem.classList).toContain('selected-item')

I know I could use the getAllByText method to query multiple items, but in this test I am expecting only one element to be found. The duplication is related to the rendering in the test, not an issue with the actual component.

Am I doing something wrong when writing the tests? Is there a way to ensure that each render will be executend independetly of renders from other tests?


Solution

  • Every render() returns @testing-library's methods (query* /get* /find* ) scoped to the template being rendered.

    In other words, they normally require a container parameter, but when returned by render, the container is already set to that particular render's DOM:

    it('should select on click', async () => {
      const { getByText } = render(TmpList, {
        props: { listItems, selectable: true },
        slots: { 'item-content': slotTemplate },
      })
    
      const firstItem = getByText(listItems[0])
      expect(firstItem).not.toHaveClass('selected-item')
    
      await fireEvent.click(firstItem)
      expect(firstItem).toHaveClass('selected-item')
    })
    

    Notes:

    • fireEvent is no longer returning a promise in latest versions of @testing-library. If, in the version you're using, still returns a promise, keep the async - only true for @testing-library/react.
    • you want to get to a point where you no longer need to import screen in your test suite

    If you find yourself writing the same selector or the same render parameters multiple times, it might make sense to write a renderComponent helper at the top of your test suite:

    describe(`<ListItems />`, () => {
      // define TmpList, listItems, slotTemplate
      const defaults = {
        props: { listItems, selectable: true },
        slots: { 'item-content': slotTemplate },
      }
      const renderComponent = (overrides = {}) => {
        // rendered test layout
        const rtl = render(TmpList, {
          ...defaults,
          ...overrides
        })
        return {
          ...rtl,
          getFirstItem: () => rtl.getByText(listItems[0]),
        }
      }
    
      it('should select on click', async () => {
        const { getFirstItem } = renderComponent()
        expect(getFirstItem()).not.toHaveClass('selected-item')
    
        await fireEvent.click(getFirstItem())
        expect(getFirstItem()).toHaveClass('selected-item')
      })
    
      it('does something else with different props', () => {
        const { getFirstItem } = renderComponent({ 
          props: /* override defaults.props */
        })
        // expect(getFirstItem()).toBeOhSoSpecial('sigh...')
      })
    })
    

    Note I'm spreading rtl in the returned value of renderComponent(), so all the get*/find*/query* methods are still available, for the one-off usage, not worth writing a getter for.