Recently I was playing with some techniques for testing the Vue Router in my app. The Vue Testing Handbook 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,
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? The Testing Handbook has some advice. 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:
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:
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:
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:
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:
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
, rejects the Promise. So are options are to switch to the older, non-Promise API by passing in some empty handler functions, like so:
loggedOutRouter.push("/", () => {}, () => {});
or swallow the rejected Promise:
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:
const push = (router, path) => {
const noOp = () => {};
router.push(path, noOp, noOp);
};
Now we write our push as:
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:
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:
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:
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.
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 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:
<template>
<div>
<router-view />
</div>
</template>
Let's rewrite login tests to test against this component.
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. Here is what the tests and implementation looks-like without using the App
component.
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.
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.
Back to Explore Focused Lab