DEV Community

loading...
Cover image for React, TypeScript, and TDD Part 3

React, TypeScript, and TDD Part 3

Paul Everitt
Paul is a PyCharm and WebStorm Developer Advocate at JetBrains. Before that: co-founder of Zope Corporation, bootstrapped both the Python Software Foundation and the Plone Foundation.
・6 min read

React component development is fun, but it breaks your flow heading over to the browser to poke around. What's a more joyful routine? Staying in a test in your IDE.

That's what this series of posts is about. I'm showing my React+TypeScript+TDD tutorial in the WebStorm Guide, which includes videos+text+code. The previous two articles covered Part 1 and Part 2.

Let's wrap up this series by taking a look at the last two steps in the tutorial: Rich Events and Testing and Presentation and Container Components.

Rich Events and Testing

Our Counter doesn't track any count. We're going to add event handling to a stateful class component by first writing tests during development. First, let's get things set back up.

Getting Setup

From the end of Part 2, we have a Counter component in a file Counter.tsx:

import React, {Component} from "react";

export type CounterProps = {
    label?: string;
    start?: number;
};
const initialState = {count: 0};
export type CounterState = Readonly<typeof initialState>;

export class Counter extends Component<CounterProps, CounterState> {
    readonly state: CounterState = initialState;

    componentDidMount() {
        if (this.props.start) {
            this.setState({
                count: this.props.start,
            });
        }
    }

    render() {
        const {label = "Count"} = this.props;
        return (
            <div>
                <div data-testid="counter-label">{label}</div>
                <div data-testid="counter">
                    {this.state.count}
                </div>
            </div>
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Side-by-side in our IDE, we have the tests for that component in Counter.test.tsx:

import React from "react";
import {render} from "@testing-library/react";
import {Counter} from "./Counter";

test("should render a label and counter", () => {
    const {getByTestId} = render(<Counter/>);
    const label = getByTestId("counter-label");
    expect(label).toBeInTheDocument();
    const counter = getByTestId("counter");
    expect(counter).toBeInTheDocument();
});

test("should render a counter with custom label", () => {
    const {getByTestId} = render(<Counter label={`Current`}/>);
    const label = getByTestId("counter-label");
    expect(label).toBeInTheDocument();
});

test("should start at zero", () => {
    const {getByTestId} = render(<Counter/>);
    const counter = getByTestId("counter");
    expect(counter).toHaveTextContent("0");
});

test("should start at another value", () => {
    const {getByTestId} = render(<Counter start={10}/>);
    const counter = getByTestId("counter");
    expect(counter).toHaveTextContent("10");
});
Enter fullscreen mode Exit fullscreen mode

With this in place, our tests pass:

01

Failing Click Test

Let's start with a failing test that clicks on the count and checks if the number is updated:

import { render, fireEvent } from "@testing-library/react";
// ...

test("should increment the count by one", () => {
  const { getByRole } = render(<Counter />);
  const counter = getByRole("counter");
  expect(counter).toHaveTextContent("0");
  fireEvent.click(counter)
  expect(counter).toHaveTextContent("1");
});
Enter fullscreen mode Exit fullscreen mode

fireEvent, what's that? It's the big idea in this tutorial step. You can pretend to click, or dispatch other DOM events, even without a real browser or "mouse". Jest uses the browser-like JSDOM environment entirely inside NodeJS to fire the event.

This new test fails: the number didn't increment. Which is good!

onClick Handler

The component doesn't handle clicks. Let's head to Counter.tsx and add a click handler on the counter, pointed at a method-like arrow function "field":

    incrementCounter = (event: React.MouseEvent<HTMLElement>) => {
        const inc: number = event.shiftKey ? 10 : 1;
        this.setState({count: this.state.count + inc});
    }

    render() {
        const {label = "Count"} = this.props;
        return (
            <div>
                <div data-testid="counter-label">{label}</div>
                <div data-testid="counter" onClick={this.incrementCounter}>
                    {this.state.count}
                </div>
            </div>
        );
    }
Enter fullscreen mode Exit fullscreen mode

With onClick={this.incrementCounter} we bind to an arrow function, which helps solve the classic "which this is this?" problem. The incrementCounter arrow function uses some good typing on the argument, which can help us spot errors in the logic of the handler.

Allow Event Modifiers

Let's add one more feature: if you click with the Shift key pressed, you increase the count by 10. To help on testing, we'll install the user-event library:

$ npm install @testing-library/user-event @testing-library/dom --save-dev
Enter fullscreen mode Exit fullscreen mode

...then import it at the top of Counter.test.tsx:

import userEvent from "@testing-library/user-event";
Enter fullscreen mode Exit fullscreen mode

The event modifier code is already written above -- we just need a test:

test("should increment the count by ten", () => {
    const {getByTestId} = render(<Counter/>);
    const counter = getByTestId("counter");
    expect(counter).toHaveTextContent("0");
    userEvent.click(counter, { shiftKey: true });
    expect(counter).toHaveTextContent("1");
});
Enter fullscreen mode Exit fullscreen mode

In this test, we changed from fireEvent in testing-library to userEvent in user-event. The click passes in some information saying shiftKey was "pressed".

The test passes!

Presentation and Container Components

Our Counter component has a lot going on inside. React encourages presentation components which have their state and some logic passed in by container components. Let's do so, and along the way, convert the back to a functional component.

As a reminder, this is covered in depth, with a video, in the Guide tutorial step.

Counter State

Let's start with a test. We want to pass the state into component as a prop, thus allowing a starting point for the count. In the should render a label and counter first test, when we change to <Counter count={0}/>, the TypeScript compiler yells at us:

02

That makes sense: it isn't in the type information as a valid prop. Change the second test to also ask for starting count:

test("should render a label and counter", () => {
    const {getByTestId} = render(<Counter count={0}/>);
    const label = getByTestId("counter-label");
    expect(label).toBeInTheDocument();
    const counter = getByTestId("counter");
    expect(counter).toBeInTheDocument();
});

test("should render a counter with custom label", () => {
    const {getByTestId} = render(<Counter label={`Current`} count={0}/>);
    const label = getByTestId("counter-label");
    expect(label).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

Back in Counter.tsx, let's convert to a dumb, presentational component:

import React from "react";

export type CounterProps = {
    label?: string;
    count: number;
};

export const Counter = ({label = "Count", count}: CounterProps) => {
    return (
        <div>
            <div data-testid="counter-label">{label}</div>
            <div data-testid="counter"
                // onClick={handleClick}
            >
                {count}
            </div>
            {count}
        </div>
    );
};
Enter fullscreen mode Exit fullscreen mode

It's pretty similar, but the count value is passed in, rather than being component state. We also have commented out the star of the show: a callable that increments the counter.

Passing In a Function

We'll tackle that now. But in a bit of a curveball way: we'll pass the handleClick callable into this dumb component. The parent will manage the logic.

Let's model the type information for this prop:

export type CounterProps = {
    label?: string;
    count: number;
    onCounterIncrease: (event: React.MouseEvent<HTMLElement>) => void;
};
Enter fullscreen mode Exit fullscreen mode

Immediately, though, TypeScript gets mad in our first two tests: we're missing a mandatory prop. We fix it by creating mock function and passing it into these two tests:

test("should render a label and counter", () => {
    const handler = jest.fn();
    const {getByTestId} = render(<Counter count={0} onCounterIncrease={handler}/>);
    const label = getByTestId("counter-label");
    expect(label).toBeInTheDocument();
    const counter = getByTestId("counter");
    expect(counter).toBeInTheDocument();
});

test("should render a counter with custom label", () => {
    const handler = jest.fn();
    const {getByTestId} = render(<Counter label={`Current`} count={0} onCounterIncrease={handler}/>);
    const label = getByTestId("counter-label");
    expect(label).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

For our third test -- tracking the click event -- we change the handler to see if it was called:


test("should call the incrementer function", () => {
    const handler = jest.fn();
    const { getByTestId } = render(
        <Counter count={0} onCounterIncrease={handler} />
    );
    const counter = getByTestId("counter");
    fireEvent.click(counter);
    expect(handler).toBeCalledTimes(1);
});
Enter fullscreen mode Exit fullscreen mode

The last section of the tutorial continues to cover more of the refactoring:

  • Make the dumb component a little smarter by not requiring a callable prop
  • Changing the parent component to track the updating of the state
  • Writing tests to make sure the App uses the container and presentation components correctly

Along the way, the tutorial shows how to refactor the type information to correctly model the contract.

Conclusion

And that's a wrap! In this 3 part series, we did a summary of this React+TS+TDD tutorial. We covered quite a bit, and the best part -- we didn't head over to a browser. We stayed in our tool, in the flow, and worked with confidence.

Discussion (0)