3

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?

1 Answer 1

2

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.

Sign up to request clarification or add additional context in comments.

2 Comments

Thank you for the help, after playing around with the code and implementing your suggestions everything is now working as I expected. As a note for others, I am using @testing-library/vue, which in its' latest version (6.6.1) still seems to return promises when using fireEvent.
I use it for both Vue and React and I rely on my IDE to tell me where async is redundant. I'm pretty sure in the react package they've removed the promise but I have no idea if that's also planned for vue. Updated the answer.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.