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

Vue Router test utils #152

Closed
lmiller1990 opened this issue Jul 4, 2020 · 14 comments
Closed

Vue Router test utils #152

lmiller1990 opened this issue Jul 4, 2020 · 14 comments
Labels
discussion enhancement New feature or request

Comments

@lmiller1990
Copy link
Member

lmiller1990 commented Jul 4, 2020

In writing some small apps and the VTU docs, I encounted some caveats when testing components using Vue Router. This is mainly because the navigation is now all async (which I think it is a great design decision, it fits into Vue's async update model perfectly).

This happens because navigations are all asynchronous now.

When I first tried writing some tests using Vue Router, I didn't realize I had to do await router.isReady, or the initial navigation may not be completed before the component renders.

Also, I triggered a click on a <router-link> I got an error. It turns out that you need to wait for the navigation to complete (makes sense with the new async navigation model).

Here is a full snippet demonstrating what is currently required when using VueRouter in a jsdom environment (sans VTU).

it('avoids several caveats to succesfully click an <a> tag", async () => {
  const el = document.createElement('div')
  el.id = 'app'
  document.body.appendChild(el)
  const app = createApp(App)
  app.use(router)

  router.push('/')
  await router.isReady() // <- need to do this

  app.mount(el)

  const event = document.createEvent('Event')
  event.initEvent('click')
  let promise = new Promise(resolve => {
    const remove = router.afterEach(() => {
      remove() // <- need to do this
      resolve() // <- need to do this
    })
  })
  document.getElementById('post')!.dispatchEvent(event)
  await promise // <- need to do this
  console.log(document.body.outerHTML)
})

The user must await isReady, and also await resolve using the afterEach callback to ensure the navigation triggered when clicking on the <a> tag (from <router-link>) before continuing their tests. Kind of like how you need to do await nextTick for Vue to update the DOM.

We should bundle some goodies to make testing Vue Router a more smooth experience. @posva suggested await routerMock.pendingNavigation(). I think we need both good utils for testing with the actual VueRouter, and a mocked VueRouter.

This is the kind of thing you would previously write in VTU 1.x with Vue 2.x and Vue Router 3.x:

const router = createRouter()

const wrapper = mount(App. {
  global: {
    plugins: [router]
  }
})

await wrapper.find('a').trigger('click') // it "just works"

I see a few options, but I think the best and most simple is some additional methods on wrapper. Here are some ideas I had:

// react testing library style, like act(() => ...
await doNavigation(() => {
  wrapper.find('a').trigger('click')
})

// or a more VTU-like like API
await wrapper.navigate(wrapper.find('a'))

// or with a sneaky implicit find/findComponent
await wrapper.navigate('a')

I like the latter the best. Tight integration with Vue Router, and I think it's really readable.

Any ideas or feedback would be great 👍

@posva
Copy link
Member

posva commented Jul 4, 2020

About the waiting for navigation. The full code should also account for errors with onErrors

  function waitNavigation(router: Router, allowRejection = false) {
    return new Promise<NavigationFailure | undefined>(
      (resolve, reject) => {
        let removeError = () => {}
        if (!allowRejection) {
        removeError = router.onError(err => {
          removeError()
          removeAfterEach()
          reject(err)
        })
        }
        let removeAfterEach = router.afterEach((_to, _from, failure) => {
          removeError()
          removeAfterEach()
          resolve(failure)
        })
      }
    )
  }

I think it would be even better if the test utils could provide an extended version of the router with its own extended RouterLink that would add a Symbol to a elements that are used for the router so that VTU can detect click events in such scenarios to return a promise to wait for the next navigation.

I think it's also worth noting what scenarios would require the user to use the whole router when writing tests with VTU. I've almost always mocked the $route and $router property in my tests and checked the calls of $router.push and such. In this scenario, there is also the need of mocking the value returned by useRoute and useRouter

@lmiller1990
Copy link
Member Author

Yep, I normally mock out the router (and the use functions) and just make push insert into an array I can make assertions against. We should definitely consider both the real and mocked router use cases, and have a single API that works the same for both.

@cexbrayat
Copy link
Member

My 2 cents: I don't think we should offer special methods on the wrapper for the router (or even encourage to have tests like these). As @posva mentioned, most tests should use a mock version, and our APIs/docs should hint that way. If you want to navigate between components, isn't it more an e2e tests than a unit test?

I wouldn't mind a createFakeRouter function though, with already stubbed links, instead of having to always manually call const mockRouter = createRouter({ history: createWebHistory(), routes: [] });, then stub the RouterLinks, and then spy the push method (Angular offers a RouterTestingModule which is a drop-in for RouterModule in tests, very handy). I migrated a few apps to Vue 3/Router 4/VTU 2, and found myself repeating the same setup code over and over.

@posva
Copy link
Member

posva commented Jul 6, 2020

A fake router with spies and stubs that are still able to change a location is what I was thinking about. Similar to https://github.com/posva/vuex-mock-store

@lmiller1990
Copy link
Member Author

lmiller1990 commented Jul 6, 2020

I think we can build navigate with the new plugin functionality, good way to dog-food the plugin architecture. I actually don't mind using a real router in my tests sometimes; it would be good to support both styles of testing (like we do by having both shallow and mount functions).

I agree we should provide a mock router that does all the good stuff you would expect without any of the caveats. We could do something like vuex-mock-store.

@lmiller1990
Copy link
Member Author

lmiller1990 commented Jul 6, 2020

I thought about this a bit more - just getting the ball rolling.

I think we could do something like this:

  • have a mockRouter function
  • you can set the initial route easily (see below)
  • if the user uses the mockRouter (via global.plugins), we would also use router-link-stub by default
  • clicking one of these would still do router.push, but not actually update the <router-view /> (if there is one in the component tree) or actually go anywhere
  • router.push would just be jest.fn, in the same way dispatch is just jest.fn in vuex-mock-store

Example:

import { mockRouter } from '@vue/test-utils'

const Foo = {
  template: `<router-link :to="link">{{ post }}</router-link> <router-view />`,
  setup() {
    const title = useRoute().params.title
    return { 
      link: `/posts/${title}`,
      post: `Go to post about  {{ title }}`
  }
}

const router = mockRouter({
  initialPath: '/posts'
})

const wrapper = mount(Foo, {
  global: {
    plugins: [router]
  }
})

expect(router.history).toEqual(['/posts'])
expect(wrapper.html()).toContain('Go to post about mocking-params') 

await wrapper.find('a').trigger('click')
expect(router.history).toEqual(['/posts', '/posts/mocking-params])

Just came up with this in 5-10 mins, I'm sure I'm missing tons of things - want to make sure we are on the same page, design wise, before we right too much code.

@posva
Copy link
Member

posva commented Jul 6, 2020

  • I think fakeRouter.push should still change the fakeRouter.currentRoute as well as the route provided by useRoute for convenience.
  • Avoid adding properties to the fake route instance like history as they could be added later on by vue-router

@cexbrayat
Copy link
Member

Having a way to mock the current route out of the box would also be nice. I currently do something like this:

  // fake router params
  mockRouter.currentRoute.value.params = {
   userId: '12'
  };

@lmiller1990
Copy link
Member Author

I will try to work on this soon. @posva I wonder if we will need to export the routerKey Symbol from vue-router to be able to override it with a fake router 🤔

@posva
Copy link
Member

posva commented Aug 19, 2020

Yeah, we might need to expose them as internal utilities

@lmiller1990
Copy link
Member Author

lmiller1990 commented Aug 31, 2020

I played around with this a bit and noticed some complexity. I modified my vue-router build to export the routerKey. Then I made a little mock router that would be made available via provide(routerKey, mockRouter). My component does

setup() {
  const router = useRouter()
}

So I decided to mock useRouter with jest. But in doing this, I mocked vue-router so I could mock useRouter, which means I can no longer import the routerKey (since I mocked vue-router). Jest will say TypeError: Cannot read property 'routerKey' of undefined.

🤔

Building a mock router doesn't seem too difficult but I the hooks may present some complexity.

@manuelmazzuola
Copy link

manuelmazzuola commented Nov 25, 2020

The doc should mention to wait for router.push, consecutive router.push even separated by await router.isReady() statements leads to an error vuejs/router#615 (comment)
@posva thanks

@afontcu
Copy link
Member

afontcu commented Dec 30, 2020

Just in case anyone stumbles upon this issue: https://github.com/posva/vue-router-mock is an alternative to mock routing interactions in Vue 3

@lmiller1990
Copy link
Member Author

Going to close this one now since we have a solution for a mock router - let's open issues and PRs there if it has any missing features.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
discussion enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

5 participants