How to test SVG Graphs

6 min read

This article is walk-through of testing a React Bar Graph with Jest and React Testing Library. The style of tests documented here could also be called integration tests.

The gold standard for UI testing is to test user behavior instead of code implementation because it makes the code flexible and ensures the user critical functionality works! Unfortunately with SVG graphs implementation testing comes with the territory, because the graph is implemented as an SVG. That's OK, the trade-off is that we create robust graph code that will withstand time.

Like all code, graphs can become complex over time as more and more features are piled on. Graphs also tend to be reused many times for different data sets so they usually succumb to many flags--with props like "shouldShowAverage" which adds a line to a bar graph.

You've heard it a million times "don't reinvent the wheel". There are great open-source libraries for graphs but eventually you might out-grow them. Tests give you the flexibility to swap out libraries in the future or write your own with lower-level libraries like D3.

Writing tests for a bar graph

Let's take my own advice and start with a charting library. We are going to be using recharts to create a simple bar chart. For the future ability to swap out libraries, we are also going to wrap the chart in our own custom component.

/**
 * https://github.com/Samic8/robust-ui-examples/blob/576e75028d462f01416f80f8bb0fc7699d57ea25/src/components/BarGraph/index.js
 */
import React from "react";
import { BarChart, YAxis, Bar } from "recharts";

export default function BarGraph({ data, width = 500, height = 500 }) {
  return (
    <BarChart
      width={width}
      height={height}
      data={data.map((value) => ({ y: value }))}
      margin={{ top: 20, right: 20, left: 20, bottom: 20 }}
    >
      <YAxis dataKey="y" />
      <Bar type="monotone" dataKey="y" fill="#ff7300" yAxisId={0} />
    </BarChart>
  );
}

How the rechart components are used within our BarChart is not important for the purposes of this article. It's important that the props include width and height so the bars rendered in our tests are consistent sizes.

We always need setup code in our tests, heres some:

/**
 * https://github.com/Samic8/robust-ui-examples/blob/576e75028d462f01416f80f8bb0fc7699d57ea25/src/components/BarGraph/BarGraph.test.js
 */
import React from "react";
import BarGraph from "./";
import { wait, render, screen } from "@testing-library/react";

describe("BarGraph", () => {
  describe("given two data points at a particular size", () => {
    const data = [40, 60];
    const size = { width: 500, height: 1000 };

    let graphContainer;
    beforeEach(() => {
      const { container } = render(
        <div>
          <BarGraph data={data} width={size.width} height={size.height} />
        </div>
      );
      graphContainer = container;
    });
    // ...
  });
});

It's not ideal that we need to create the graphContainer variable, we will see why it's needed in a moment.

Visually--although we won't see it in these tests--the bar graph setup in the beforeEach() would look like:

Bar Chart Example

First up let's get the bars under tests. Jest's test.each() allows us to write tabular data which pairs nicely with graphs.

/**
 * https://github.com/Samic8/robust-ui-examples/blob/576e75028d462f01416f80f8bb0fc7699d57ea25/src/components/BarGraph/BarGraph.test.js
 */
// ...
describe("BarGraph", () => {
  describe("given two data points at a particular size", () => {
  // ...
    // It's super handy to use .each for graphs
    test.each`
      index | height   | x        | y
      ${0}  | ${"640"} | ${"100"} | ${"340"}
      ${1}  | ${"960"} | ${"300"} | ${"20"}
    `("renders rects", async ({ index, height, x, y }) => {
      // waits for rectangles to appear due to animation in graph library
      await wait(() => {
        // Hard coding classes is not ideal but best we have to work with
        const bars = graphContainer.querySelectorAll(".recharts-rectangle");
        expect(bars[index].getAttribute("height")).toBe(height);
        expect(bars[index].getAttribute("x")).toBe(x);
        expect(bars[index].getAttribute("y")).toBe(y);
      });
    });
    // ...
  });
});

The Recharts bars are not immediately rendered due to animations so we need to wait() a few seconds for the expect() assertions to pass.

Having to use querySelector() or querySelectorAll() is a red flag that your testing implementation, but as we already have discussed it's a necessary evil when testing SVG graphs.

Notice how we are not trying to capture every attribute and making use of getAttribute(), this allows the code some flexibility to change.

Next, let's add some tests for the y-axis value markers:

/**
 * https://github.com/Samic8/robust-ui-examples/blob/576e75028d462f01416f80f8bb0fc7699d57ea25/src/components/BarGraph/BarGraph.test.js
 */
// ...
describe("BarGraph", () => {
  describe("given two data points at a particular size", () => {
  // ...
    test.each`
      value   | x       | y
      ${"15"} | ${"72"} | ${"740"}
      ${"30"} | ${"72"} | ${"500"}
      ${"45"} | ${"72"} | ${"260"}
      ${"60"} | ${"72"} | ${"20"}
    `("displays $value in the correct position", ({ value, x, y }) => {
      // By testing the position of the tick we are also testing it existence, it's a two for one deal.
      const textNode = screen.getByText(value).parentNode;
      expect(textNode.getAttribute("y")).toBe(y);
      expect(textNode.getAttribute("x")).toBe(x);
    });
  });
});

For this test we have introduced another implementation detail--like the wait() previously--because the parentNode contains the x and y attributes. Again, it's not ideal but we get to have solid tests for the graph.

This second test is more in line with the Testing Library philosophy, but we do lose points for the parentNode usage.

We also made use of screen which makes tests simpler as we don't have to worry about destructing or scoping variables, which was not available in the first test as we needed access to the container element.

Snapshots

It's worth considering Jests snapshot testing tool toMatchSnapshot()

expect(textNode).toMatchSnapshot()

This further nudges us in the direction of testing implementation (not good). It's worth considering if the attributes of the element are minimal and you can accurately describe what your testing in the test description. The saved snapshot from the previous assertions creates the output:

exports[
  `BarGraph given two data points at a particular size displays 15 in the correct position 1`
] = `
<text
  class="recharts-text recharts-cartesian-axis-tick-value"
  fill="#666"
  height="960"
  stroke="none"
  text-anchor="end"
  width="60"
  x="72"
  y="740"
>
  <tspan
    dy="0.355em"
    x="72"
  >
    15
  </tspan>
</text>
`

I don't know how to describe all of those attributes in a description, so it's not a good usage of snapshot testing.

What about TDD (Test Driven Development)?

Yeah about that... it's hard with graphs, especially as showcased in this example when using an external library. I recommend first writing (or including a library) the code for a section of a graph (e.g the bars), making sure it visually looks right, then writing tests.

You can write test with empty values:

const textNode = screen.getByText(value).parentNode
expect(textNode.getAttribute("y")).toBe("")
expect(textNode.getAttribute("x")).toBe("")

The tests will error when run:

BarGraph › given two data points at a particular size › displays 15 in the correct position

    expect(received).toBe(expected) // Object.is equality

    Expected: ""
    Received: "740"

Then copy and paste the correct values.

This is a manual version of what snapshot testing, so you could just use that if you prefer. Instead of capturing the entire element, you can use it to capture specific data points:

const textNode = screen.getByText(value).parentNode
expect(textNode.getAttribute("y")).toMatchSnapshot()
expect(textNode.getAttribute("x")).toMatchSnapshot()

Conclusion

Although testing implementation goes against Testing Libraries philosophy it's worth being a bit awkward to make your graph code robust and future proof. SVG graphs are not particularly accessible themselves, so it's hard to query by user-accessible features, which is what Testing Library wants you to do.

On swapping out libraries as mentioned earlier: It's never going to be as simple as swapping libraries and the tests all pass because we are testing implementation (the SVG), but that's ok it's an intentional decision.

Resources

  • Visually testing SVG graphs to complement unit tests

Was this article helpful?