24
loading...
This website collects cookies to deliver better user experience
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.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>
);
}
}
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");
});
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.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>
);
}
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.user-event
library:$ npm install @testing-library/user-event @testing-library/dom --save-dev
Counter.test.tsx
:import userEvent from "@testing-library/user-event";
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");
});
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.should render a label and counter
first test, when we change to <Counter count={0}/>
, the TypeScript compiler yells at us: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();
});
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>
);
};
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.handleClick
callable into this dumb component. The parent will manage the logic.export type CounterProps = {
label?: string;
count: number;
onCounterIncrease: (event: React.MouseEvent<HTMLElement>) => void;
};
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();
});
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);
});
App
uses the container and presentation components correctly