The company I work for started embracing Typescript as a go-to solution for writing React. During code reviews, I noticed a lot of people had problems while testing their components. While looking at the code, I noticed that it was written in such a way that made Typescript look more like a burden and not a tool that assists you while writing code.
Having some experience with Typescript I came up with a pattern for writing tests which, in my opinion, avoids unnecessary repetition and makes them clear.
Example Component
This is the component we are going to test. It is quite simple but contains enough logic so that we can use a couple of features of jest
and react-testing-library
.
import React from "react";
import { Todo } from "./Todo";
type Props = {
id: number;
onClick: (todo: Todo) => void;
};
type State = {
fetchState: "loading" | "error" | "success";
todo: Todo | undefined;
};
function Todo({ id, onClick }: Props) {
const [state, setState] = React.useState<State>({
fetchState: "loading",
todo: undefined
});
React.useEffect(() => {
function fetchTodo() {
fetch(`https://jsonplaceholder.typicode.com/todos/${id}`)
.then<Todo>(response => response.json())
// Normally we would probably check if the component
// is still mounted here, before using `setState`
.then(todo => setState({ todo, fetchState: "success" }))
.catch(() => setState({ todo: undefined, fetchState: "error" }));
}
fetchTodo();
}, [id]);
if (state.fetchState == "loading" || !state.todo) return <p>loading ...</p>;
if (state.fetchState == "error") return <p>error!...</p>;
return (
<div onClick={() => onClick(state.todo as Todo)}>
<p>{state.todo.title}</p>
<p>{state.todo.id}</p>
</div>
);
}
Like I said the code here does not really matter. It's just here so that we have something to test.
Tests
Your test cases would probably look like this:
import { render } from "@testing-library/react";
it("fetches a todo", () => {
const {/* selectors */} = render(<Todo onClick={() => {}} id={1} />);
// rest of the test
});
it("handles non-existing id", () => {
const {/* selectors */} = render(<Todo onClick={() => {}} id={420} />);
// rest of the test
});
// more test cases
And there is nothing wrong with that.
But when writing fourth, fifth test case you may get tired of all this repetition. Notice that I had to explicitly provide onClick
function even though that function will not be used within the test (eg. handles non-existing id
)?
We can remove all of this repetition by creating renderUI
or setup
function (these are just propositions, call it what you want).
renderUI
function
Let's create renderUI
function which will be responsible for rendering the component and returning react-testing-library
selectors and utilities.
function renderUI(props: ?) {
return render(<Todo {...props}/>)
}
Now, I left the question mark here on purpose. You might be tempted to just import the type of props
from ./App
(the file that holds the component we are testing).
import { render } from "@testing-library/react";
import { Todo, Props } from "./App";
function renderUI(props: Props) {
return render(<Todo {...props} />);
}
While you certainly can do that, I personally do not recommend doing so.
unless you use verbose names like
TodoComponentProps
, exporting the type of component props may cause collisions with other exported types, this can be especially painful when using code completion.exporting the type of component props can be confusing for the future reader of the code. Can I change the name of the type?, Are those used somewhere?.
With that in mind, lets leverage Typescript features and get the type of component props without exporting/importing them.
import { render } from "@testing-library/react";
import { Todo } from "./App";
type ComponentProps = React.ComponentProps<typeof Todo>;
function renderUI(props: ComponentProps) {
return render(<Todo {...props} />);
}
I'm using generic React.ComponentProps
defined within @types/react
to get the type I need. No exporting/importing of the props type needed!
With that, within our test, we got rid of some repetition:
it("fetches a todo", () => {
const { /* selectors */ } = renderUI({ onClick: () => {}, id: 1 });
// rest of the test
});
But still, we have to include properties that are not really important for a given test case (onClick
in this case). Parial<T>
from Typescript utility types can help with that.
import { Todo } from "./App";
type ComponentProps = React.ComponentProps<typeof Todo>;
const baseProps: ComponentProps = {
onClick: () => {},
id: 1
};
function renderUI(props: Partial<ComponentProps> = {}) {
return render(<Todo {...baseProps} {...props} />);
}
Notice that I had to create baseProps
. These should be specified in such a manner that your component can actually render using them. The baseProps
and props
combo allows us to only pass these properties to renderUI
function which matters in the context of a given test.
it("handles non-existing id", () => {
const {/* selectors */} = render(<Todo id={420} />);
// rest of the test
});
The handles non-existing id
test case does test the ability to respond to user clicks so it does not specify onClick
function. This is possible because we included baseProps
within our renderUI
function.
Rerendering
Sometimes, you need to use the rerender
function returned from react-testing-library
render
function to test how the component behaves when given prop changes (before and after the change).
Looking at the signature of the rerender
function:
rerender: (ui: React.ReactElement) => void;
it takes an parameter of type React.ReactElement
. This means that our renderUI
function, as it stands, will not cut it.
it("reacts to id change", () => {
const { rerender } = renderUI({ id: 1 });
// assert
rerender(<Todo {...baseProps} id={2} />);
// assert
});
We can abstract the rerender
function in the same way we abstracted render
.
function renderUI(props: Partial<ComponentProps> = {}) {
const rtlProps = render(<Todo {...baseProps} {...props} />);
return {
...rtlProps,
rerender: (newProps: Partial<ComponentProps>) =>
rtlProps.rerender(<Todo {...baseProps} {...props} {...newProps} />)
};
}
I've replaced the returned rerender
function. Instead of returning the original one, it now abstracts the renedring of the component away, which makes our tests clearer.
it("reacts to id change", () => {
const { rerender } = renderUI({ id: 1 });
// assert
rerender({ id: 2 });
// assert
});
Word of caution
I just want to point out that, sometimes, repetition is not necessarily a bad thing. Creating hasty abstractions surely is worse than having to pass props
multiple times.
This is why I only recommend following the advice I'm giving here if and only if you feel the need to do so.
There is a great article which you definitely should read and consider before creating any kind of abstractions within your tests (and in general).
Summary
Overall, I think this pattern can help you write tests faster and with less repetition.
Please keep in mind that I'm no expert in the field of testing and/or Typescript so if something feels off or incorrect to you, please reach out!
You can follow me on twitter: @wm_matuszewski
Thanks π
Top comments (19)
Man, it does get complicated quickly unless you're VERY familiar with the ins and outs of Typescript.
For instance, I noticed this:
with a type declaration on the "then" function - nice one, but this had me puzzled already.
Unless you have a pretty deep understanding of TS then this one will already have you scratching your head, let alone more advanced stuff like the "auto generation" of prop types with React.ComponentProps, and the 'partial' thing with Partial.
The problem is that these advanced features are what makes TS really powerful, but to get there and to be able to use that sort of stuff you have to climb a steep learning curve first.
I've used TS on one project, and while I liked it it does require a hefty investment in getting familiar with the more subtle aspects of the type system - this article goes to prove that if you only know the basics of TS it won't get you very far! You won't reap the benefits and instead you'll just add more fluff and more 'noise' to your code.
I'm inclined to say that TS pays off in larger team settings, to provide structure and 'safety', and a larger team can afford to make the investment, but for smaller or "solo" projects I'm still not convinced. But I could be wrong, maybe it's less daunting than it seems.
Anyway nice writeup, I've saved it for future reference!
P.S. by the way, the Kent C. Dodds article is a gem! I absolutely agree with what he says, I also hate the excessive nesting with describe-within-describe and the overuse of beforeEach (in his example the NESTING of beforeEach's is really atrocious).
Keep it simple, keep it linear, and keep it non-nested! And for reuse just use functions :-)
Just going to put this out there: I do essentially all my solo projects in TypeScript, to the point that I haven't written vanilla JS in close to a year. I find it helps me develope faster, make less mistakes, and refactor with a crazy amount of ease.
That said, I've been using static type systems for years now (mostly with Java, with a little C++), so I think you're probably write about the learning curve.
Funny, I've been a Java dev for over a decade, but TS has subtleties and brain-twisters in its type system which Java doesn't have (well generics can become complicated but that's more the exception than the rule). This article demonstrates some pretty nifty stuff which TS can do.
But, having said that, of course nobody forces you to use the really advanced TS features, and I understand that if you use TS in a less complicated way it can have benefits too. As I said I used TS on one project and I did like it.
Wow, well... You'd wipe the floor with me in Java expertise then. I guess I'll throw that theory out the window.
I guess I'm probably too up close to it TypeScript to make an objective judgement, really.
That's really interesting to hear, though - like, I'm comfortable with the type system in Java, but I've been slowly doing more Java projects at work lately, and trying to come to grips with the ecosystem has been totally overwhelming. I feel like I've scratched the surface of a handful of icebergs in a sea full of them. Even just getting a rudimentary level of skill with Maven has been a months-long process.
Yes the Java ecosystem is big, I guess it's because Java has been around for a while ... are you using Spring? There used to be a war between Java EE and Spring, but the war has all but ended in Spring's favor ... but just Spring as a framework already has a huge ecosystem around it.
And don't get me started about Maven, that's such a beast, I also grappled with that for a long time - back then I even bought a book about it, 200 plus pages thick, ONLY about Maven, can you imagine it?
Later on I started using other programming languages and their build tools/package managers, for instance npm in the JS/node.js world, and I was baffled - HOW CAN THIS BE SO SIMPLE WHILE MAVEN IS SO RIDICULOUSLY COMPLEX?
It often seems that in the Java world people love to make everything as complicated as possible :-)
Something else in the Java world that always seemed ridiculously complicated was Eclipse, are you using that as your IDE? and then the integration between Eclipse and Maven which NEVER seemed to work, and always required voodoo and magical incantations like "perform 'clean projects' exactly 3 times and then click 'update Maven'", something like that (it has got a lot better though with recent Eclipse versions).
I still have one Java customer but for 80% I've moved on to other languages (Javascript backend/frontend and PHP on the backend), and mostly it feels like a breath of fresh air.
But, I think it doesn't have to be that way - Java CAN be elegant and simple, if you can toss out all the baggage ("containers" like Tomcat and so on being one of them) - the language itself was what I always liked, it's just the tooling and the concepts around it (WAR, EAR and so on) that seem overly complicated.
Spring was definitely a step in the right direction when it was introduced - it felt so much more flexible and lightweight than all of the sluggish "Java EE" stuff ... but Spring itself has become more and more complex over time.
Oh and I just came across this dev.to article and was surprised to see how fast Java is evolving now, compared to the old days when there were 2 or 3 years between one release and the next:
dev.to/lemuelogbunude/introducing-...
So, we're already at Java 14 (!) and look at all that nifty syntax ("var", "record" and so on) ... Java is definitely still alive and kicking!
Wow. So it's kind of always been like this, then?
Yeah, we're using Spring. It's neat, though also really huge. We use OSGi as well, for some reason. I haven't really grappled with it yet, but the looks of dread in peoples' eyes when they talk about it certainly isn't encouraging!
On the plus side, at least we can use intellij. I don't think I'd ever voluntarily go back to Eclipse.
I'm not surprised you picked up a Maven-only book - I picked up a similarly-sized Coursera course a couple of weeks ago!
I think you're probably right about Java as a whole, though! The core language is really pretty nice, and the JVM (from what I've heard/read) is kind of a marvel. I guess the ecosystem is really just built for enterprise needs or something.
OSGi ouch, that's also a beast ... never used it, but what I heard about it didn't make me eager to use it. Eclipse is built on OSGi.
But why don't you try the new Java module system (Jigsaw) instead of OSGi? The lead dev at my Java client uses it and apparently with reasonable success. I think it's much simpler and easier to use than OSGi.
As I said, I think Java can be a joy to use, the more "legacy" and enterprise baggage you can shed and the more 'lightweight' and modern you can make it the better.
For instance, get rid of classical "containers" or application servers and just run your application as a standalone "app" with the webserver embedded, that removes a ton of annoyance. Spring Boot simplifies a lot of things in that regard.
And if you can use the newest Java versions with their goodies (closures and map/filter/reduce and all that), that also makes a big difference.
That's a good question. I guess we're probably still using OSGi because we don't have the resources to justify the lift (competitive, rapidly changing market; big code base, etc.).
Jigsaw looks really nice, though. I've always wondered why it was preferable to make missing dependencies a runtime exception rather than fail on compilation.
I guess because Java doesn't have a static linker? With C++ everything is statically linked at build time, not so with Java, linking/loading is dynamic.
Right... I suppose back when Java was in its inception, long-running, stateful servers, were the only game in town, hey?
I don't know exactly where I got this, but I sort of feel like so much of the promise of the JVM was in its ability to load new code at runtime. I'm not sure if that's true, but I think I can understand the appeal - particularly for embedded devices and the like. I don't know, does that sound sort of accurate?
By the way, I wanted to say thanks for the chat over the last few days! It's been really cool to hear your perspective on things.
You're welcome, absolutely interesting to talk about Java!
I'm amazed to see how much innovation there's still going on in the Java space, all the time they're inventing new GC's (garbage collectors), VM's and so on. Recently I heard about "GraalVM" which is apparently a big deal, and just now I came across this:
quarkus.io/
Never heard about it but it looks powerful, a "cloud native" Kubernetes-ready non-blocking reactive GraalVM based super Java which supposedly blows all the others out of the water. Amazing, goes to show how far you can take Java and especially the Java VM architecture.
I don't know about those long-running servers, but I do think that "dynamic linking" is an intrinsic part of the Java VM architecture. Just look at class loaders, that's a huge (and hugely confusing) topic in Java land, together with garbage collectors, and I guess it wouldn't work with static linking.
On the other hand (I am not completely sure about this but it seems so) I think that Android apps (which can also be built with Java) do use static linking. But, Java apps on Android aren't compiled to Java JVM bytecodes and don't run under the JVM, it's all handled by the 'Dalvik' VM so it's a totally different architecture, only the syntax (Java) looks the same but under he hood it's something else.
I've heard of GraalVM! It sounds really promising. Quarkus sounds pretty cool, too - 0.008s to launch a Java app is pretty damned good, even if it's just Hello World.
Also, wow - I never realized Android used a totally different VM. That's really cool.
How about using describes and beforeEach's? That would probably be a more idiomatic approach to accomplish the same things. If you find the need to rerender stuff too often, you're probably combining what should be multiple tests.
I'd +1 this.
Not that I don't enjoy the type gymnastics that are going on in the article (I'm a really big fan of TS's structural typing, and I spend way more time than I should doing similar things in my side projects). I just think this could probably be simpler by better leveraging your testing library.
Nice article. I had already come up with a render function that used a base props and accepted an optional override props like this
But the rerender was something new. I'm using it in my current project
Nice one mate :)
And I wonder what made you use 420 for the other Id :)
Could you use act() on the behavior that is expected to cause a rerender rather than directly changes props?
In theory - yes, but
react-testing-library
is already doing that for you (plus some other stuff)github.com/testing-library/react-t...
(
act
is used withinrender
andrender
is used withinrerender
)