20
React, TypeScript, and TDD Part 3
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.
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.
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>
);
}
}
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");
});
With this in place, our tests pass:
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");
});
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!
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>
);
}
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.
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
...then import it at the top of Counter.test.tsx
:
import userEvent from "@testing-library/user-event";
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");
});
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!
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.
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:
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();
});
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>
);
};
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.
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;
};
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();
});
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);
});
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.
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.
20