Blog

Vue Router Testing Strategies

01.02.2020 | Testing Frontend | James McMahon

hero

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:

icon
  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, 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.

Share

Read More

Related Posts

related_image

06.30.2021 | Culture | Katy Scott

At Focused Labs, collaboration is key to how we work together; it helps our teams learn from each other, brings us closer and helps us become more efficient...

related_image

06.23.2021 | Culture | Austyn

Late-night feedings and diaper changes, the 3-4 month sleep regression, teething, and a growth spurt all mean I'm getting less sleep than...

related_image

05.12.2021 | Culture Backend Frontend | Ryan Taylor

Temporarily disrupts "normal" business operations and allow self-organized teams to rapid prototype around their interest areas

related_image

04.27.2021 | Culture | Erin Hochstatter

Several years ago, I'd been trying to find an approach to software consulting that made sense for me [...]

related_image

01.28.2021 | Backend | Parker Drake

Recently I found myself needing to validate fields in a Spring Boot controller written in Kotlin...

related_image

01.22.2021 | Tutorial | Luke Mueller

⌘+⇧+g is the way to go

related_image

01.21.2021 | Devops | Katy G

Kube jobs running wild? To delete successful jobs...

additional accent
accent
FocusedLabs

171 N Aberdeen St
Suite 400
Chicago, IL 60607

[email protected]

© 2021 FocusedLabs, All Rights Reserved.

  • facebook icon
  • twitter icon
  • linkedin icon
  • github icon