vuejs / vue-test-utils

Component Test Utils for Vue 2
https://vue-test-utils.vuejs.org
MIT License
3.57k stars 669 forks source link

Provide simple way to find/filter nodes based on text content #960

Open doits opened 6 years ago

doits commented 6 years ago

What problem does this feature solve?

It should be possible to find nodes by the text they contain. For example when I want to click the logout link in my component:

<template>
  <a href="/my_account">My Account</a>
  <a @click="logout">Logout</a>
</template>

Currently, I can do this:

wrapper.findAll("a").at(1).trigger("click")

I dislike using findAll.at because requires the test to know the order of the links. For my test I don't care about the order, but I just want to click on the link with the text "Logout".

If later the component changes and the order is rearranged or a new a-tag is inserted, I have to change my test, even though it has nothing to do with the order.

Second option is to use findAll with a filter:

wrapper.findAll("a").filter(node => node.text().match(/Logout/)).at(0).trigger("click")

This works, but is really cumbersome to write for such a simple thing. I would expect an easier method to be provided.

What does the proposed API look like?

I come from ruby where it is simply an option to the find or findAll call:

wrapper.findAll("a", { text: "Logout" }) // find all `a` which contain the text
wrapper.find("a", { text: "Logout" }) // find the first `a` which contains the text

There could be a second option exactText which only finds element where the text is exactly the one provided.

wrapper.find("a", { exactText: "Log" }) // will not find `<a>Logout</a>`

wrapper.find("a", { text: "Log" }) // will find `<a>Logout</a>`

A possible way of implementing this with the current provided methods would be:

wrapper.find("a", { text: "Log" })
==>
wrapper.findAll("a").filter(node => node.text().match(/Log/)).at(0)

wrapper.find("a", { exactText: "Log" })
==>
wrapper.findAll("a").filter(node => node.text() == "Log").at(0)

Not sure if there is a more efficient method though.

I think it would be handy to have this filtering by text provided by vue-test-utils directly, since it makes writing tests much easier and error prone.

38elements commented 6 years ago

@eddyerburgh I think this example is not common. I think that the element usually has attributes. I think CSS Selector is enough. This feature is not essential. If it is necessary to change many same CSS Selector, it will resolve by abstracting CSS Selector. I think this feature is unnecessary.

doits commented 6 years ago

@38elements Thanks for your feedback. I think it comes down to the way how components are tested if such extenstions to the finders are common/meaningful or not.

I like to stay as close to what the normal user sees. This means when I want to test a click on the logout button I don't want to write in my test:

Click on the button with the class logout

or

Click on the button with the data-logout attribute

Because a normal user does not decide on the class or attribute which button to click.

I'd rather write in my test:

Click on the button with the text 'Logout'

Because this is what a user should be able to do. He does not see any class or attribute.

For me this makes more sense because it stays closer to what the user actually sees and does and not on some hidden attributes or classes.

Actually this is my real example why I proposed this syntax:

<template>
  <div class="buttonlogout">
    <a
      href="#"
      @click.prevent="toggleOpen"
    >Logout
      <i class="fas fa-caret-down" />
    </a>
    <div
      v-if="open"
      class="modal-container"
    >
      <p>Do you really want to log out?</p>
      <button
        class="button primary"
        @click.prevent="logoutClose"
      >
        Logout
      </button>
    </div>
  </div>
</template>

Of course I could test here (by using the primary class)

Click on the primary button

... but for me this is a different test than ...

Click on the logout button

... which I actually want to write! I want to test the logout button (which is defined by the text "Logout" for a normal user).

Besides it might happen that I change the primary button later to be a different one than the logout button, so I would have to refactor my "click on the logout button" spec just because I changed some classes – which feels wrong to me. The logout button would be the same then.

Maybe I'm a nitpicker on that difference, but in ruby I have come a good way along with such tests which even survived refactoring whole components by testing what the user sees and not hidden attributes or classes.


Anyway, I can do this already with the helper functions I wrote in the OP (and I do), but I'd feel having such functionality implement in vue-test-utils directly would also benefit other users.

The implementation could also be different, eg by extending the css selectors like wrapper.find("button:contains(Logout)"), if this feels better.

38elements commented 5 years ago

I think that this is necessary.

eddyerburgh commented 5 years ago

Yes, I think we should add support to find components based on text. Tests that use text as selectors can be more durable to refactoring, because text is (sometimes) less likely to change than class names/ DOM attributes.

dasDaniel commented 5 years ago

I think a common use for this may be (as it is for me) testing pagination. I don't think dom attributes or styles or ids should be necessary to select a div with text 3. For my e2e, I use testcafe, and they have a selector like this Selector('.page__btn').withText('3')

yveyeh commented 5 years ago

I had several buttons on my modal and also had to do:

wrapper.findAll("button").filter(node => node.text().match(/Annuler/)).at(0).trigger("click")

But that seems too cumbersome for just testing that one button. Please, has support been added for this? I mean if there's a shorter way to do it now as suggested by @doits?

denisinvader commented 5 years ago

Is anybody working on this? I could implement it

eddyerburgh commented 5 years ago

@denisinvader no one is working on it, it would be great if you could :)

dasDaniel commented 5 years ago

For anyone looking to use the feature before it gets implemented, you can also implement a helper function like this

function withWrapperArray(wrapperArray) {
  return {
    childSelectorHasText: (selector, str) => wrapperArray.filter(i => i.find(selector).text().match(str)),
    hasText: (str) => wrapperArray.filter(i => i.text().match(str)),
  }
}

usage:

// find first element that that has class name `.filter__name` with a textvalue of `Vehicles`
withWrapperArray(wrapper.findAll('.filter__name')).hasText('Vehicles').at(0);

// find an element that has a child with matching  text content 
withWrapperArray(wrapper.findAll('.filter')).childSelectorHasText('label', 'Saab').at(0)
// get the input that's a sibling of the matched label
withWrapperArray(wrapper.findAll('.filter')).childSelectorHasText('label', 'Saab').at(0).find('input')

this and more in gist https://gist.github.com/dasDaniel/d4fb8f0eb6f36acdbe7b1ce66c034949

viT-1 commented 4 years ago

@dasDaniel solution, but typescript variation (for ts-jest tests):

import { WrapperArray } from '@vue/test-utils/types';
import Vue from 'vue';

function withWrapperArray(wrapperArray: WrapperArray<Vue>): Record<string, Function> {
    return {
        childSelectorHasText: (
            selector: string,
            str: string,
        ): WrapperArray<Vue> => wrapperArray
            .filter(i => i.find(selector).text().match(str)),

        hasText: (str: string): WrapperArray<Vue> => wrapperArray
            .filter(i => i.text().match(str)),
    };
}
exmaxx commented 4 years ago

@viT-1 Thanks for the snippet. It might be even better to drop the generic return type (i.e. drop Record<string, Function>).

That way Typescript will infer the return type from the returned object. Then the code completion (e.g. in Webstorm) will offer the methods hasText and childSelectorHasText correctly. And will also know that they return WrapperArray.

Modified code:

import Vue from 'vue'
import { WrapperArray } from '@vue/test-utils'

function withNameFilter(wrapperArray: WrapperArray<Vue>) {
  return {
    childSelectorHasText: (selector: string, str: string): WrapperArray<Vue> =>
      wrapperArray.filter((i) => i.find(selector).text().match(str)),

    hasText: (str: string): WrapperArray<Vue> =>
      wrapperArray.filter((i) => i.text().match(str)),
  }
}
ghost commented 4 years ago

+1 for adding this

dobromir-hristov commented 4 years ago

In VTU 2, you would be able to add these as plugins, or use https://github.com/testing-library/vue-testing-library

agonzalez0515 commented 4 years ago

+1 for adding this please!

ninoscholz commented 3 years ago

+1 for adding this. One more feature that would clear out some of the "data-test" tags around my project

hcunninghampk commented 3 years ago

@eddyerburgh I think this example is not common. I think that the element usually has attributes. I think CSS Selector is enough. This feature is not essential. If it is necessary to change many same CSS Selector, it will resolve by abstracting CSS Selector. I think this feature is unnecessary.

This example is totally common. This feature is essential, especially since elements are often styled at the component level and not given ids (by many, many developers) and classes are not unique enough.

dospunk commented 3 years ago

Just to add to the precedent for adding this feature, it already exists in vue-testing-library, see the second code block here.

zhao-li commented 2 years ago

Hey Folks,

Sorry in advance if I'm in the wrong place to ask this.

I got here because I was hoping to do something along the lines of what @doits alluded to: wrapper.find("button:contains(Logout)") from https://github.com/vuejs/vue-test-utils/issues/960#issuecomment-421082617

But I guess contains is not a valid CSS selector to use because I got this error:

SyntaxError: unknown pseudo-class selector ':contains(upload)'

However, this page in the documentation suggested that pseudo selectors are supported: https://vue-test-utils.vuejs.org/api/selectors.html

Is there a list of supported pseudo selectors? Or did I miss something?

Thank you for any guidance you can provide πŸ™

dobromir-hristov commented 2 years ago

Pretty sure you can do that with Vue testing library - https://github.com/testing-library/vue-testing-library

zhao-li commented 2 years ago

Pretty sure you can do that with Vue testing library - https://github.com/testing-library/vue-testing-library

Thank you @dobromir-hristov for the recommendation. I was hoping to not need another library, but that is a very tempting library to include. Thanks again for your help πŸ™

a-toms commented 2 years ago

Some simplified helpers. These a) don't require specifying a selector and b) include a friendly error handler for your tests:

getAllByText = (wrapper, text) => {
  /*
  Get all elements with the given text.
   */
  return wrapper.findAll("*").filter(node => node.text() === text)
}

getByText = (wrapper, text) => {
  /*
  Get the first element that has the given text.
   */
  const results = getAllByText(wrapper, text)
  if (results.length === 0) {
    throw new Error(`getByText() found no element with the text: "${text}".`)
  }
  return results.at(0)
}

As others have mentioned, this is similar in functionality to getByText() from https://testing-library.com/docs/bs-react-testing-library/examples/#getbytext.

aentwist commented 1 year ago

I do not think I agree with that. Selector is important.

  1. Looping over all elements instead of narrowing by a selector is much less efficient
  2. That solution would either a. Return the node with the text and all parents which by definition also contain the text - unwanted duplicates b Return only the node with the text but none of the parents since the text isn't theirs - can't get desired parent

For example with something like this - what are you hoping to do?

<button>
  <div class="d-flex justify-space-between">
    <svg>
      <use href="#mdiContentSave">
    </svg>
    Save
  </div>
</button>

I would also like to review the implementation proposed in the issue.

wrapper.find("a", { text: "Log" })
// sugar for
wrapper.findAll("a").filter(node => node.text().match(/Log/)).at(0)

wrapper.find("a", { exactText: "Log" })
// sugar for
wrapper.findAll("a").filter(node => node.text() == "Log").at(0)

First of all, we need to ditch the Array.at.

For wrapper.findAll (not shown), we should return an array, so don't index it - just return the whole thing. Array.filter would be correct here.

wrapper.findAll("a").filter(node => node.text().match(/Log/))

For wrapper.find, we have Array.find. So it should just be,

wrapper.findAll("a").find(node => node.text().match(/Log/))

find and findAll are sufficient since users can index findAll however they want if they need more flexibility.

Second of all, this isn't type-safe. Just do it up in TypeScript. We need some optional chaining.

wrapper.findAll("a")?.filter(node => node.text().match(/Log/))
wrapper.find("a")?.find(node => node.text().match(/Log/))

Last, and most importantly, we need to decide on the kind of matching - equals, contains, or regular expression. And whether multiple of these options will be provided. I think I'd suggest contains. I can't see the use case for a regex.

wrapper.find("a")?.find(node => node.text().includes("Log"))

On the proposed solution... chained calls will probably fit with the rest of the library better...

wrapper.findAll("a").withText("Log");

The problem with this isn't findAll, it's find...

Could be solved by adding methods instead of options or chaining...

wrapper.findAllWithText("a", "Log");
wrapper.findWithText("a", "Log");

Open to feedback on that...

If in the end the options idea is chosen and multiple matching methodologies are allowed, I highly recommend against using "text" for anything other than equals / exact match.

danielsballes commented 10 months ago

I know this is closed, but I left this comment for future reference. If you want to use, find by text follow these steps:

  1. create vitest.config.js file.
  2. In the object passed to your defineConfig method define test property.
  3. Create a setup file for your tests.
  4. Add setupFiles option with the path of setup file in your test property.
  5. Add a plugin to your VueWrapper in your setup file that allows you to find components by text.

Examples:

vitest.config.js

export default defineConfig({
    ...,
    test: {
        ...,
        setupFiles: ["tests/Vue/setup.js"],
    },
});

setup.js

const customBehaviors = () => {
    return {
        findComponentByText(searchedComponent, text) {
            return this.findAllComponents(searchedComponent)
                .filter((c) => {
                    return c.text() === text;
                })
                .at(0);
        },
        findElementByText(searchedElement, text) {
            return this.findAll(searchedElement)
                .filter((c) => {
                    return c.text() === text;
                })
                .at(0);
        },
    };
};

config.plugins.VueWrapper.install(customBehaviors);

example.test.js

describe("Example", () => {

    it("disables the confirm button when disabled is true", async () => {
        const wrapper = mount(Component);

        const searchedComponent = wrapper.findComponentByText({ name: "SearchedComponent" }, "CONFIRM");
    });
});