跳至内容

测试 Vue Router

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

  1. 使用真实的 Vue Router,这更像生产环境,但在测试大型应用程序时也可能导致复杂性
  2. 使用模拟路由器,允许更细粒度地控制测试环境。

请注意,Vue 测试工具没有提供任何特殊功能来帮助测试依赖于 Vue Router 的组件。

使用模拟路由器

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

我们可以创建一个模拟版本,它只实现了我们感兴趣的功能,而不是使用真实的 Vue Router 实例。我们可以使用 jest.mock(如果您使用的是 Jest)和 global.components 的组合来做到这一点。

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

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

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 属性的路由来实现这一点

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)来为每个测试设置理想状态。

然后,我们能够使用 jest.fn() 来监控 this.$router.push 被调用了多少次以及使用哪些参数。最棒的是,我们不必在测试中处理 Vue Router 的复杂性或注意事项!我们只关心测试应用程序逻辑。

提示

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

使用真实的路由器

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

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

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,我们在那里列出帖子。

真实的路由器如下所示。请注意,我们将路由与路由分开导出,以便稍后为每个单独的测试实例化一个新的路由器。

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 测试应用程序的最佳方法是让警告引导我们。以下最小测试足以让我们开始

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

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

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

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 安装选项来安装它

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')
})

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

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 异步处理路由这一事实有关。

Vue Router 提供了一个 isReady 函数,它告诉我们路由器何时准备就绪。然后,我们可以 await 它以确保初始导航已发生。

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')
})

测试现在通过了!这需要相当多的工作,但现在我们确保应用程序正确地导航到初始路由。

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

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')
})

同样,另一个有点神秘的错误

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 路由完成,然后才能进行任何断言。

但是,在这种情况下,没有我们可以等待的 hasNavigated 钩子。一种替代方法是使用 Vue 测试工具导出的 flushPromises 函数

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 测试工具测试 Vue 组件时使用模拟路由器是一种常见方法的原因。如果您更喜欢继续使用真实的路由器,请记住,每个测试都应该使用它自己的路由器实例,如下所示

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 的模拟路由器

Vue 路由器 4 允许使用 Composition API 在 setup 函数中使用路由器和路由。

考虑使用 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 并直接模拟路由器和路由。

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 的真实路由器

使用 Composition API 的真实路由器与使用 Options API 的真实路由器的工作方式相同。请记住,就像使用 Options API 一样,将每个测试实例化一个新的路由器对象,而不是直接从您的应用程序导入路由器,这被认为是一种良好的做法。

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 也可用作替代方案。

结论

  • 您可以在测试中使用真实的路由器实例。
  • 不过,有一些注意事项:Vue Router 4 是异步的,我们在编写测试时需要考虑这一点。
  • 对于更复杂的应用程序,请考虑模拟路由器依赖项并专注于测试底层逻辑。
  • 尽可能利用测试运行器的存根/模拟功能。
  • 使用 global.mocks 来模拟全局依赖项,例如 this.$routethis.$router