Vue Router Testing Strategies
Tags: vue, testing, tdd, typescript
Published: 2020-01-02 16:41:31

Recently I was playing with some techniques for testing the [Vue Router](https://router.vuejs.org/) in my app. The [Vue Testing Handbook](https://lmiller1990.github.io/vue-testing-handbook/vue-router.html#vue-router) has some excellent advice for the basics, but I wanted to take some time to do a deep dive on various techniques and how you can evolve your testing patterns to meet the needs of your app.

## Why

Why should we care about testing our Vue Router?

If our Router looks like this,

```typescript
export default new Router({
    mode: "history",
    base: process.env.BASE_URL,
    routes: [
        {
            path: "/",
            component: Home
        },
        {
            path: "/about",
            component: About
        }
    ]
});
```

You might not think you need to test it, and you are probably right. The Router in it's purest form is configuration, so tests at this point are limited to verifying our configuration.

But as our Router starts to grow and we start attaching behavior to it, then testing and test driving that behavior becomes reasonable and efficient.

## How

So how do we go about testing behavior? Specifically, the behavior that comes from [Navigation Guards](https://router.vuejs.org/guide/advanced/navigation-guards.html)? The [Testing Handbook has some advice](https://lmiller1990.github.io/vue-testing-handbook/vue-router.html#strategies-for-testing-router-hooks). They recommend de-coupling the guard function from the Router and testing that a mock inside the guard function is invoked.

That handbook is full of excellent testing strategies, and in the cache bursting scenario they laid out, this approach makes sense, but what if I want my guard to control my resulting navigation?

For this scenario, I want to add the following behavior to the Router,

- I have a login in page everyone can access
- My other routes require the user to be logged in. If they are not and try and access those routes, they are redirected back to the login screen.

Let's take a TDD approach and start with the tests to drive our implementation:

```typescript
describe("/login", () => {
    it("routes to the login page", async () => {
        const router = createRouter();
        await router.push("/login");
        expect(router.currentRoute.fullPath).to.eq("/login");
    });
});
``` 

Now our implementation, notice that I've changed the Router export from configuration object to a function that creates the configuration. This change makes it easier to create a new instance on per test basis and avoid cross-contamination due to global state:

```typescript
export const createRouter = () =>
    new Router({
        mode: "history",
        base: process.env.BASE_URL,
        routes: [
            {
                path: "/login",
                component: Login
            }
        ]
    });
```

Super easy to implement. However, it feels like our basic scenario above where we are just checking configuration. Let's add some more interesting behavior:

```typescript
describe("/", () => {
    it("can only be accessed by a logged in user", async () => {
        const loggedOutRouter = createRouter({ loggedIn: false });
        await loggedOutRouter.push("/");
        expect(loggedOutRouter.currentRoute.fullPath).to.eq("/login");

        const loggedInRouter = createRouter({ loggedIn: true });
        await loggedOutRouter.push("/");
        expect(loggedOutRouter.currentRoute.fullPath).to.eq("/");
    });
});
```

and here is the implementation:

```typescript
export const createRouter = authContext => {
  const router = new Router({
    mode: "history",
    base: process.env.BASE_URL,
    routes: [
      {
        path: "/login",
        component: Login
      },
      {
        path: "/",
        component: Home,
        meta: { requiresAuth: true }
      }
    ]
  });

  router.beforeEach((to, from, next) => {
    if (to.meta.requiresAuth && !authContext.loggedIn) {
      next("/login");
    } else {
      next();
    }
  });

  return router;
};
```

Wait! Our tests still don't pass. Instead, we get this mysterious error:

> 1. router
>    /
>
>    can only be accessed by a logged in user:
>
> Error: Promise rejected with no or falsy reason

What is happening is that when we redirect to the `next("/login")` we trigger an abort, which, if we are using [the Promise API for `router.push`](https://router.vuejs.org/guide/essentials/navigation.html#router-push-location-oncomplete-onabort), rejects the Promise. So are options are to switch to the older, non-Promise API by passing in some empty handler functions, like so:

```typescript
loggedOutRouter.push("/", () => {}, () => {});
```

or swallow the rejected Promise:

```typescript
await loggedOutRouter.push("/").catch(() => {})
```

All things being equal, I would prefer to keep Promises and asynchronicity out of our tests if possible as they add another layer of complexity. So let's go ahead and use the non-Promise API. Adding two no-op functions to each call to `push` is going to get old fast, so let's make a helper function:

```typescript
const push = (router, path) => {
  const noOp = () => {};
  router.push(path, noOp, noOp);
};
```

Now we write our push as:

```typescript
describe("/", () => {
  it("can only be accessed by a logged in user", () => {
    const loggedOutRouter = createRouter({ loggedIn: false });
    push(loggedOutRouter, "/");
    expect(loggedOutRouter.currentRoute.fullPath).to.eq("/login");

    const loggedInRouter = createRouter({ loggedIn: true });
    push(loggedInRouter, "/");
    expect(loggedInRouter.currentRoute.fullPath).to.eq("/");
  });
});
```

Much better, both in terms of succinctness and readability.

Looking at this test suite, I am tempted to delete that login test as it doesn't seem to provide much value. But let's think about what we are building for a second. Does it make sense for a user who is already logged in to be able to see the login screen? Let's make sure that can't happen:

```typescript
describe("/login", () => {
  it("routes to the login page if not logged in", () => {
    const loggedOutRouter = createRouter({ loggedIn: false });
    push(loggedOutRouter, "/login");
    expect(loggedOutRouter.currentRoute.fullPath).to.eq("/login");

    const loggedInRouter = createRouter({ loggedIn: true });
    push(loggedInRouter, "/login");
    expect(loggedInRouter.currentRoute.fullPath).to.eq("/");
  });
});
```

And our implementation:

```typescript
router.beforeEach((to, from, next) => {
  if (to.meta.requiresAuth && !authContext.loggedIn) {
    next("/login");
  } else if (to.path === "/login" && authContext.loggedIn) {
    next("/");
  } else {
    next();
  }
});
```

This block could be hairy in the future as we add additional conditions, but for now, it is reasonably straight forward, and our passing tests allow us to refactor as the need arises.

Let's add some more behavior to our Router. Let's say we have a component that needs some props:

```typescript
describe("/gizmos", () => {
  it("add id as a prop to the route", () => {
    const router = createRouter({ loggedIn: true });
    router.push("/gizmos");

    const matchedRoute = router.currentRoute.matched[0];
    const props = matchedRoute.props.default;
    expect(props).to.eql({
      sampleProp: true
    });
  });
});

// implementation - new route
{
  path: "/gizmos",
  component: Gizmos,
  props: { sampleProp: true }
}
```

Pretty straightforward, aside from the nested objects needed to get to the actual props object. That test feels less readable because of that logic; let's extract it out to a helper function.

```typescript
describe("/gizmos", () => {
  it("adds a sample prop to the route", () => {
    const router = createRouter({ loggedIn: true });
    push(router, "/gizmos");
    expect(currentProps(router)).to.eql({
      sampleProp: true
    });
  });

  const currentProps = router => {
    const matchedRoute = router.currentRoute.matched[0];
    return matchedRoute.props.default;
  };
});
```

That feels more readable and straightforward to me.

## What about router-view?

[The testing handbook lays out another scenario](https://lmiller1990.github.io/vue-testing-handbook/vue-router.html#writing-the-test) and demonstrates testing against a top-level `App` component using `router-view`. This strategy sounds pretty good as we aren't currently directly testing what component is loaded by our Router.

So say we have a component named `App.vue` that looks like the following:

```html
<template>
  <div>
    <router-view />
  </div>
</template>
```

Let's rewrite login tests to test against this component.

```typescript
describe("App.vue", () => {
  it("routes to the login page if not logged in", () => {
    const loggedOutRouter = createRouter({ loggedIn: false });
    const loggedOutApp = mount(App, { router: loggedOutRouter });
    push(loggedOutRouter, "/login");
    expect(loggedOutApp.find(Login).exists()).to.eq(true);

    const loggedInRouter = createRouter({ loggedIn: true });
    const loggedInApp = mount(App, { router: loggedInRouter });
    push(loggedInRouter, "/login");
    expect(loggedInApp.find(Login).exists()).to.eq(false);
  });
});

const push = (router, path) => {
  const noOp = () => {};
  router.push(path, noOp, noOp);
};
```

We could potentially rewrite our entire test suite this way, let's examine the trade-offs. Tests pointed at the `App` component are concerned with more moving pieces, because they now need to mount said component and attach the router to it. On the other hand, this approach is verifying that we can load the component that is routed to. Depending on the needs of your app and the complexity of your Router, either approach could be valid.

A scenario where testing through a component is beneficial is when we are dealing with props. Let's say we added an `id` to our `gizmos` route and put that `id` in our props as described in the [Vue Router docs](https://router.vuejs.org/guide/essentials/passing-props.html#passing-props-to-route-components). Here is what the tests and implementation looks-like without using the `App` component.

```typescript
it("adds the gizmo id as a prop to the route", () => {
  const router = createRouter({ loggedIn: true });
  push(router, "/gizmos/123");
  expect(currentProps(router).id).to.eq("123");
});

const currentProps = router => {
  const currentRoute = router.currentRoute;
  const props = currentRoute.matched[0].props;
  const propsFunction = props.default;
  return propsFunction(currentRoute);
};

// adjusted gizmos route implementation
{
  path: "/gizmos/:id",
  component: Gizmos,
  props: route => ({ id: route.params.id, sampleProp: true })
}
```

This test is working, but it isn't great. It isn't actually verifying the `id` is passed in. Instead, it is verifying that the props function resolves correctly, which requires replicating the circumstances under how Vue Router is invoking the props function. Therefore, reading this test now requires a good understanding of how Vue Router works, which is less then ideal when you are onboarding new Developers to this codebase or if you forget the internal details of Vue Router's behavior.

Let's look at how this test looks written against the `App` component.

```typescript
it("adds the gizmo id as a prop to the route", () => {
  const router = createRouter({ loggedIn: true });
  const app = mount(App, { router });

  push(router, "/gizmos/123");

  expect(app.find(Gizmos).props().id).to.eq("123");
});
```

This approach looks a little more straightforward. The downside is that now multiple components, both `App` and `Gizmos`, are pulled into the testing of our Router behavior. That means these tests are going to be more likely to break if either of those components changes, which can be a good thing, but overall our tests are going to be more complicated.

Choosing the right testing strategy for your Application requires weighing the pros and cons of both approaches. Testing, like software engineering in general, is not about one size fits all solutions.

## Conclusion

Hopefully, it is now clear how you would test a Vue Router with a few different strategies, and you can choose the right approach for your project.