跳至内容

存根和浅层挂载

Vue 测试工具提供了一些用于存根组件和指令的进阶功能。存根是指用一个不执行任何操作的虚拟组件或指令替换现有自定义组件或指令的实现,这可以简化原本复杂的测试。让我们看一个例子。

存根单个子组件

一个常见的例子是,当你想测试组件中出现在组件层次结构中非常高位置的内容时。

在这个例子中,我们有一个<App> 渲染一条消息,以及一个FetchDataFromApi 组件,它发出 API 调用并渲染其结果。

js
const FetchDataFromApi = {
  name: 'FetchDataFromApi',
  template: `
    <div>{{ result }}</div>
  `,
  async mounted() {
    const res = await axios.get('/api/info')
    this.result = res.data
  },
  data() {
    return {
      result: ''
    }
  }
}

const App = {
  components: {
    FetchDataFromApi
  },
  template: `
    <h1>Welcome to Vue.js 3</h1>
    <fetch-data-from-api />
  `
}

我们不想在这个特定的测试中发出 API 调用,我们只想断言消息被渲染。在这种情况下,我们可以使用stubs,它出现在global 挂载选项中。

js
test('stubs component with custom template', () => {
  const wrapper = mount(App, {
    global: {
      stubs: {
        FetchDataFromApi: {
          template: '<span />'
        }
      }
    }
  })

  console.log(wrapper.html())
  // <h1>Welcome to Vue.js 3</h1><span></span>

  expect(wrapper.html()).toContain('Welcome to Vue.js 3')
})

注意模板中显示的是<span></span>,而<fetch-data-from-api /> 在哪里?我们用一个存根替换了它——在这种情况下,我们通过传入一个template 提供了自己的实现。

你也可以获得一个默认存根,而不是提供你自己的存根

js
test('stubs component', () => {
  const wrapper = mount(App, {
    global: {
      stubs: {
        FetchDataFromApi: true
      }
    }
  })

  console.log(wrapper.html())
  /*
    <h1>Welcome to Vue.js 3</h1>
    <fetch-data-from-api-stub></fetch-data-from-api-stub>
  */

  expect(wrapper.html()).toContain('Welcome to Vue.js 3')
})

这将所有<FetchDataFromApi /> 组件在整个渲染树中存根,无论它们出现在哪个级别。这就是为什么它在global 挂载选项中的原因。

提示

要存根,你可以使用components 中的键或组件的名称。如果两者都在global.stubs 中给出,则将优先使用键。

存根所有子组件

有时你可能想要存根所有自定义组件。例如,你可能有一个这样的组件

js
const ComplexComponent = {
  components: { ComplexA, ComplexB, ComplexC },
  template: `
    <h1>Welcome to Vue.js 3</h1>
    <ComplexA />
    <ComplexB />
    <ComplexC />
  `
}

想象一下,每个<Complex> 都做了一些复杂的事情,而你只对测试<h1> 是否渲染了正确的问候语感兴趣。你可以做这样的事情

js
const wrapper = mount(ComplexComponent, {
  global: {
    stubs: {
      ComplexA: true,
      ComplexB: true,
      ComplexC: true
    }
  }
})

但这会产生很多样板代码。VTU 有一个shallow 挂载选项,它会自动存根所有子组件

js
test('shallow stubs out all child components', () => {
  const wrapper = mount(ComplexComponent, {
    shallow: true
  })

  console.log(wrapper.html())
  /*
    <h1>Welcome to Vue.js 3</h1>
    <complex-a-stub></complex-a-stub>
    <complex-b-stub></complex-b-stub>
    <complex-c-stub></complex-c-stub>
  */
})

提示

如果你使用过 VTU V1,你可能还记得它叫shallowMount。该方法仍然可用,它与编写shallow: true 相同。

存根所有子组件,但有例外

有时你想要存根所有自定义组件,除了特定的组件。让我们考虑一个例子

js
const ComplexA = {
  template: '<h2>Hello from real component!</h2>'
}

const ComplexComponent = {
  components: { ComplexA, ComplexB, ComplexC },
  template: `
    <h1>Welcome to Vue.js 3</h1>
    <ComplexA />
    <ComplexB />
    <ComplexC />
  `
}

通过使用shallow 挂载选项,它会自动存根所有子组件。如果我们想要明确地选择不存根特定组件,我们可以使用stubs 中的值设置为false 的组件名称来提供它

js
test('shallow allows opt-out of stubbing specific component', () => {
  const wrapper = mount(ComplexComponent, {
    shallow: true,
    global: {
      stubs: { ComplexA: false }
    }
  })

  console.log(wrapper.html())
  /*
    <h1>Welcome to Vue.js 3</h1>
    <h2>Hello from real component!</h2>
    <complex-b-stub></complex-b-stub>
    <complex-c-stub></complex-c-stub>
  */
})

存根异步组件

如果你想要存根异步组件,那么有两种行为。例如,你可能会有这样的组件

js
// AsyncComponent.js
export default defineComponent({
  name: 'AsyncComponent',
  template: '<span>AsyncComponent</span>'
})

// App.js
const App = defineComponent({
  components: {
    MyComponent: defineAsyncComponent(() => import('./AsyncComponent'))
  },
  template: '<MyComponent/>'
})

第一种行为是使用在组件中定义的键,该键加载异步组件。在这个例子中,我们使用的是键 "MyComponent"。在测试用例中不需要使用async/await,因为组件在解析之前就被存根了。

js
test('stubs async component without resolving', () => {
  const wrapper = mount(App, {
    global: {
      stubs: {
        MyComponent: true
      }
    }
  })

  expect(wrapper.html()).toBe('<my-component-stub></my-component-stub>')
})

第二种行为是使用异步组件的名称。在这个例子中,我们使用的是名称 "AsyncComponent"。现在需要使用async/await,因为异步组件需要解析,然后才能通过异步组件中定义的名称存根它。

确保在异步组件中定义一个名称!

js
test('stubs async component with resolving', async () => {
  const wrapper = mount(App, {
    global: {
      stubs: {
        AsyncComponent: true
      }
    }
  })

  await flushPromises()

  expect(wrapper.html()).toBe('<async-component-stub></async-component-stub>')
})

存根指令

有时指令会做一些非常复杂的事情,比如执行大量的 DOM 操作,这可能会导致测试中出现错误(因为 JSDOM 不像整个 DOM 行为那样)。一个常见的例子是来自各种库的工具提示指令,它们通常严重依赖于测量 DOM 节点的定位/大小。

在这个例子中,我们还有另一个<App> 渲染一条带有工具提示的消息

js
// tooltip directive declared somewhere, named `Tooltip`

const App = {
  directives: {
    Tooltip
  },
  template: '<h1 v-tooltip title="Welcome tooltip">Welcome to Vue.js 3</h1>'
}

我们不想在这个测试中执行Tooltip 指令代码,我们只想断言消息被渲染。在这种情况下,我们可以使用stubs,它出现在global 挂载选项中,传递vTooltip

js
test('stubs component with custom template', () => {
  const wrapper = mount(App, {
    global: {
      stubs: {
        vTooltip: true
      }
    }
  })

  console.log(wrapper.html())
  // <h1>Welcome to Vue.js 3</h1>

  expect(wrapper.html()).toContain('Welcome to Vue.js 3')
})

提示

使用vCustomDirective 命名方案来区分组件和指令的灵感来自<script setup> 中使用的相同方法

有时,我们需要指令功能的一部分(通常是因为某些代码依赖于它)。假设我们的指令在执行时添加了with-tooltip CSS 类,而这对于我们的代码来说是一个重要的行为。在这种情况下,我们可以用我们自己的模拟指令实现来替换true

js
test('stubs component with custom template', () => {
  const wrapper = mount(App, {
    global: {
      stubs: {
        vTooltip: {
          beforeMount(el: Element) {
            console.log('directive called')
            el.classList.add('with-tooltip')
          }
        }
      }
    }
  })

  // 'directive called' logged to console

  console.log(wrapper.html())
  // <h1 class="with-tooltip">Welcome to Vue.js 3</h1>

  expect(wrapper.classes('with-tooltip')).toBe(true)
})

我们刚刚用我们自己的指令实现替换了我们的指令实现!

警告

由于在withDirectives 函数中缺少指令名称,因此在函数组件或<script setup> 上存根指令将不起作用。如果你需要模拟在函数组件中使用的指令,请考虑通过你的测试框架来模拟指令模块。有关解锁此功能的提案,请参阅https://github.com/vuejs/core/issues/6887

默认插槽和shallow

由于shallow 会存根组件的所有内容,因此在使用shallow 时,任何<slot> 都不会被渲染。虽然在大多数情况下这不是问题,但有一些场景并不理想。

js
const CustomButton = {
  template: `
    <button>
      <slot />
    </button>
  `
}

你可能会这样使用它

js
const App = {
  props: ['authenticated'],
  components: { CustomButton },
  template: `
    <custom-button>
      <div v-if="authenticated">Log out</div>
      <div v-else>Log in</div>
    </custom-button>
  `
}

如果你使用的是shallow,插槽将不会被渲染,因为<custom-button /> 中的渲染函数被存根了。这意味着你将无法验证是否渲染了正确的文本!

对于这种情况,你可以使用config.renderStubDefaultSlot,它会在使用shallow 时渲染默认插槽内容

js
import { config, mount } from '@vue/test-utils'

beforeAll(() => {
  config.global.renderStubDefaultSlot = true
})

afterAll(() => {
  config.global.renderStubDefaultSlot = false
})

test('shallow with stubs', () => {
  const wrapper = mount(AnotherApp, {
    props: {
      authenticated: true
    },
    shallow: true
  })

  expect(wrapper.html()).toContain('Log out')
})

由于此行为是全局的,而不是在每次mount 时,因此你需要记住在每个测试之前和之后启用/禁用它。

提示

你也可以通过在你的测试设置文件中导入config 并将renderStubDefaultSlot 设置为true 来全局启用它。不幸的是,由于技术限制,这种行为不会扩展到默认插槽以外的插槽。

mountshallowstubs:哪个以及何时使用?

作为经验法则,你的测试越接近你的软件的使用方式,它们就能给你带来越多的信心。

使用mount 的测试将渲染整个组件层次结构,这更接近用户在真实浏览器中体验到的内容。

另一方面,使用shallow 的测试专注于特定组件。shallow 可用于测试完全隔离的进阶组件。如果你只有一个或两个与你的测试无关的组件,请考虑使用mountstubs 结合使用,而不是使用shallow。你存根的越多,你的测试就越不像生产环境。

请记住,无论你是进行完整挂载还是浅层渲染,好的测试都应该关注输入(props 和用户交互,例如使用trigger)和输出(渲染的 DOM 元素和事件),而不是实现细节。

因此,无论你选择哪种挂载方法,我们建议你牢记这些准则。

结论

  • 使用global.stubs 用虚拟组件或指令替换组件或指令,以简化你的测试
  • 使用shallow: true(或shallowMount)存根所有子组件
  • 使用global.renderStubDefaultSlot 渲染存根组件的默认<slot>