loading...

How to test implementation details with react-testing-library

mcrowder65 profile image Matt Crowder ・3 min read

If you are using enzyme to test your react components, you should consider switching to react-testing-library as soon as possible, its API is intuitive, easy to use, and it encourages writing tests in a way that your end users use your application.

With that being said, when you write tests with react-testing-library, it does not directly expose a way to test the implementation details of a component, because your users do not care if you're using a stateless functional component, a stateful functional component (a component with hooks), or a class component. With enzyme, it's easy to test implementation details, which then encourages engineers to ... test implementation details.

I had the odd scenario where it made sense to test implementation details, but I only knew how to do so with enzyme, so I made a tweet listing my concerns, to which react-testing-library's author, Kent C. Dodds, promptly replied saying that I can test implementation details by using refs. Tweet available here: https://twitter.com/mcrowder65/status/1100587157264187392

So I set out to find out how to accomplish this!

The specific use case I was having at work was with ag-grid, so I wanted to reproduce here as well, let's render a simple grid with the following code:

import React from "react";
import { AgGridReact } from "ag-grid-react";
import "ag-grid-community/dist/styles/ag-grid.css";
import "ag-grid-community/dist/styles/ag-theme-balham.css";
import CellEditor from "./custom-cell";

function App() {
  const columnDefs = [
    {
      headerName: "Make",
      field: "make",
      cellEditorFramework: CellEditor,
      editable: true
    },
    {
      headerName: "Model",
      field: "model",
      cellEditorFramework: CellEditor,
      editable: true
    },
    {
      headerName: "Price",
      field: "price",
      cellEditorFramework: CellEditor,
      editable: true
    }
  ];
  const rowData = [
    {
      make: "Toyota",
      model: "Celica",
      price: 35000
    },
    {
      make: "Ford",
      model: "Mondeo",
      price: 32000
    },
    {
      make: "Porsche",
      model: "Boxter",
      price: 72000
    }
  ];

  return (
    <div
      className="ag-theme-balham"
      style={{
        height: "130px",
        width: "600px"
      }}
    >
      <AgGridReact columnDefs={columnDefs} rowData={rowData} />
    </div>
  );
}

export default App;

This produces the following:

If you look at columnDefs, you'll notice that I added cellEditorFramework, this allows me to add my own, custom cell editor here. Let's look at that custom cell editor.

import React from "react";
import { TextField } from "@material-ui/core";

class CellEditor extends React.Component {
  state = {
    value: this.props.value
  };
  getValue() {
    return this.state.value;
  }

  handleChange = event => {
    this.setState({ value: event.target.value });
  };

  render() {
    return <TextField value={this.state.value} onChange={this.handleChange} />;
  }
}

export default CellEditor;

You'll notice here, that we are just setting local state values which takes the initial prop value and syncs to local state. But one thing you'll notice here if you look closely, getValue is completely unnecessary, it does not provide any value! Let's look at what ag-grid does now when I start editing with getValue removed:

The value disappears once we're done editing! This is because ag-grid calls getValue to get the final value once we're done editing, it doesn't know that the value is stored in state. So, there are three things one must do to ensure that this code works.

  1. Add getValue back.
  2. Add a jsdoc like so:
   /**
   * Ag-grid calls this function to get the final value once everything is updated.
   * DO NOT DELETE
   * @returns {String|Number} this.state.value
   */
  getValue() {
    return this.state.value;
  }
  1. Create a unit test that tests that getValue() returns this.state.value Let's write that unit test!

If you read the tweet, you noticed that Kent said, "You can do that with with react-testing-library using a ref in what you render in your test.", then let's do that.

In custom-cell.test.js:

import React from "react";
import { render } from "react-testing-library";
import CustomCell from "../custom-cell";

test("that getData returns this.state.data", () => {
  const ref = React.createRef();
  render(<CustomCell ref={ref} />);
  expect(ref.current.getValue()).toEqual(ref.current.state.value);
});

Now we know, if someone gets rid of getValue for some reason, it will fail, and you are protected.

Again, there are VERY rare cases where you need to do this, so please think twice, maybe even thrice, whether or not you should be doing this.

Source code available here: https://github.com/mcrowder65/rtl-testing-implementation-details

Posted on by:

mcrowder65 profile

Matt Crowder

@mcrowder65

Software Engineer at Appian, previously at Walmart Labs. Co-organizer of novajavascript.com, public speaker, teacher, codementor.io/mcrowder65

Discussion

pic
Editor guide
 

That is so neat, and really goes off to show that there are always edge cases.

 

Just in time! We're moving to this from enzyme

 

why would you test implementation details? Tests should reflect the behavior only in case the implementation changes