vuejs / vue-test-utils

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

How to test an object property of a child functional component (inside a non-functional component)? #1507

Open matanshukry opened 4 years ago

matanshukry commented 4 years ago

Version

1.0.0-beta.33

Reproduction link

https://codesandbox.io/s/vue-template-yy6q9

Steps to reproduce

  1. Create a component that uses a functional component with a property of type object
  2. Use said functional component in template, and pass the property as an object
  3. (3.A) Try and use the .props() method - can't use it since the child component is a functional component
  4. (3.B) Try and use the .attributes() method. It will return the object as string "[object Object]", so you can't test the actual value of it.

What is expected?

To have a way to test the value passed as an object property to a child functional component

What is actually happening?

There is no way to test the value passed as an object property to a child functional component

lmiller1990 commented 4 years ago

How does your functional child use the prop? Presumably to render in the DOM (that's all functional components do).

You can test it by using mount and assert against the rendered DOM, not the passed props/attrs.

matanshukry commented 4 years ago

I tried to do that, but then I need to check the "href" attribute of the result; which means the route need to be resolved and I also need to put the app routes on it.

I tried to go that way as well, but I can't get it to work either; one of the nested components (BLink from vue-bootstrap specifically) uses this.$router , which is undefined when installing VueRouter on the localVue.

It works if I'm installing VueRouter on the global Vue, but this mess up mocking $route for other tests (as you probably know).

Code on using local vue that ends up with an undefined this.$router:

import VueRouter from "vue-router";
import router from "@/router";

const localVue = createLocalVue();
localVue.use(VueRouter);

// import Vue from 'vue';
// Vue.use(VueRouter);

const wrapper = mount(MyComponent, { localVue, router });
console.log("Html: ", wrapper.html());

As said, only when the 2 commented out lines are "commented-in" the result .html() will contain a valid href (and not just "#")

matanshukry commented 4 years ago

I managed to create a small sandbox code that shows the problem with the global vue (using mount like you suggested @lmiller1990 ): https://codesandbox.io/s/vue-template-g6wqb

Currently if you run the test it will fail. If you go to MyComp.test.js you'll see a commented line (installing VueRouter globally). Uncomment it and the test will succeed.

lmiller1990 commented 4 years ago

This should work. I will debug this now.

lmiller1990 commented 4 years ago

I looked into this. I tried <b-nav-item :to="{ name: 'home' }">home</b-nav-item> using Jest locally and in an actual browser and both give "#" as the route. I don't think this is a bug in VTU, but the bootstrap nav component (or the usage).

I verified this here: https://gist.github.com/lmiller1990/f6b9f6c5fa28e4e6cb4089b5765cb4c4

If you run that in a browser you will see the normal <router-link> works fine, but <b-nav-link>

Original example from here: https://router.vuejs.org/guide/essentials/named-routes.html

You may need to investigate BootstrapVue and how it works with the router. On first look, their link component does not seem to check if the to prop is an object: https://github.com/bootstrap-vue/bootstrap-vue/blob/8241644477b174042bb163ba1741c3066165d9f9/src/components/link/link.js#L101

matanshukry commented 4 years ago

@lmiller1990 I'm confused - you do realize the codesandbox link I gave does work if you use VueRouter globally, even with VTU? That is, it does NOT give "#".

I haven't actually tried your gist, but I'm pretty sure it's not working due to case sensitive.

That is, the route is 'home' (lowercase 'h')

    { path: '/', name: 'home', component: Home },

while the nav item name is Home (uppercase 'H')

        <b-nav-item :to="{ name: 'Home' }">Home</b-nav-item>

Unlike the router links which all uses lowercase, matching the routes.

Regarding vue bootstrap link: the important part (regarding my issue) is actually computeTag and not computeHref. That is, computeTag will return the acutal tag used. If the tag is 'router-link' (which it should be), it will pass the to prop to it and there's no problem.

However, when installing VueRouter locally (unlike globally), the $router is undefined and hence the computeTag will return 'a' rather than 'router-link'. Line:

https://github.com/bootstrap-vue/bootstrap-vue/blob/8241644477b174042bb163ba1741c3066165d9f9/src/utils/router.js#L93

p.s. To clarify, I know all that by debugging locally

lmiller1990 commented 4 years ago

I think I missed that you had the global usage commented out. I see what you are explaining now with the global router.

One you you forgot was localVue.use(BootstrapVue). Your localVue doesn't know about the bootstrap components.

I'll investigate some more. Do you have a real repo by any chance? It is very difficult to debug on code sandbox - constantly I get random errors like "record is undefined" for no reason. It not, no problem, I can copy paste from the sandbox.

lmiller1990 commented 4 years ago

This is so weird. Any non :to prop renders as object Object. What is going on with :to?

lmiller1990 commented 4 years ago

I dug deeper.

This is incorrectly returning when using localVue. For some reason BootstrapVue is not finding the locally installed router? Is it using the global vue instance instead maybe? There is a function in BoostrapVue that does isRouterLink(tag) and returns true if the tag is a - which it is in this example.

I think this particular bug is unique to BoostrapVue. As you demonstrated, regular functional components receive their props (as object Object).

BootstrapVue seems to do a bunch of stuff to integrate with VueRouter, something is going on there. This will require some more digging into BootstrapVue, I think. Do you know their codebase well? Maybe we can work together on this.

matanshukry commented 4 years ago

@lmiller1990 First let me say thanks for looking into it!

And I have a code base but it's only local on my computer and it's not something I can share. I can debug any place if there's something specific you want me to.

from my debugging, the code you referred to in isRouterLink is not the problem. The problem is the tag that's passed to it.

The problem is with computeTag. Specifically the part where it's checking thisOrParent.$router which is undefined, which results in a the ANCHOR_TAG rather than the router-link tag.

I can debug a bit more to see why the $router is null, but I'm not sure when it's suppose to be "filled". One thing that could be is that when installing Vue-Router locally, this.$router is null at the render() hook method, which is where computeTag is being used. Not sure how to check ti though

lmiller1990 commented 4 years ago

Sorry, by code base I meant the BootstrapVue code base.

Something weird is definitely happening where BootstrapVue doesn't know about the mock router. I don't know if I can look too much more into it, but if anything comes to mind I will post it here 🤔

The fact it works w/ the global router is super weird to me. My guess is BootstrapVue does import Vue somewhere and it is getting the non local vue, but global one.

matanshukry commented 4 years ago

@lmiller1990 The BootstrapVue code base is open source on github: https://github.com/bootstrap-vue/bootstrap-vue

Also, the code that calls computeTag() is simply passing "this", where this.$router is undefined as well

matanshukry commented 4 years ago

@lmiller1990 Got a simpler code for you to check:

https://codesandbox.io/s/vue-template-bj9he

I pretty much copied the 'BLink' and 'BNavItem' code in there, but reduced a lot of code that's unrelated.

So now a router-link will always be created, but without the global router the path will either be "#" (incorrect) or "/hello" (correct).

lmiller1990 commented 4 years ago

^ Yep thanks, I ended up making my own case just like this. I think we need to do a deep dive in the bootstrap-vue codebase and explore how it is using vue router.

matanshukry commented 4 years ago

@lmiller1990 What? are you sure you looked at the code I provided?

To be clear, the code I provided doesn't use Bootstrap-vue at all. There are leftovers in there, but it's not part of the test.

Here, I even removed the import lines and the package.json usage, so now there's no mention to bootstrap-vue other than comments: https://codesandbox.io/s/vue-template-2upde

lmiller1990 commented 4 years ago

👀

I'm silly, I assumed something incorrectly without properly looking at the code - I was looking at some left overs from previously.

This is very good minimal repro. I'll make a repo locally with this and debug a bit - sandbox does not appear to expose node_modules :(

I guess we are doing something incorrectly with functional components?

matanshukry commented 4 years ago

@lmiller1990 all good :)

I'm not too familiar with functional components to be honest. I suspected as well, which is why I've tried to make MyNav non functional, but since I'm not familiar with it enough it threw some errors I didn't know how to handle. I might give it another try tomorrow

lmiller1990 commented 4 years ago

I don't see anywhere in our codebase where we do anything special with functional components except for the shallowMount hacks. This reproduction is very good though, let's dig a bit deeper, probably with a log console.logs and debug statements. Thanks @matanshukry!

matanshukry commented 4 years ago

@lmiller1990 Made it even smaller now:

https://codesandbox.io/s/vue-template-v2urw

Changes:

So it's either something with functional or something with the short code in MyNav.js. Hopefully it helps more!

PVince81 commented 3 years ago

if functional components are treated like or are actually DOM nodes in a shallow mount scenario, then it's not possible to store objects in DOM node attributes.

I've tried this in the browser with a DOM node:

image

so maybe need to use another approach to store the attributes ? dataset ?

PVince81 commented 3 years ago

possible workaround, use a stub like this:

const wrapper = shallowMount(ComponentToTest, {
    ...
    stubs: {
        TheInnerComponent: {
            props: {
                stringArgs: {
                    type: String,
                },
                objectArgs: {
                    type: Object,
                },
            },
            template: '<div/>',
        },
    },
})
...
console.log(wrapper.findComponent(TheInnerComponent).props)

In this case you can simply use "props".

Drawback is that you need to redefine all props/attributes that you want to test against.

I guess if a fix is possible in vue-test-utils it would need to do something similar internally for functional components.

lmiller1990 commented 3 years ago

Probably best to just use mount at this point, depending on what you are trying to test.

Happy to accept a PR if you find something - I've spend a decent amount of time on this, and have a ton of other issues to triage, so I can't look into this one right now.

kiranparajuli589 commented 3 years ago

hope this helps somebody

Component

<template>
<oc-button
id="files-list-not-found-button-reload-link"
type="router-link"
appearance="raw"
:to="publicLinkRoute"

<translate>Reload public link</translate>
</oc-button>
</template>
<script>
export default {
..
computed: {
publicLinkRoute() {
const item = this.$route.params.item.replace(/^\/+/, '')
return {
name: 'files-public-list',
params: {
item: item.split('/')[0]
}
}
}
}
}
</script>

Spec File


function getMountedWrapper(route) {
return mount(NotFoundMessage, {
localVue,
store: store,
stubs: stubs,
mocks: {
$route: route
}
})
}
function publicLinkRoute(item) {
return {
name: 'files-public-list',
params: {
item: item
}
}
}
describe("component", () => {
it('should have property route to files public list', () => {
// remember to use mounted wrapper
const wrapper = getMountedWrapper(publicLinkRoute('parent/sub'))
const reloadLinkButton = wrapper.find(selectors.reloadLinkButton)
// if you log reloadLinkButton HTML: o/p is like
// <oc-button-stub type="router-link" size="medium" to="[object Object]" variation="passive" appearance="raw" justifycontent="center" gapsize="medium" id="files-list-not-found-button-reload-link"><translate-stub>Reload public link</translate-stub></oc-button-stub>
  // but you can access the `[object Object]` value using `props()` method.
  expect(reloadLinkButton.props().to.name).toBe('files-public-list')
  expect(reloadLinkButton.props().to.params.item).toBe('parent')
})

})