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;
...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();
});
We can then run some tests. Here we see the Jest test runner integrated into a "smart editor", in this case, WebStorm:
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();
});
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>;
}
When our test adds the import of Heading
, the new test will then pass:
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>;
}
...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();
});
When we run the test in this file, it passes again:
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;
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();
});
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:
Let's head to the Heading component and fix it:
export function Heading({ name }) {
return <h1>Hello {name}</h1>;
}
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:
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>;
}
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>;
}
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 };
We can then use another ES6 feature -- default values in object destructuring:
export function Heading({name = "React"}: HeadingProps) {
return <h1>Hello {name}</h1>;
}
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:
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();
});
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>
);
}
}
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();
});
This failed even before I ran the test, as TypeScript told us we broke the contract:
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>
);
}
}
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");
});
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 };
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>
);
}
}
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>;
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>
);
}
}
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");
});
Not only does the test fail, but TypeScript yells at us about the contract, even before the test is run:
We need to change the type definition for our props:
export type CounterProps = {
label?: string;
start?: number;
};
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,
});
}
}
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."
Top comments (0)