Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Named slots' content is not generated for stubbed components via shallowMount #1307

Closed
chriswa opened this issue Sep 12, 2019 · 19 comments
Closed

Comments

@chriswa
Copy link

chriswa commented Sep 12, 2019

What problem does this feature solve?

Higher-order components make use of named slots. It's possible to test higher-order components via shallowMount because named slot content can be mocked, but it's not possible to test components which consume higher-order components because their generated named slot output is not available in the stubbed content.

#782 added support for outputting default slot content, but not named slots.

To be clear, an example of a component which needs this support is:

<!-- myComponent.vue -->
<template>
  <HigherOrderComponent foo="bar">
    default slot content
    <template v-slot="header">header slot content</template>
    <template v-slot="footer">footer slot content<HigherOrderComponent foo="waldo"/></template>
  </HigherOrderComponent>
</template>

Currently, this is stubbed out to only:

<higherordercomponent-stub foo="bar">default slot content</higherordercomponent-stub>

The end user experience should be that components which generate named slot content "just work" when testing with shallowMount.

What does the proposed API look like?

The API would not change.

@runarberg
Copy link

Related to #1261?

@chriswa
Copy link
Author

chriswa commented Sep 17, 2019

@runarberg I think this is a different issue. The "old syntax" slots render, but they do not render with any kind of wrapper, so they appear to be in the default slot and it's not possible for a test to confirm whether content was rendered to the correct slot.

@chriswa
Copy link
Author

chriswa commented Sep 19, 2019

To be clear, to properly test a component, its shallowMount rendering should be something like this:

<higherordercomponent-stub foo="bar">
  default slot content
  <template-stub v-slot="header">header slot content</template-stub>
  <template-stub v-slot="footer">footer slot content<higherordercomponent-stub foo="waldo"/></template-stub>
</higherordercomponent-stub>

Not simply this:

<higherordercomponent-stub foo="bar">
  default slot content
  header slot content
  footer slot content<higherordercomponent-stub foo="waldo"/>
</higherordercomponent-stub>

@jcmillett
Copy link

I was hoping #1261 would fix the issue we were having with moving our codebase to the new slot syntax, but that didn't resolve the issue. Moving to the new slot syntax breaks a lot of our existing unit tests.

It looks like this would allow us to use the new syntax while keeping our existing unit tests as-is. Is there a timeline of when this would be available?

@lmiller1990
Copy link
Member

I believe the new slot syntax is now supported @jcmillett - this is a differerent issue unique to shallowMount, no?

@lmiller1990
Copy link
Member

I have closed the PR that supposedly solved this problem.

We will revisit shallowMount and how it should render things for the Vue 3 integration. At this point, I don't think we will change how shallowMount is working.

I will make a new issue when we revisit shallowMount for v3 integration and invite everyone's input that point.

@danieltian
Copy link

For those looking for a solution now, manually stubbing out the component seems to render the child slots:

const wrapper = shallowMount(MyComponent, {
  stubs: { HigherOrderComponent }
}

expect(wrapper.text()).toContain('header slot content');
expect(wrapper.text()).toContain('footer slot content');

@lmiller1990
Copy link
Member

This may work but you end up stubbing so much you aren't really testing anything. The best way to test slots is by using mount, you'll get the same behavior as production and avoid writing tests that give you false confidence.

@danieltian
Copy link

danieltian commented Apr 20, 2020

but you end up stubbing so much

That's the intention. To use OP's example, I'm writing a unit test for myComponent.vue, so I want HigherOrderComponent to be stubbed out. I also want to test that myComponent is sending down the correct template data to HigherOrderComponent.

Right now, the default template is being rendered in the stub with shallowMount, but named templates are not. This doesn't make sense. Manually stubbing out HigherOrderComponent fixes it so that named templates are rendered in the stub as well.

Using mount means that the unit test is no longer a unit test. If myComponent was the root component, by using mount the entire app is instantiated. This is explicitly mentioned in the official docs:

https://vue-test-utils.vuejs.org/guides/common-tips.html

In unit tests, we typically want to focus on the component being tested as an isolated unit and avoid indirectly asserting the behavior of its child components.

In addition, for components that contain many child components, the entire rendered tree can get really big. Repeatedly rendering all child components could slow down our tests.

Vue Test Utils allows you to mount a component without rendering its child components (by stubbing them) with the shallowMount method

@lmiller1990
Copy link
Member

Important part:

Vue Test Utils allows you to mount a component without rendering its child components (by stubbing them) with the shallowMount method

At least, this was the intention. To stub the children.

Unit testing a single component might make sense sometimes, but if you are testing with slots, it doesn't make a ton of sense to test it as a single unit - it will never be used a single unit, but with other components and children. Just use mount to test it. Using stubs and shallow is really just making more work for yourself - but each to their own.

We will improve the docs before Vue 3 integration (over the next few weeks). If you are interested, I will add you as a reviewer to a PR that discusses testing slots, since you have an interest in this topic. There is definitely different ways to accomplish the same thing. We should mention both approaches, and their pros and cons. Is this something you'd like to be involve with?

@danieltian
Copy link

danieltian commented Apr 21, 2020

it will never be used a single unit, but with other components and children. Just use mount to test it.

mount no longer makes it a unit test, it becomes an integration test, and like all integration tests, everything that's instantiated must be set up properly. If like OP I'm testing the root component and I want to verify in a unit test that the header, footer, and body content is being passed to <HigherOrderComponent> properly, using mount means I have to ensure that every descendant component has the data it needs to render properly. This might mean mocking out API endpoints or any other setup needed, just to test the root component. In other words:

<root-component>
  <child-component-1>
    <child-component-2>
      ...
      <child-component-10> <!-- if this does an AJAX call, I have to mock it -->
        <child-component-11> <!-- if this does an AJAX call, I have to mock it -->

child components 10 and 11 are very far removed from the root component, but by using mount, I now have to mock out endpoints that have nothing to do with the root component. It becomes the same problem as instantiating the entire app and setting everything up for that to happen, just to test one thing.

Using stubs and shallow is really just making more work for yourself

shallowMount works exactly as expected, except when it comes to rendering named slots. I believe you're saying that because the slot is rendered in the child and the child is stubbed, so the slot shouldn't be rendered, and that would make sense, except that the default slot is rendered. This is what I mean by it doesn't make sense; why would the default slot get rendered, but not named slots? If stubbed children shouldn't render anything, then why is the default slot getting rendered?

Ultimately, what we need is a way to unit test a component that passes down named slot templates to a stubbed child. It's already possible to check for attributes() and props() passed down to a child using shallowMount, but currently nothing exists for templates, so we have no choice but to check the contents of the child stub. Ideally, there'd be something like wrapper.templates() so that we don't have to rely on the child rendering the slot template, but for now the stubs: { Component } workaround works.

@lmiller1990
Copy link
Member

lmiller1990 commented Apr 21, 2020

then why is the default slot getting rendered?

Because shallowMount has been pretty buggy from day 1. What you are saying is correct; we should be stubbing the default slot as well (like Enzyme, which this was based on). If you are rendering the child components, it's not really testing in isolation anymore. Why people are obsessed with isolating components is beyond me. Anyway...

I see two options:

  • we change the current behavior to be "correct" and stub everything (as shallow mount in enzyme does)
  • we "shallow" everything including their stubs. A few people have tried this, I can't remember why no-one has managed to solve it, I for one have spent quite a few hours on this and had no luck. Maybe someone smarter than me can fix this.

In the second case if I have

<template>
  <Foo>
    <Bar>
      <Qux>
    </Bar>
  </Foo>
</template>

What do I get?

<foo-stub>
  <bar-stub>
    <qux-stub />
  </bar-stub>
</foo-stub>

Or no <qux-stub />?

Before even trying to implement this, we should be very clear one what the goal is.

@danieltian
Copy link

danieltian commented Apr 21, 2020

Why people are obsessed with isolating components is beyond me.

I already addressed that, but to quote @souldzin, who put it much better than I can: #1498 (comment)

mount and shallowMount are different levels of testing, each with their own pro's and con's. TDD'ing from the perspective of a user with mount is awesome (and my personal preference), but it isn't free. There's some application states which are difficult to get to in full integration (e.g. step 3 of a highly interactive single page form). Even if I build a test helper to get there, my tests suite is going to be doing a lot of redundant work to trigger this state in an integrated environment. This is where the profitability of shallowMount unit tests can come in.

What do I get? <foo-stub><bar-stub><qux-stub></bar-stub></foo-stub>?

Yes.

I still don't really see what's so bad about using stubs or jest.mock

For the sake of example, you have 1 million unique child components, each of which does an AJAX call in their created() hook that takes 2 seconds to complete. Do you stub every one out? And after you do, what's the difference between mount and shallowMount if you've stubbed everything out except the one component you're testing?

@lmiller1990
Copy link
Member

lmiller1990 commented Apr 21, 2020

We are investigating the rendering of stubbed slots for our Vue 3 integration. I still don't see why slots should get special treatment - shallow should stub everything, instead of this weird selector slot behavior.

If you have 1 million unique components then your component is not testable. Write your code in a testable manner. Use mount. What's the alternative? 1 million isolated specs with shallowMount?

Clearly I am not the target user for this feature, so I can't fully understand. Either way, I am way pasting trying to push this issue. Let's move on.


We will find something for this use case in the Vue 3 integration, since this is something (some) users feel strongly about. If you feel strongly about this issue, you are more than welcome to attempt to implement the above behavior in this code base, too.

@danieltian
Copy link

If you have 1 million unique components then your component is not testable. Write your code in a testable manner.

I said "for the sake of example", the focus here is not the number of components. You said "just stub out the troublesome component", and I'm saying that it could be 2, 5, a dozen, or any arbitrary number of components that need to be stubbed out when using mount, with the worst case being having to stub out every other component except for the one being tested. For large apps with deep trees, this can lead to stubbing out large parts of the app just to write a "unit test", which is starting with an integration test and stubbing out everything that's causing problems until it effectively becomes a unit test.

Clearly I am not the target user for this feature, so I can't fully understand.

The user story is, "as a developer writing a unit test, I want to verify that my component is passing the proper data to a child component." Data is defined as anything and everything that can be passed to the child component, which AFAIK is props, attributes, and templates. So given this example:

<my-component>
  <!-- other stuff that my-component does with its data -->
  <child-component :p1="data.p1" :p2="data.p2" data-prefix="prefix">
    <template>{{ data.defaultSlot }}</template>
    <template #named>{{ data.namedSlot }}</template>
  </child-component>
</my-component>
it('test my component', () => {
  const data = {
    p1: 'p1',
    p2: 'p2'
    defaultSlot: 'default slot text',
    namedSlot: 'named slot text'
  }
  const wrapper = shallowMount(MyComponent, {
    propsData: { data });
  })
  const child = wrapper.find(ChildComponent);

  // We can test for props on child stubs.
  expect(child.props('p1')).toBe(data.p1);
  expect(child.props('p2')).toBe(data.p2);
  // We can test for attributes on child stubs.
  expect(child.attributes('data-prefix')).toBe('prefix');
  // We can test for the default slot on child stubs.
  expect(child.text()).toContain(data.defaultSlot);

  // We CANNOT test for named slots on child stubs.
  expect(child.text()).toContain(data.namedSlot);
}

If you feel strongly about this issue, you are more than welcome to attempt to implement the above behavior in this code base, too.

It's already been done, but you closed it: #1309 From your comment in the PR:

I think stubbed children should not render their slots.

To reiterate the above example:

We can test that props are properly passed to children using .props().
We can test that attributes are properly passed to children using .attributes().
We can test that the default slot is properly passed to children using .text() or .html().

Without the stubs: { Component } workaround, we cannot test that named slots are passed properly. Whether stubs should render content inside them is really not the point here, it's that there's no other way to test slots otherwise in a unit test. If there was something like childWrapper.templates(), then I'm all for stubbing out the child completely, but until that happens, checking childWrapper.text() or childWrapper.html() remains the only way to verify slots. Using mount is not a solution because, once again, it turns a unit test into an integration test.

Either way, I am way pasting trying to push this issue. Let's move on.

Sure, my original comment was simply to provide a solution that works today. This issue has been reported multiple times and even has a PR for it, so it's definitely important to at least some of us, and we can't wait around for the Vue 3 integration.

@lmiller1990
Copy link
Member

lmiller1990 commented Apr 22, 2020

It wasn't just a case of "I didn't merge it". Looks like there was some conflict and failing tests here which were not resolved: #1309

I think we need to support both behaviors to make this work for everyone. Some people will expect a true shallow mount, others want stubbed components to render slots.

I am pretty focused on getting v1 of this lib released, vue-jest 4 and 5, and VTU next, so I cannot work on this right now. If you (or anyone else reading this) would like to take a stab at this (or anyone else) feel free to do so. What I'd like to see:

  • stub all the children as the default
  • support rendering slots of stubbed children. I don't know what the API should look like, perhaps
import { config } from '@vue/test-utils'

config.shallowMount = {
  renderSlots: true
}

Or

shallowMount(Foo, {
  stubbedSlots: true
})

I don't think it makes sense as a mounting option, since those are shared with mount, though.

Another alternative would be a shallowWithStubs method.

Either way, the API won't impact the implementation. Does this sound appropriate? I think this will let everyone write their tests however they like.

@LeonardoRick
Copy link

What is the final solution for this? I can't find those configurations on shallowMount

@lmiller1990
Copy link
Member

Try using mount - shallowMount has (always) been pretty buggy and error prone. If you have a specific component that is problematic, you could use stubs to get around it.

@Kobee1203
Copy link

Here's my solution for named slots content:
https://stackblitz.com/edit/vitejs-vite-w7qqun?file=tests%2FHelloWorld.test.ts

I took my inspiration from the stubComponentsTransformer.ts file:
https://github.com/vuejs/test-utils/blob/d7e3c2fb9b89592ae0db8f4806d332c8cea6c78e/src/vnodeTransformers/stubComponentsTransformer.ts#L91

I used config.plugins.createStubs to override the default and resolve all stubbed component slots.

vitest.setup.ts

import { createStub } from './tests/utils/stub';
import { config } from '@vue/test-utils';
import { defineComponent } from 'vue';

config.global.renderStubDefaultSlot = true;

config.plugins.createStubs = ({ name, component }) => {
  return defineComponent({
    ...createStub(name, component),
  });
};

stub.ts (I had to copy some functions from stubComponentsTransformer.ts because they are not exposed)

import type {
  ComponentObjectPropsOptions,
  ComponentOptions,
  ComponentPropsOptions,
  ConcreteComponent,
  VNodeTypes,
} from 'vue';
import { h } from 'vue';

/**
 * @see https://github.com/vuejs/test-utils/blob/d7e3c2fb9b89592ae0db8f4806d332c8cea6c78e/src/utils/vueShared.ts#L1
 */
const cacheStringFunction = <T extends (str: string) => string>(fn: T): T => {
  const cache: Record<string, string> = Object.create(null);
  return ((str: string) => {
    const hit = cache[str];
    return hit || (cache[str] = fn(str));
  }) as any;
};

/**
 * @see https://github.com/vuejs/test-utils/blob/d7e3c2fb9b89592ae0db8f4806d332c8cea6c78e/src/utils/vueShared.ts#L18
 */
const hyphenateRE = /\B([A-Z])/g;
const hyphenate = cacheStringFunction((str: string): string => {
  return str.replace(hyphenateRE, '-$1').toLowerCase();
});

/**
 * https://github.com/vuejs/test-utils/blob/d7e3c2fb9b89592ae0db8f4806d332c8cea6c78e/src/utils.ts#L131
 */
const isComponent = (component: unknown): component is ConcreteComponent =>
  Boolean(
    component &&
      (typeof component === 'object' || typeof component === 'function')
  );

/**
 * @see https://github.com/vuejs/test-utils/blob/d7e3c2fb9b89592ae0db8f4806d332c8cea6c78e/src/vnodeTransformers/stubComponentsTransformer.ts#L45
 */
const normalizeStubProps = (props: ComponentPropsOptions) => {
  // props are always normalized to object syntax
  const $props = props as unknown as ComponentObjectPropsOptions;
  // eslint-disable-next-line unicorn/no-array-reduce
  return Object.keys($props).reduce((acc, key) => {
    if (typeof $props[key] === 'symbol') {
      return { ...acc, [key]: [$props[key]?.toString()] };
    }
    if (typeof $props[key] === 'function') {
      return { ...acc, [key]: ['[Function]'] };
    }
    return { ...acc, [key]: $props[key] };
  }, {});
};

type SlotProps = Record<string, Record<string, unknown>>;

export const createStub = (
  name: string,
  component: VNodeTypes,
  slotProps: SlotProps = {}
) => {
  const anonName = 'anonymous-stub';
  const tag = name ? `${hyphenate(name)}-stub` : anonName;

  const props = isComponent(component)
    ? (component as ConcreteComponent).props
    : {};

  return {
    name: name || anonName,
    props,
    setup(props, { slots }) {
      return () => {
        const stubProps = normalizeStubProps(props);
        const resolvedSlots = slots
          ? Object.keys(slots).map((k) => {
              const slot = slots[k];
              if (slot) {
                return slot(slotProps[k] ?? {});
              }
            })
          : [];
        return h(tag, stubProps, resolvedSlots);
      };
    },
  } satisfies ComponentOptions;
};

We can then use shallowMount and override some component stubs to specify slot props.

const wrapper = shallowMount(HelloWorld, {
      props: {
        msg: 'the message',
      },
      global: {
        stubs: {
          Parent: createStub('Parent', Parent, {
            child: { message: 'Slot message' },
          }),
        },
      },
    });

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging a pull request may close this issue.

8 participants