Testing The Anchor Links

Back to Cypress blog

Once upon a time Zach Bloomquist and I were discussing how to achieve an all-time hero status. We were talking about our open source heroes of course, and I wanted to send Zach a link to the testing section of the Develop, Preview, Test blog post written by Guillermo Rauch. The testing section was in the middle of the post, I scrolled, clicked on the # link, and ... screamed in horror: the anchor URL was #undefined

The anchor link leads nowhere

Of course, Guillermo quickly fixed the missing element IDs. Yet even then, he missed one of the problems and had to make another commit. Which brings to mind this popular tweet:

Write tests. Not too many. Mostly integration.

Let's write an integration test to ensure that none of our anchor links ever go undefined. Luckily I already had a fork of Guillermo's blog for another blog post of mine called Components People Test so everything was ready.

The blog runs on the Next.js system, a simple yarn dev and the posts are available at localhost:3000. Perfect. Let's start writing a cypress/integration/spec.js test file (get it? "mostly integration").

Checking an anchor

describe("Blog", () => {
  it("has anchor tags", () => {
    cy.visit("2020/develop-preview-test");
    cy.contains("a", "#");
  });
});

I place the localhost:3000 base url into the cypress.json file so I do not have to repeat myself in every test. The test runs and finds ... a single <a> element with text # inside. It is not even our anchor tag - it is just the first tag matching the command.

Hmm, cy.contains returns the first matching element, we could probably write a test and fix every anchor element one by one.

Tip: the Command Log shows a crossed eye icon. This unfortunate event happens when the found element only becomes visible on hover. For our purposes, it does not matter if the element is visible or note - we are only interested in its href attribute.

The anchor element is hidden by default, but present in the DOM

The test can simply check if the href attribute is equal to the string value #undefined:

it("has anchor tags", () => {
  cy.visit("2020/develop-preview-test");
  cy.contains("a", "#").should("not.have.attr", "href", "#undefined");
});

The test works - it fails as expected

Yet we do not know which element has the invalid attribute, and it would be hard to find it in the long post. Here is a little trick - use .scrollIntoView command to bring the element into the viewport.

it("has anchor tags", () => {
  cy.visit("2020/develop-preview-test");
  cy.contains("a", "#")
    .scrollIntoView()
    .should("not.have.attr", "href", "#undefined");
});

Much better - the heading with the undefined anchor is now at the top of the viewport.

Even better would be to print the H2 element that contains the anchor. Here is the markup we are working with:

The A element is for H2 element

Let's print the H2 element when making the assertion.

it("has anchor tags", () => {
  cy.visit("2020/develop-preview-test");
  cy.contains("a", "#")
    .scrollIntoView()
    .should($a => {
      const message = $a.parent().parent().text();
      expect($a, message).to.not.have.attr("href", "#undefined");
    });
});

Beautiful - the screenshot shows the heading element with missing anchor.

One more trick - we could replace cy.contains that returns a single element with cy.get command, which returns all elements matching the selector. Because cy.get allows any jQuery selector, we could use :contains to find anchor elements with text containing "#". Then we can iterate over every element using .each Cypress command.

it("has anchor tags using cy.get and .each", () => {
  cy.visit("2020/develop-preview-test");
  cy.get("a:contains(#)").each($a => {
    const message = $a.parent().parent().text();
    expect($a, message).to.not.have.attr("href", "#undefined");
  });
});

We lost the scroll command, but our text message is still enough. The Command Log also shows that there are four anchor elements. Let's fix the anchor tags until the integration test passes.

Great - notice how the passing assertions show the heading text. The four anchor elements are all accounted for.

Checking every post

We have written a test for a single blog post. But the site contains multiple posts - all in the pages subfolders:

We probably should check every post against the broken anchors. This is an example of dynamic tests - we need to generate tests from the data. In this case, we do not even need to glob the folder - all posts are linked in ... posts.json file:

{
  "posts": [
    {
      "id": "books-people-reread",
      "date": "August 2, 2020",
      "title": "Books people re(read)"
    },
    {
      "id": "develop-preview-test",
      "date": "June 11, 2020",
      "title": "Develop, Preview, Test"
    },
    ...
  ]
}

In our spec we can import this JSON file directly - even destructure the import to grab just the posts property. Then we can iterate over every post, creating a separate it test block. We just need to extract the year to form the right URL to visit.

import { posts } from "../../posts.json";
context("Post", () => {
  posts.forEach(post => {
    it(`"${post.title}" has no broken anchors`, () => {
      const year = new Date(post.date).getFullYear();
      const url = `${year}/${post.id}`;
      cy.visit(url);
      cy.get("a:contains(#)").each($a => {
        const message = $a.parent().parent().text();
        expect($a, message).to.not.have.attr("href", "#undefined");
      });
    });
  });
});

Almost works. Most posts are checked successfully, but there are a couple of posts with no anchor links - and thus the cy.get(...) command fails because it does expect to find at least one element.

Here is a little trick to avoid the problem. Every page has a few links in the navigation element at the top. There is the logo link, the source link, and "Follow me" link.

Let's grab heading links AND one of those always-present anchors - and validate them all. We can use , operator to combine selectors like this:

cy.get("a:contains(#), a.src").each($a => {
  const message = $a.parent().parent().text();
  expect($a, message).to.not.have.attr("href", "#undefined");
});

Now every dynamically generated test passes. Every blog post is ok.

Finally, if we are checking some anchor links, then we probably should check them all. After all - if somehow we got #undefined, then it is possible to get undefined in the middle - after all, JavaScript is notorious for typecasting values into links like /posts/undefined/comments/

Let's check every anchor element against this problem.

cy.visit(url);
cy.get("a").each($a => {
  const message = $a.text();
  expect($a, message).to.have.attr("href").not.contain("undefined");
});

Notice how every expect assertion really has two assertions chained together. First, we confirm that the anchor element has href attribute. Second, the value of that attribute is checked against containing the string "undefined". There are a lot of anchors, and every anchor is checked this way.

All links are 👍

You can find the source code and the tests here.