DEV Community

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

React, TypeScript, and TDD Part 2

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.
・7 min read

React component development is kinda fun. What's even...uhhh...funner? Driving your component development from inside a test.

No, I'm serious.

As we saw in the previous article introducing this, React+TDD isn't just about "quality" (scare quotes) and eating your vegetables. Particularly when paired with TypeScript and smart tooling, it's a pleasurable mode of development -- faster, joyful, puppies.

Let's get more specific in this article by going through some of the modes of component development. As a reminder, this article follows a full video+text+code tutorial in the WebStorm Guide.

TSX and ES6

Using React and TypeScript means good JSX (TSX) and ES6+ support, especially in smart editors. We can see this in action from the tutorial step on this subject.

Imagine we have some React code:

import React from "react";

function App() {
    return (
        <div>
            <h1>Hello React</h1>
        </div>
    );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

...and the test that goes with it:

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

test("renders hello react", () => {
    const {getByText} = render(<App/>);
    const linkElement = getByText(/hello react/i);
    expect(linkElement).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

We can then run some tests. Here we see the Jest test runner integrated into a "smart editor", in this case, WebStorm:

01

Let's do some TDD and show some ES6 features along the way.

Extracted Heading

What's something you do All The Time(tm) in React? Decompose big components into smaller components. Let's extract a Heading component from this App component, starting with a new test. One that fails, of course:

test("renders heading", () => {
  const { getByText } = render(<Heading />);
  const linkElement = getByText(/hello react/i);
  expect(linkElement).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

We can't even import our component because...it doesn't exist. Let's now write our first attempt at an extracted Heading component:

import React from "react";

export function Heading() {
    return <h1>Hello React</h1>;
}
Enter fullscreen mode Exit fullscreen mode

When our test adds the import of Heading, the new test will then pass:

02

Of course, extracting a component into the same file somewhat violates the React community's adherence to "one component per file." Let's move our component to its own Heading.tsx file:

export function Heading() {
  return <h1>Hello React</h1>;
}
Enter fullscreen mode Exit fullscreen mode

...with a companion Heading.test.tsx:

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

test("renders heading", () => {
    const {getByText} = render(<Heading/>);
    const linkElement = getByText(/hello react/i);
    expect(linkElement).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

When we run the test in this file, it passes again:

03

We need to change our App.tsx to import this Heading component and use it:

import React from "react";
import {Heading} from "./Heading";

function App() {
    return (
        <div>
            <Heading/>
        </div>
    );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Our test in App.test.tsx still passes -- it doesn't really know that Hello React came from a subcomponent.

We can now show some testing of parent and child components.

Props and Types

That is a boring component. It says the same thing every time! Let's change it so "parent" components can pass in a value for the "name" to say hello to.

We first write a (failing) first test in Heading.test.tsx:

test("renders heading with argument", () => {
  const { getByText } = render(<Heading name={`World`}/>);
  const linkElement = getByText(/hello world/i);
  expect(linkElement).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

Thanks to TypeScript and tooling, we "failed faster": it immediately told us, with a red-squiggly, that we violated the contract. Heading doesn't (yet) take a name prop:

04

Let's head to the Heading component and fix it:

export function Heading({ name }) {
  return <h1>Hello {name}</h1>;
}
Enter fullscreen mode Exit fullscreen mode

Our new test passes. The previous test is broken -- no name was passed in. We'll handle that in a bit.

What's up with the {name} as the function argument? This is ES6 object destructuring, a cool way to pick out the values you want from an argument.

Our test passes but TypeScript is unhappy:

05

We don't have any type information on the props. We could add the type information inline:

export function Heading({ name }: {name: string}) {
  return <h1>Hello {name}</h1>;
}
Enter fullscreen mode Exit fullscreen mode

It is better, though, to put this in a standalone type or interface, then use that in the function arguments:

type HeadingProps = { name: string };

export function Heading({ name }: HeadingProps) {
  return <h1>Hello {name}</h1>;
}
Enter fullscreen mode Exit fullscreen mode

Let's now look at fixing the first test.

Default Prop Value

We want Heading to accept a name prop but not require it. Sounds like a change to the type definition, marking name as an optional field:

type HeadingProps = { name?: string };
Enter fullscreen mode Exit fullscreen mode

We can then use another ES6 feature -- default values in object destructuring:

export function Heading({name = "React"}: HeadingProps) {
    return <h1>Hello {name}</h1>;
}
Enter fullscreen mode Exit fullscreen mode

With this, Heading will use React as the prop value if the calling component doesn't provide it. Our first test in Heading.test.tsx now passes.

You know who else doesn't provide that prop? Our App component. And guess what -- our tests in App.test.tsx now pass again:

06

At each step during development of this, we "failed faster" thanks to TypeScript and test-first. Even better -- we have yet to look at the browser. We stayed "in the flow".

Class Components With Props

The React community has become very enthusiastic about functional programming and pure, function-based components. But the class-based component syntax is still there for all the old die-hards. (Narrator: He means himself.)

Let's make a new Counter component, written as a class-based component which takes a single prop. We'll be following along the tutorial step that matches this section. In the next section, we'll introduce state into the class.

Of course, we'll start with a failing Counter.test.tsx test which uses Testing Library's getByTestId query:

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();
});
Enter fullscreen mode Exit fullscreen mode

We create a new Counter.tsx file:

import React, {Component} from "react";

export class Counter extends Component {
    render() {
        return (
            <div>
                <div data-testid="counter-label">Count</div>
                <div data-testid="counter">
          1
        </div>
            </div>
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Our test passes. But it's boring: we want the label displayed next to the count to be configurable, passed in by the parent as a prop. Here's the (failing) test:

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

This failed even before I ran the test, as TypeScript told us we broke the contract:

07

Back in the implementation, we need two things: a type definition for the props then a changed class which uses the prop:

import React, {Component} from "react";

export type CounterProps = { label?: string };

export class Counter extends Component<CounterProps> {
    render() {
        const {label = "Count"} = this.props;
        return (
            <div>
                <div data-testid="counter-label">{label}</div>
                <div data-testid="counter">
                    1
                </div>
            </div>
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Our Counter tests now pass. We have a class-based Counter component which accepts a prop.

Class Components With State

"Yay, us" in a way, but the Counter doesn't...count. Let's make a stateful class-based component. This section matches the tutorial step for Class Components With State.

What's the first step? Hint: it rhymes with "best". That's right, let's start with a failing test in Counter.test.tsx:

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

Now on to the implementation. When we did the component prop, we wrote a TypeScript type to model the prop shape. Same for the state:

export type CounterState = { count: number };
Enter fullscreen mode Exit fullscreen mode

We then change our Counter class to point to and implement that state:

export class Counter extends Component<CounterProps, CounterState> {
    state: CounterState = {
        count: 0,
    };

    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

Our test passes. The value of the state is done as a class variable, which then meant we got autocomplete on this.state.count. But if we try to do an assignment, we know that React will complain that we didn't use setState.

Fortunately this is something TypeScript can help with. Let's move the initialization of the state to the module scope, then change the type definition:

const initialState = {count: 0};
export type CounterState = Readonly<typeof initialState>;
Enter fullscreen mode Exit fullscreen mode

Our class now points at this initial state:

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

    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

Our test still passes. Again, this is what's nice about test-driven development: you can make changes with confidence, while staying in the tool.

Let's make a change to allow the starting value of the counter to passed in as a prop. First, a failing test:

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

Not only does the test fail, but TypeScript yells at us about the contract, even before the test is run:

08

We need to change the type definition for our props:

export type CounterProps = {
    label?: string;
    start?: number;
};
Enter fullscreen mode Exit fullscreen mode

With this in place, we can make a call to setState to update the value. We will do it in a lifecycle method:

componentDidMount() {
    if (this.props.start) {
      this.setState({
        count: this.props.start,
      });
    }
  }
Enter fullscreen mode Exit fullscreen mode

Our test now passes. The counter has a default starting count, but can accept a new one passed in as a prop.

Conclusion

We covered a lot in these three steps: the use of ES6 niceties, type definitions for props and state, and the use of class-based components. All without visiting a browser.

In the third and final installment, we will wire up event handlers and refactor into smarter parent/child components. We'll do both in a way that let both TypeScript and testing help us "fail faster."

Discussion (0)

Forem Open with the Forem app