Skip to content

测试 Vue 路由

¥Testing Vue Router

本文将介绍两种使用 Vue Router 测试应用的方法:

¥This article will present two ways to test an application using Vue Router:

  1. 使用真正的 Vue Router,它更像生产环境,但在测试大型应用时也可能会导致复杂性

    ¥Using the real Vue Router, which is more production like but also may lead to complexity when testing larger applications

  2. 使用模拟路由,可以对测试环境进行更细粒度的控制。

    ¥Using a mocked router, allowing for more fine grained control of the testing environment.

请注意,Vue Test Utils 不提供任何特殊功能来协助测试依赖于 Vue Router 的组件。

¥Notice that Vue Test Utils does not provide any special functions to assist with testing components that rely on Vue Router.

使用模拟路由

¥Using a Mocked Router

你可以使用模拟路由来避免在单元测试中关心 Vue Router 的实现细节。

¥You can use a mocked router to avoid caring about the implementation details of Vue Router in your unit tests.

我们可以创建一个仅实现我们感兴趣的功能的模拟版本,而不是使用真正的 Vue Router 实例。我们可以使用 jest.mock(如果你使用 Jest)和 global.components 的组合来完成此操作。

¥Instead of using a real Vue Router instance, we can create a mock version which only implements the features we are interested in. We can do this using a combination of jest.mock (if you are using Jest), and global.components.

当我们模拟依赖时,通常是因为我们对测试其行为不感兴趣。我们不想测试点击 <router-link> 导航到正确的页面 - 当然可以!不过,我们可能有兴趣确保 <a> 具有正确的 to 属性。

¥When we mock out a dependency, it's usually because we are not interested in testing its behavior. We don't want to test clicking <router-link> navigates to the correct page - of course it does! We might be interested in ensuring that the <a> has the correct to attribute, though.

让我们看一个更现实的例子!该组件显示一个按钮,该按钮会将经过身份验证的用户重定向到编辑帖子页面(基于当前的路由参数)。未经身份验证的用户应被重定向到 /404 路由。

¥Let's see a more realistic example! This component shows a button that will redirect an authenticated user to the edit post page (based on the current route parameters). An unauthenticated user should be redirected to a /404 route.

js
const Component = {
  template: `<button @click="redirect">Click to Edit</button>`,
  props: ['isAuthenticated'],
  methods: {
    redirect() {
      if (this.isAuthenticated) {
        this.$router.push(`/posts/${this.$route.params.id}/edit`)
      } else {
        this.$router.push('/404')
      }
    }
  }
}

我们可以使用真正的路由,然后导航到该组件的正确路由,然后单击按钮后断言渲染了正确的页面......然而,对于相对简单的测试来说,这是很多设置。我们要编写的测试的核心是 "如果通过身份验证,则重定向到 X,否则重定向到 Y"。让我们看看如何通过使用 global.mocks 属性模拟路由来实现此目的:

¥We could use a real router, then navigate to the correct route for this component, then after clicking the button assert that the correct page is rendered... however, this is a lot of setup for a relatively simple test. At its core, the test we want to write is "if authenticated, redirect to X, otherwise redirect to Y". Let's see how we might accomplish this by mocking the routing using the global.mocks property:

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

test('allows authenticated user to edit a post', async () => {
  const mockRoute = {
    params: {
      id: 1
    }
  }
  const mockRouter = {
    push: jest.fn()
  }

  const wrapper = mount(Component, {
    props: {
      isAuthenticated: true
    },
    global: {
      mocks: {
        $route: mockRoute,
        $router: mockRouter
      }
    }
  })

  await wrapper.find('button').trigger('click')

  expect(mockRouter.push).toHaveBeenCalledTimes(1)
  expect(mockRouter.push).toHaveBeenCalledWith('/posts/1/edit')
})

test('redirect an unauthenticated user to 404', async () => {
  const mockRoute = {
    params: {
      id: 1
    }
  }
  const mockRouter = {
    push: jest.fn()
  }

  const wrapper = mount(Component, {
    props: {
      isAuthenticated: false
    },
    global: {
      mocks: {
        $route: mockRoute,
        $router: mockRouter
      }
    }
  })

  await wrapper.find('button').trigger('click')

  expect(mockRouter.push).toHaveBeenCalledTimes(1)
  expect(mockRouter.push).toHaveBeenCalledWith('/404')
})

我们使用 global.mocks 提供必要的依赖(this.$routethis.$router)来为每个测试设置理想状态。

¥We used global.mocks to provide the necessary dependencies (this.$route and this.$router) to set an ideal state for each test.

然后,我们能够使用 jest.fn() 来监视 this.$router.push 被调用的次数以及使用的参数。最重要的是,我们不必在测试中处理 Vue Router 的复杂性或警告!我们只关心测试应用逻辑。

¥We were then able to use jest.fn() to monitor how many times, and with which arguments, this.$router.push was called with. Best of all, we don't have to deal with the complexity or caveats of Vue Router in our test! We were only concerned with testing the app logic.

提示

你可能希望以端到端的方式测试整个系统。你可以考虑使用像 Cypress 这样的框架,使用真实的浏览器进行完整的系统测试。

¥You might want to test the entire system in an end-to-end manner. You could consider a framework like Cypress for full system tests using a real browser.

使用真正的路由

¥Using a Real Router

现在我们已经了解了如何使用模拟路由,让我们看看如何使用真正的 Vue 路由。

¥Now we have seen how to use a mocked router, let's take a look at using the real Vue Router.

让我们创建一个使用 Vue Router 的基本博客应用。帖子在 /posts 路由上列出:

¥Let's create a basic blogging application that uses Vue Router. The posts are listed on the /posts route:

js
const App = {
  template: `
    <router-link to="/posts">Go to posts</router-link>
    <router-view />
  `
}

const Posts = {
  template: `
    <h1>Posts</h1>
    <ul>
      <li v-for="post in posts" :key="post.id">
        {{ post.name }}
      </li>
    </ul>
  `,
  data() {
    return {
      posts: [{ id: 1, name: 'Testing Vue Router' }]
    }
  }
}

应用的根目录显示 <router-link> 通向 /posts,我们在其中列出帖子。

¥The root of the app displays a <router-link> leading to /posts, where we list the posts.

真正的路由是这样的。请注意,我们将路由与路由分开导出,以便稍后我们可以为每个单独的测试实例化一个新路由。

¥The real router looks like this. Notice that we're exporting the routes separately from the route, so that we can instantiate a new router for each individual test later.

js
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  {
    path: '/',
    component: {
      template: 'Welcome to the blogging app'
    }
  },
  {
    path: '/posts',
    component: Posts
  }
];

const router = createRouter({
  history: createWebHistory(),
  routes: routes,
})

export { routes };

export default router;

说明如何使用 Vue Router 测试应用的最佳方法是让警告指导我们。以下最小测试足以让我们继续:

¥The best way to illustrate how to test an app using Vue Router is to let the warnings guide us. The following minimal test is enough to get us going:

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

test('routing', () => {
  const wrapper = mount(App)
  expect(wrapper.html()).toContain('Welcome to the blogging app')
})

测试失败。它还打印两个警告:

¥The test fails. It also prints two warnings:

bash
console.warn node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:39
  [Vue warn]: Failed to resolve component: router-link

console.warn node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:39
  [Vue warn]: Failed to resolve component: router-view

未找到 <router-link><router-view> 组件。我们需要挂载 Vue Router!由于 Vue Router 是一个插件,我们使用 global.plugins 挂载选项来挂载它:

¥The <router-link> and <router-view> component are not found. We need to install Vue Router! Since Vue Router is a plugin, we install it using the global.plugins mounting option:

js
import { mount } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'
import { routes } from "@/router" // This import should point to your routes file declared above

const router = createRouter({
  history: createWebHistory(),
  routes: routes,
})

test('routing', () => {
  const wrapper = mount(App, {
    global: {
      plugins: [router]
    }
  })
  expect(wrapper.html()).toContain('Welcome to the blogging app')
})

这两个警告现在消失了 - 但现在我们有另一个警告:

¥Those two warnings are now gone - but now we have another warning:

js
console.warn node_modules/vue-router/dist/vue-router.cjs.js:225
  [Vue Router warn]: Unexpected error when starting the router: TypeError: Cannot read property '_history' of null

虽然警告中并不完全清楚,但这与 Vue Router 4 异步处理路由这一事实有关。

¥Although it's not entirely clear from the warning, it's related to the fact that Vue Router 4 handles routing asynchronously.

Vue Router 提供了 isReady 函数来告诉我们路由何时准备就绪。然后我们可以对其进行 await 以确保初始导航已发生。

¥Vue Router provides an isReady function that tell us when router is ready. We can then await it to ensure the initial navigation has happened.

js
import { mount } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'
import { routes } from "@/router"

const router = createRouter({
  history: createWebHistory(),
  routes: routes,
})

test('routing', async () => {
  router.push('/')

  // After this line, router is ready
  await router.isReady()

  const wrapper = mount(App, {
    global: {
      plugins: [router]
    }
  })
  expect(wrapper.html()).toContain('Welcome to the blogging app')
})

现在测试已经通过了!这是一项相当多的工作,但现在我们确保应用正确导航到初始路由。

¥The test is now passing! It was quite a bit of work, but now we make sure the application is properly navigating to the initial route.

现在让我们导航到 /posts 并确保路由按预期工作:

¥Now let's navigate to /posts and make sure the routing is working as expected:

js
import { mount } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'
import { routes } from "@/router"

const router = createRouter({
  history: createWebHistory(),
  routes: routes,
})

test('routing', async () => {
  router.push('/')
  await router.isReady()

  const wrapper = mount(App, {
    global: {
      plugins: [router]
    }
  })
  expect(wrapper.html()).toContain('Welcome to the blogging app')

  await wrapper.find('a').trigger('click')
  expect(wrapper.html()).toContain('Testing Vue Router')
})

再次,另一个有点神秘的错误:

¥Again, another somewhat cryptic error:

js
console.warn node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:39
  [Vue warn]: Unhandled error during execution of native event handler
    at <RouterLink to="/posts" >

console.error node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:211
  TypeError: Cannot read property '_history' of null

同样,由于 Vue Router 4 的新异步特性,我们需要在做出任何断言之前对路由进行 await 完成。

¥Again, due to Vue Router 4's new asynchronous nature, we need to await the routing to complete before making any assertions.

然而,在这种情况下,我们没有可以等待的 hasNaviated 钩子。一种替代方法是使用从 Vue Test Utils 导出的 flushPromises 函数:

¥In this case, however, there is no hasNavigated hook we can await on. One alternative is to use the flushPromises function exported from Vue Test Utils:

js
import { mount, flushPromises } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'
import { routes } from "@/router"

const router = createRouter({
  history: createWebHistory(),
  routes: routes,
})

test('routing', async () => {
  router.push('/')
  await router.isReady()

  const wrapper = mount(App, {
    global: {
      plugins: [router]
    }
  })
  expect(wrapper.html()).toContain('Welcome to the blogging app')

  await wrapper.find('a').trigger('click')
  await flushPromises()
  expect(wrapper.html()).toContain('Testing Vue Router')
})

终于过去了。很棒的!然而,这一切都是非常手动的 - 这是一个小而琐碎的应用。这就是使用 Vue Test Utils 测试 Vue 组件时使用模拟路由是一种常见方法的原因。如果你更喜欢继续使用真实的路由,请记住每个测试都应该使用它自己的路由实例,如下所示:

¥It finally passes. Great! This is all very manual, however - and this is for a tiny, trivial app. This is the reason using a mocked router is a common approach when testing Vue components using Vue Test Utils. In case you prefer to keep using a real router, keep in mind that each test should use it's own instance of the router like so:

js
import { mount, flushPromises } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'
import { routes } from "@/router"

let router;
beforeEach(async () => {
  router = createRouter({
    history: createWebHistory(),
    routes: routes,
  })
});

test('routing', async () => {
  router.push('/')
  await router.isReady()

  const wrapper = mount(App, {
    global: {
      plugins: [router]
    }
  })
  expect(wrapper.html()).toContain('Welcome to the blogging app')

  await wrapper.find('a').trigger('click')
  await flushPromises()
  expect(wrapper.html()).toContain('Testing Vue Router')
})

使用具有 Composition API 的模拟路由

¥Using a mocked router with Composition API

Vue router 4 允许使用组合 API 在 setup 函数内使用路由和路由。

¥Vue router 4 allows for working with the router and route inside the setup function with the composition API.

考虑使用组合 API 重写的相同演示组件。

¥Consider the same demo component rewritten using the composition API.

js
import { useRouter, useRoute } from 'vue-router'

const Component = {
  template: `<button @click="redirect">Click to Edit</button>`,
  props: ['isAuthenticated'],
  setup (props) {
    const router = useRouter()
    const route = useRoute()

    const redirect = () => {
      if (props.isAuthenticated) {
        router.push(`/posts/${route.params.id}/edit`)
      } else {
        router.push('/404')
      }
    }

    return {
      redirect
    }
  }
}

这次为了测试该组件,我们将使用 jest 的能力来模拟导入的资源 vue-router 并直接模拟路由和路由。

¥This time in order to test the component, we will use jest's ability to mock an imported resource, vue-router and mock both the router and route directly.

js
import { useRouter, useRoute } from 'vue-router'

jest.mock('vue-router', () => ({
  useRoute: jest.fn(),
  useRouter: jest.fn(() => ({
    push: () => {}
  }))
}))

test('allows authenticated user to edit a post', () => {
  useRoute.mockImplementationOnce(() => ({
    params: {
      id: 1
    }
  }))

  const push = jest.fn()
  useRouter.mockImplementationOnce(() => ({
    push
  }))

  const wrapper = mount(Component, {
    props: {
      isAuthenticated: true
    },
    global: {
      stubs: ["router-link", "router-view"], // Stubs for router-link and router-view in case they're rendered in your template
    }
  })

  await wrapper.find('button').trigger('click')

  expect(push).toHaveBeenCalledTimes(1)
  expect(push).toHaveBeenCalledWith('/posts/1/edit')
})

test('redirect an unauthenticated user to 404', () => {
  useRoute.mockImplementationOnce(() => ({
    params: {
      id: 1
    }
  }))

  const push = jest.fn()
  useRouter.mockImplementationOnce(() => ({
    push
  }))

  const wrapper = mount(Component, {
    props: {
      isAuthenticated: false
    }
    global: {
      stubs: ["router-link", "router-view"], // Stubs for router-link and router-view in case they're rendered in your template
    }
  })

  await wrapper.find('button').trigger('click')

  expect(push).toHaveBeenCalledTimes(1)
  expect(push).toHaveBeenCalledWith('/404')
})

使用具有 Composition API 的真实路由

¥Using a real router with Composition API

使用具有 Composition API 的真实路由与使用具有 Options API 的真实路由的工作方式相同。请记住,就像选项 API 的情况一样,为每个测试实例化一个新的路由对象,而不是直接从应用导入路由,被认为是一种很好的做法。

¥Using a real router with Composition API works the same as using a real router with Options API. Keep in mind that, just as is the case with Options API, it's considered a good practice to instantiate a new router object for each test, instead of importing the router directly from your app.

js
import { mount } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'
import { routes } from "@/router"

let router;

beforeEach(async () => {
  router = createRouter({
    history: createWebHistory(),
    routes: routes,
  })

  router.push('/')
  await router.isReady()
});

test('allows authenticated user to edit a post', async () => {
  const wrapper = mount(Component, {
    props: {
      isAuthenticated: true
    },
    global: {
      plugins: [router],
    }
  })

  const push = jest.spyOn(router, 'push')
  await wrapper.find('button').trigger('click')

  expect(push).toHaveBeenCalledTimes(1)
  expect(push).toHaveBeenCalledWith('/posts/1/edit')
})

对于那些喜欢非手动方法的人,Posva 创建的库 vue-router-mock 也可以作为替代方案。

¥For those who prefer a non-manual approach, the library vue-router-mock created by Posva is also available as an alternative.

结论

¥Conclusion

  • 你可以在测试中使用真实的路由实例。

    ¥You can use a real router instance in your tests.

  • 但有一些注意事项:Vue Router 4 是异步的,我们在编写测试时需要考虑到这一点。

    ¥There are some caveats, though: Vue Router 4 is asynchronous, and we need to take it into account when writing tests.

  • 对于更复杂的应用,请考虑模拟路由依赖并专注于测试底层逻辑。

    ¥For more complex applications, consider mocking the router dependency and focus on testing the underlying logic.

  • 尽可能利用测试运行程序的存根/模拟功能。

    ¥Make use of your test runner's stubbing/mocking functionality where possible.

  • 使用 global.mocks 来模拟全局依赖,例如 this.$routethis.$router

    ¥Use global.mocks to mock global dependencies, such as this.$route and this.$router.

Vue Test Utils 中文网 - 粤ICP备13048890号