Testing
Testing. For some it's an essential part of their development workflow. For others it's something they know they should do, but for whatever reason it hasn't struck their fancy yet. For others still it's something they ignore completely, hoping the whole concept will go away. But tests are here to stay, and maybe Redwood can change some opinions about testing being awesome and fun.
Introduction to Testing
If you're already familiar with the ins and outs of testing and just want to know how to do it in Redwood, feel free to skip ahead. Or, keep reading for a refresher. In the following section, we'll build a simple test runner from scratch to help clarify the concepts of testing in our minds.
Building a Test Runner
The idea of testing is pretty simple: for each "unit" of code you write, you write additional code that exercises that unit and makes sure it works as expected. What's a "unit" of code? That's for you to decide: it could be an entire class, a single function, or even a single line! In general, the smaller the unit, the better. Your tests will stay fast and focused on just one thing, which makes them easy to update when you refactor. The important thing is that you start somewhere and codify your code's functionality in a repeatable, verifiable way.
Let's say we write a function that adds two numbers together:
const add = (a, b) => {
return a + b
}
You test this code by writing another piece of code (which usually lives in a separate file and can be run in isolation), just including the functionality from the real codebase that you need for the test to run. For our examples here we'll put the code and its test side-by-side so that everything can be run at once. Our first test will call the add()
function and make sure that it does indeed add two numbers together:
const add = (a, b) => {
return a + b
}
if (add(1, 1) === 2) {
console.log('pass')
} else {
console.error('fail')
}
Pretty simple, right? The secret is that this simple check is the basis of all testing. Yes, that's it. So no matter how convoluted and theoretical the discussions on testing get, just remember that at the end of the day you're testing whether a condition is true or false.
Running a Test
You can run that code with Node or just copy/paste it into the web console of a browser. You can also run it in a dedicated web development environment like JSFiddle. Switch to the Javascript tab below to see the code:
Note that you'll see
document.write()
in the JSFiddle examples instead ofconsole.log
; this is just so that you can actually see something in the Result tab, which is HTML output.
You should see "pass" written to the output. To verify that our test is working as expected, try changing the +
in the add()
function to a -
(effectively turning it into a subtract()
function) and run the test again. Now you should see "fail".
Terminology
Let's get to some terminology:
- The entire code block that checks the functionality of
add()
is what's considered a single test - The specific check that
add(1, 1) === 2
is known as an assertion - The
add()
function itself is the subject of the test, or the code that is under test - The value you expect to get (in our example, that's the number
2
) is sometimes called the expected value - The value you actually get (whatever the output of
add(1, 1)
is) is sometimes called the actual or received value - The file that contains the test is a test file
- Multiple test files, all run together, is known as a test suite
- You'll generally run your test files and suites with another piece of software. In Redwood that's Jest, and it's known as a test runner
- The amount of code you have that is exercised by tests is referred to as coverage and is usually reported as a percentage. If every single line of code is touched as a result of running your test suite then you have 100% coverage!
This is the basic idea behind all the tests you'll write: when you add code, you'll add another piece of code that uses the first and verifies that the result is what you expect.
Tests can also help drive new development. For example, what happens to our add()
function if you leave out one of the arguments? We can drive these changes by writing a test of what we want to happen, and then modify the code that's being tested (the subject) to make it satisfy the assertion(s).
Expecting Errors
So, what does happen if we leave off an argument when calling add()
? Well, what do we want to happen? We'll answer that question by writing a test for what we expect. For this example let's have it throw an error. We'll write the test first that expects the error:
try {
add(1)
} catch (e) {
if (e === 'add() requires two arguments') {
console.log('pass')
} else {
console.error('fail')
}
}
This is interesting because we actually expect an error to be thrown, but we don't want that error to stop the test suite in it's tracks—we want the error to be raised, we just want to make sure it's exactly what we expect it to be! So we'll surround the code that's going to error in a try/catch block and inspect the error message. If it's what we want, then the test actually passes.
Remember: we're testing for what we want to happen. Usually you think of errors as being "bad" but in this case we want the code to throw an error, so if it does, that's actually good! Raising an error passes the test, not raising the error (or raising the wrong error) is a failure.
Run this test and what happens? (If you previously made a change to add()
to see the test fail, change it back now):
Where did that come from? Well, our subject add()
didn't raise any errors (Javascript doesn't care about the number of arguments passed to a function) and so it tried to add 1
to undefined
, and that's Not A Number. We didn't think about that! Testing is already helping us catch edge cases.
To respond properly to this case we'll make one slight modification: add another "fail" log message if the code somehow gets past the call to add(1)
without throwing an error:
try {
add(1)
console.error('fail: no error thrown')
} catch (e) {
if (e === 'add() requires two arguments') {
console.log('pass')
} else {
console.error('fail: wrong error')
}
}
We also added a little more information to the "fail" messages so we know which one we encountered. Try running that code again and you should see "fail: no error thrown" in the console.
Now we'll actually update add()
to behave as we expect: by throwing an error if less than two arguments are passed.
const add = (...nums) => {
if (nums.length !== 2) {
throw 'add() requires two arguments'
}
return nums[0] + nums[1]
}
Javascript doesn't have a simple way to check how many arguments were passed to a function, so we've converted the incoming arguments to an array via spread syntax and then we check the length of that instead.
We've covered passing too few arguments, what if we pass too many? We'll leave writing that test as homework, but you should have everything you need, and you won't even need any changes to the add()
function to make it work!
Our Test Runner Compared to Jest
Our tests are a little verbose (10 lines of code to test that the right number of arguments were passed). Luckily, the test runner that Redwood uses, Jest, provides a simpler syntax for the same assertions. Here's the complete test file, but using Jest's provided helpers:
describe('add()', () => {
it('adds two numbers', () => {
expect(add(1, 1)).toEqual(2)
})
it('throws an error for too few arguments', () => {
expect(() => add(1)).toThrow('add requires 2 arguments')
})
})
Jest lets us be very clear about our subject in the first argument to the describe()
function, letting us know what we're testing. Note that it's just a string and doesn't have to be exactly the same as the function/class you're testing (but usually is for clarity).
Likewise, each test is given a descriptive name as the first argument to the it()
functions ("it" being the subject under test). Functions like expect()
and toEqual()
make it clear what values we expect to receive when running the test suite. If the expectation fails, Jest will indicate that in the output letting us know the name of the test that failed and what went wrong (the expected and actual values didn't match, or an error was thrown that we didn't expect).
Jest also has a nicer output than our cobbled-together test runner using console.log
:
Are you convinced? Let's keep going and see what Redwood brings to the table.
Redwood and Testing
Redwood relies on several packages to do the heavy lifting, but many are wrapped in Redwood's own functionality which makes them even better suited to their individual jobs:
- Jest
- React Testing Library
- Mock Service Worker or msw for short.
Redwood Generators get your test suite bootstrapped. Redwood also includes Storybook, which isn't technically a test suite, but can help in other ways.
Let's explore each one and how they're integrated with Redwood.
Jest
Jest is Redwood's test runner. By default, starting Jest via yarn rw test
will start a watch process that monitors your files for changes and re-runs the test(s) that are affected by that changed file (either the test itself, or the subject under test).
React Testing Library
React Testing Library is an extension of DOM Testing Library, adding functionality specifically for React. React Testing Library lets us render a single component in isolation and test that expected text is present or a certain HTML structure has been built.
Mock Service Worker
Among other things, Mock Service Worker (msw) lets you simulate the response from API calls. Where this comes into play with Redwood is how the web-side constantly calls to the api-side using GraphQL: rather than make actual GraphQL calls, which would slow down the test suite and put a bunch of unrelated code under test, Redwood uses MSW to intercept GraphQL calls and return a canned response, which you include in your test.
Storybook
Storybook itself doesn't appear to be related to testing at all—it's for building and styling components in isolation from your main application—but it can serve as a sanity check for an overlooked part of testing: the user interface. Your tests will only be as good as you write them, and testing things like the alignment of text on the page, the inclusion of images, or animation can be very difficult without investing huge amounts of time and effort. These tests are also very brittle since, depending on how they're written, they can break without any code changes at all! Imagine an integration with a CMS that allows a marketing person to make text/style changes. These changes will probably not be covered by your test suite, but could make your site unusable depending on how bad they are.
Storybook can provide a quick way to inspect all visual aspects of your site without the tried-and-true method of having a QA person log in and exercise every possible function. Unfortunately, checking those UI elements is not something that Storybook can automate for you, and so can't be part of a continuous integration system. But it makes it possible to do so, even if it currently requires a human touch.
Redwood Generators
Redwood's generators will include test files for basic functionality automatically with any Components, Pages, Cells, or Services you generate. These will test very basic functionality, but they're a solid foundation and will not automatically break as soon as you start building out custom features.
Test Commands
You can use a single command to run your entire suite :
yarn rw test
This will start Jest in "watch" mode which will continually run and monitor the file system for changes. If you change a test or the component that's being tested, Jest will re-run any associated test file. This is handy when you're spending the afternoon writing tests and always want to verify the code you're adding without swapping back and forth to a terminal and pressing ↑
Enter
to run the last command again.
To start the process without watching, add the --no-watch
flag:
yarn rw test --no-watch
This one is handy before committing some changes to be sure you didn't inadvertently break something you didn't expect, or before a deploy to production.
Filtering what tests to run
You can run only the web- or api-side test suites by including the side as another argument to the command:
yarn rw test web
yarn rw test api
Let's say you have a test file called CommentForm.test.js
. In order to only watch and run tests in this file you can run
yarn rw test CommentForm
If you need to be more specific, you can combine side filters, with other filters
yarn rw test api Comment
which will only run test specs matching "Comment" in the API side
Testing Components
Let's start with the things you're probably most familiar with if you've done any React work (with or without Redwood): components. The simplest test for a component would be matching against the exact HTML that's rendered by React (this doesn't actually work so don't bother trying):
const Article = ({ article }) => {
return <article>{ article.title }</article>
}
// web/src/components/Article/Article.test.js
import { render } from '@cedarjs/testing/web'
import Article from 'src/components/Article'
describe('Article', () => {
it('renders an article', () => {
expect(render(<Article article={ title: 'Foobar' } />))
.toEqual('<article>Foobar</article>')
})
})
This test (if it worked) would prove that you are indeed rendering an article. But it's also extremely brittle: any change to the component, even adding a className
attribute for styling, will cause the test to break. That's not ideal, especially when you're just starting out building your components and will constantly be making changes as you improve them.
Because as far as we can tell there's no easy way to simply render to a string. render
actually returns an object that has several functions for testing different parts of the output. Those are what we'll look into in the next section.
Note that Redwood's render
function is based on React Testing Library's. The only difference is that Redwood's wraps everything with mock providers for the various providers in Redwood, such as auth, the GraphQL client, the router, etc.
If you were to use React Testing Library's render
function, you'd need to provide your own wrapper function. In this case you probably want to compose the mock providers from @cedarjs/testing/web
:
import { render, MockProviders } from '@cedarjs/testing/web'
// ...
render(<Article article={ title: 'Foobar' } />, {
wrapper: ({ children }) => (
<MockProviders>
<MyCustomProvider>{children}</MyCustomProvider>
</MockProviders>
)
})
Mocking useLocation
To mock useLocation
in your component tests, wrap the component with LocationProvider
:
import { LocationProvider } from '@cedarjs/router'
render(
<LocationProvider location={{ pathname: '', search: '?cancelled=true' }}>
<Component />
</LocationProvider>
)
Mocking useParams
To mock useParams
in your component tests, wrap the component with ParamsProvider
:
import { ParamsProvider } from '@cedarjs/router';
render(
<ParamsProvider allParams={{ param1: 'val1', param2: 'val2' }}>
<Component />
</ParamsProvider>
)
The allParams
argument accepts an object that will provide parameters as you expect them from the query parameters of a URL string. In the above example, we are assuming the URL looks like /?param1=val1¶m2=val2
.
Queries
In most cases you will want to exclude the design elements and structure of your components from your test. Then you're free to redesign the component all you want without also having to make the same changes to your test suite. Let's look at some of the functions that React Testing Library provides (they call them "queries") that let you check for parts of the rendered component, rather than a full string match.
getByText()
In our <Article> component it seems like we really just want to test that the title of the product is rendered. How and what it looks like aren't really a concern for this test. Let's update the test to just check for the presence of the title itself:
import { render, screen } from '@cedarjs/testing/web'
describe('Article', () => {
it('renders an article', () => {
render(<Article article={ title: 'Foobar' } />)
expect(screen.getByText('Foobar')).toBeInTheDocument()
})
})
Note the additional screen
import. This is a convenience helper from React Testing Library that automatically puts you in the document.body
context before any of the following checks.
We can use getByText()
to find text content anywhere in the rendered DOM nodes. toBeInTheDocument()
is a matcher added to Jest by React Testing Library that returns true if the getByText()
query finds the given text in the document.
So, the above test in plain English says "if there is any DOM node containing the text 'Foobar' anywhere in the document, return true."
queryByText()
Why not use getByText()
for everything? Because it will raise an error if the text is not found in the document. That means if you want to explicitly test that some text is not present, you can't—you'll always get an error.
Consider an update to our <Article> component:
import { Link, routes } from '@cedarjs/router'
const Article = ({ article, summary }) => {
return (
<article>
<h1>{article.title}</h1>
<div>
{summary ? article.body.substring(0, 100) + '...' : article.body}
{summary && <Link to={routes.article(article.id)}>Read more</Link>}
</div>
</article>
)
}
export default Article
If we're only displaying the summary of an article then we'll only show the first 100 characters with an ellipsis on the end ("...") and include a link to "Read more" to see the full article. A reasonable test for this component would be that when the summary
prop is true
then the "Read more" text should be present. If summary
is false
then it should not be present. That's where queryByText()
comes in (relevant test lines are highlighted):
import { render, screen } from '@cedarjs/testing/web'
import Article from 'src/components/Article'
describe('Article', () => {
const article = { id: 1, title: 'Foobar', body: 'Lorem ipsum...' }
it('renders the title of an article', () => {
render(<Article article={article} />)
expect(screen.getByText('Foobar')).toBeInTheDocument()
})
it('renders a summary version', () => {
render(<Article article={article} summary={true} />)
expect(screen.getByText('Read more')).toBeInTheDocument()
})
it('renders a full version', () => {
render(<Article article={article} summary={false} />)
expect(screen.queryByText('Read more')).not.toBeInTheDocument()
})
})
getByRole() / queryByRole()
getByRole()
allows you to look up elements by their "role", which is an ARIA element that assists in accessibility features. Many HTML elements have a default role (including <button>
and <a>
) but you can also define one yourself with a role
attribute on an element.
Sometimes it may not be enough to say "this text must be on the page." You may want to test that an actual link is present on the page. Maybe you have a list of users' names and each name should be a link to a detail page. We could test that like so:
it('renders a link with a name', () => {
render(<List data={[{ name: 'Rob' }, { name: 'Tom' }]} />)
expect(screen.getByRole('link', { name: 'Rob' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Tom' })).toBeInTheDocument()
})
getByRole()
expects the role (<a>
elements have a default role of link
) and then an object with options, one of which is name
which refers to the text content inside the element. Check out the docs for the *ByRole
queries.
If we wanted to eliminate some duplication (and make it easy to expand or change the names in the future):
it('renders a link with a name', () => {
const data = [{ name: 'Rob' }, { name: 'Tom' }]
render(<List data={data} />)
data.forEach((datum) => {
expect(screen.getByRole('link', { name: datum.name })).toBeInTheDocument()
})
})
But what if we wanted to check the href
of the link itself to be sure it's correct? In that case we can capture the screen.getByRole()
return and run expectations on that as well (the forEach()
loop has been removed here for simplicity):
import { routes } from '@cedarjs/router'
it('renders a link with a name', () => {
render(<List data={[{ id: 1, name: 'Rob' }]} />)
const element = screen.getByRole('link', { name: data.name })
expect(element).toBeInTheDocument()
expect(element).toHaveAttribute('href', routes.user({ id: data.id }))
})
Why so many empty lines in the middle of the test?
You may have noticed a pattern of steps begin to emerge in your tests:
- Set variables or otherwise prepare some code
render
or execute the function under testexpect
s to verify outputMost tests will contain at least the last two, but sometimes all three of these parts, and in some communities it's become standard to include a newline between each "section". Remember the acronym SEA: setup, execute, assert.
Jest Expect: Type Considerations
Redwood uses prisma as an ORM for connecting to different databases like PostgreSQL, MySQL, and many more. The database models are defined in the schema.prisma
file. Prisma schema supports model
field scaler types which is used to define the data types for the models properties.
Due to this, there are some exceptions that can occur while testing your API and UI components.
Floats and Decimals
Prisma recommends using Decimal
instead of Float
because of accuracy in precision. Float is inaccurate in the number of digits after decimal whereas Prisma returns a string for Decimal value which preserves all the digits after the decimal point.
e.g., using Float
type
Expected: 1498892.0256940164
Received: 1498892.025694016
expect(result.floatingNumber).toEqual(1498892.0256940164)
e.g., using Decimal
type
Expected: 7420440.088194787
Received: '7420440.088194787'
expect(result.floatingNumber).toEqual(7420440.088194787)
In the above examples, we can see expect doesn't preserve the floating numbers. Using decimals, the number is matched with the expected result.
For cases where using decimal is not optimal, see the Jest Expect documentation for other options and methods.
DateTime
Prisma returns DateTime as ISO 8601-formatted strings. So, you can convert the date to ISO String in JavaScript:
// Output: '2021-10-15T19:40:33.000Z'
const isoString = new Date('2021-10-15T19:40:33Z').toISOString()
Other Queries/Matchers
There are several other node/text types you can query against with React Testing Library, including title
, role
and alt
attributes, Form labels, placeholder text, and more.
If you still can't access the node or text you're looking for there's a fallback attribute you can add to any DOM element that can always be found: data-testid
which you can access using getByTestId
, queryByTestId
and others (but it involves including that attribute in your rendered HTML always, not just when running the test suite).
You can refer to the Cheatsheet from React Testing Library with the various permutations of getBy
, queryBy
and siblings.
The full list of available matchers like toBeInTheDocument()
and toHaveAttribute()
don't seem to have nice docs on the Testing Library site, but you can find them in the README inside the main repo.
In addition to testing for static things like text and attributes, you can also use fire events and check that the DOM responds as expected.
You can read more about these in below documentations:
Mocking GraphQL Calls
If you're using GraphQL inside your components, you can mock them to return the exact response you want and then focus on the content of the component being correct based on that data. Returning to our <Article> component, let's make an update where only the id
of the article is passed to the component as a prop and then the component itself is responsible for fetching the content from GraphQL:
Normally we recommend using a cell for exactly this functionality, but for the sake of completeness we're showing how to test when doing GraphQL queries the manual way!
import { useQuery } from '@cedarjs/web'
const GET_ARTICLE = gql`
query getArticle($id: Int!) {
article(id: $id) {
id
title
body
}
}
`
const Article = ({ id }) => {
const { data } = useQuery(GET_ARTICLE, { variables: { id } })
if (data) {
return (
<article>
<h1>{data.article.title}</h1>
<div>{data.article.body}</div>
</article>
)
} else {
return 'Loading...'
}
}
export default Article
mockGraphQLQuery()
Redwood provides the test function mockGraphQLQuery()
for providing the result of a given named GraphQL. In this case our query is named getArticle
and we can mock that in our test as follows:
import { render, screen } from '@cedarjs/testing/web'
import Article from 'src/components/Article'
describe('Article', () => {
it('renders the title of an article', async () => {
mockGraphQLQuery('getArticle', (variables) => {
return {
article: {
id: variables.id,
title: 'Foobar',
body: 'Lorem ipsum...',
}
}
})
render(<Article id={1} />)
expect(await screen.findByText('Foobar')).toBeInTheDocument()
})
})
We're using a new query here, findByText()
, which allows us to find things that may not be present in the first render of the component. In our case, when the component first renders, the data hasn't loaded yet, so it will render only "Loading..." which does not include the title of our article. Without it the test would immediately fail, but findByText()
is smart and waits for subsequent renders or a maximum amount of time before giving up.
Note that you need to make the test function async
and put an await
before the findByText()
call. Read more about findBy*()
queries and the higher level waitFor()
utility here.
The function that's given as the second argument to mockGraphQLQuery
will be sent a couple of arguments. The first—and only one we're using here—is variables
which will contain the variables given to the query when useQuery
was called. In this test we passed an id
of 1
to the <Article> component when test rendering, so variables
will contain {id: 1}
. Using this variable in the callback function to mockGraphQLQuery
allows us to reference those same variables in the body of our response. Here we're making sure that the returned article's id
is the same as the one that was requested:
return {
article: {
id: variables.id,
title: 'Foobar',
body: 'Lorem ipsum...',
},
}
Along with variables
there is a second argument: an object which you can destructure a couple of properties from. One of them is ctx
which is the context around the GraphQL response. One thing you can do with ctx
is simulate your GraphQL call returning an error:
mockGraphQLQuery('getArticle', (variables, { ctx }) => {
ctx.errors([{ message: 'Error' }])
})
You could then test that you show a proper error message in your component:
const Article = ({ id }) => {
const { data, error } = useQuery(GET_ARTICLE, {
variables: { id },
})
if (error) {
return <div>Sorry, there was an error</div>
}
if (data) {
// ...
}
}
// web/src/components/Article/Article.test.js
it('renders an error message', async () => {
mockGraphQLQuery('getArticle', (variables, { ctx }) => {
ctx.errors([{ message: 'Error' }])
})
render(<Article id={1} />)
expect(await screen.findByText('Sorry, there was an error')).toBeInTheDocument()
})
mockGraphQLMutation()
Similar to how we mocked GraphQL queries, we can mock mutations as well. Read more about GraphQL mocking in our Mocking GraphQL requests docs.
Mocking Auth
Most applications will eventually add Authentication/Authorization to the mix. How do we test that a component behaves a certain way when someone is logged in, or has a certain role?
Consider the following component (that happens to be a page) which displays a "welcome" message if the user is logged in, and a button to log in if they aren't:
import { useAuth } from '@cedarjs/auth'
const HomePage = () => {
const { isAuthenticated, currentUser, logIn } = useAuth()
return (
<>
<header>
{ isAuthenticated && <h1>Welcome back {currentUser.name}</h1> }
</header>
<main>
{ !isAuthenticated && <button onClick={logIn}>Login</button> }
</main>
</>
)
}
If we didn't do anything special, there would be no user logged in and we could only ever test the not-logged-in state:
import { render, screen } from '@cedarjs/testing/web'
import HomePage from './HomePage'
describe('HomePage', () => {
it('renders a login button', () => {
render(<HomePage />)
expect(screen.getByRole('button', { name: 'Login' })).toBeInTheDocument()
})
})
This test is a little more explicit in that it expects an actual <button>
element to exist and that it's label (name) be "Login". Being explicit with something as important as the login button can be a good idea, especially if you want to be sure that your site is friendly to screen-readers or another assistive browsing devices.
mockCurrentUser() on the Web-side
How do we test that when a user is logged in, it outputs a message welcoming them, and that the button is not present? Similar to mockGraphQLQuery()
Redwood also provides a mockCurrentUser()
which tells Redwood what to return when the getCurrentUser()
function of api/src/lib/auth.js
is invoked:
import { render, screen, waitFor } from '@cedarjs/testing/web'
import HomePage from './HomePage'
describe('HomePage', () => {
it('renders a login button when logged out', () => {
render(<HomePage />)
expect(screen.getByRole('button', { name: 'Login' })).toBeInTheDocument()
})
it('does not render a login button when logged in', async () => {
mockCurrentUser({ name: 'Rob' })
render(<HomePage />)
await waitFor(() => {
expect(
screen.queryByRole('button', { name: 'Login' })
).not.toBeInTheDocument()
})
})
it('renders a welcome message when logged in', async () => {
mockCurrentUser({ name: 'Rob' })
render(<HomePage />)
expect(await screen.findByText('Welcome back Rob')).toBeInTheDocument()
})
})
Here we call mockCurrentUser()
before the render()
call. Right now our code only references the name
of the current user, but you would want this object to include everything a real user contains, maybe an email
and an array of roles
.
We introduced waitFor()
which waits for a render update before passing/failing the expectation. Although findByRole()
will wait for an update, it will raise an error if the element is not found (similar to getByRole()
). So here we had to switch to queryByRole()
, but that version isn't async, so we added waitFor()
to get the async behavior back.
The async behavior here is important. Even after setting the user with mockCurrentUser()
, currentUser
may be null
during the initial render because it's being resolved. Waiting for a render update before passing/failing the exception gives the resolver a chance to execute and populate currentUser
.
Figuring out which assertions need to be async and which ones don't can be frustrating, we know. If you get a failing test when using
screen
you'll see the output of the DOM dumped along with the failure message, which helps find what went wrong. You can see exactly what the test saw (or didn't see) in the DOM at the time of the failure.If you see some text rendering that you're sure shouldn't be there (because maybe you have a conditional around whether or not to display it) this is a good indication that the test isn't waiting for a render update that would cause that conditional to render the opposite output. Change to a
findBy*
query or wrap theexpect()
in awaitFor()
and you should be good to go!
You may have noticed above that we created two tests, one for checking the button and one for checking the "welcome" message. This is a best practice in testing: keep your tests as small as possible by only testing one "thing" in each. If you find that you're using the word "and" in the name of your test (like "does not render a login button and renders a welcome message") that's a sign that your test is doing too much.
Mocking Roles
By including a list of roles
in the object returned from mockCurrentUser()
you are also mocking out calls to hasRole()
in your components so that they respond correctly as to whether currentUser
has an expected role or not.
Given a component that does something like this:
const { currentUser, hasRole } = useAuth()
return (
{ hasRole('admin') && <button onClick={deleteUser}>Delete User</button> }
)
You can test both cases (user does and does not have the "admin" role) with two separate mocks:
mockCurrentUser({ roles: ['admin'] })
mockCurrentUser({ roles: [] })
That's it!
Handling Duplication
We had to duplicate the mockCurrentUser()
call and duplication is usually another sign that things can be refactored. In Jest you can nest describe
blocks and include setup that is shared by the members of that block:
describe('HomePage', () => {
describe('logged out', () => {
it('renders a login button when logged out', () => {
render(<HomePage />)
expect(screen.getByRole('button', { name: 'Login' })).toBeInTheDocument()
})
})
describe('log in', () => {
beforeEach(() => {
mockCurrentUser({ name: 'Rob' })
render(<HomePage />)
})
it('does not render a login button when logged in', async () => {
await waitFor(() => {
expect(
screen.queryByRole('button', { name: 'Login' })
).not.toBeInTheDocument()
})
})
it('renders a welcome message when logged in', async () => {
expect(await screen.findByText('Welcome back Rob')).toBeInTheDocument()
})
})
})
While the primordial developer inside of you probably breathed a sign of relief seeing this refactor, heed this warning: the more deeply nested your tests become, the harder it is to read through the file and figure out what's in scope and what's not by the time your actual test is invoked. In our test above, if you just focused on the last test, you would have no idea that currentUser
is being mocked. Imagine a test file with dozens of tests and multiple levels of nested describe
s—it becomes a chore to scroll through and mentally keep track of what variables are in scope as you look for nested beforeEach()
blocks.
Some schools of thought say you should keep your test files flat (that is, no nesting) which trades ease of readability for duplication: when flat, each test is completely self contained and you know you can rely on just the code inside that test to determine what's in scope. It makes future test modifications easier because each test only relies on the code inside of itself. You may get nervous thinking about changing 10 identical instances of mockCurrentUser()
but that kind of thing is exactly what your IDE is good at!
For what it's worth, your humble author endorses the flat tests style.
Testing Custom Hooks
Custom hooks are a great way to encapsulate non-presentational code.
To test custom hooks, we'll use the renderHook
function from @cedarjs/testing/web
.
Note that Redwood's renderHook
function is based on React Testing Library's. The only difference is that Redwood's wraps everything with mock providers for the various providers in Redwood, such as auth, the GraphQL client, the router, etc.
If you were to use React Testing Library's renderHook
function, you'd need to provide your own wrapper function. In this case you probably want to compose the mock providers from @cedarjs/testing/web
:
import { renderHook, MockProviders } from '@cedarjs/testing/web'
// ...
renderHook(() => myCustomHook(), {
wrapper: ({ children }) => (
<MockProviders>
<MyCustomProvider>{children}</MyCustomProvider>
</MockProviders>
)
})
To use renderHook
:
- Call your custom hook from an inline function passed to
renderHook
. For example:
const { result } = renderHook(() => useAccumulator(0))
renderHook
will return an object with the following properties:
result
: holds the return value of the hook in itscurrent
property (soresult.current
). Think ofresult
as aref
for the most recently returned valuererender
: a function to render the previously rendered hook with new props
Let's go through an example. Given the following custom hook:
const useAccumulator = (initialValue) => {
const [total, setTotal] = useState(initialValue)
const add = (value) => {
const newTotal = total + value
setTotal(newTotal)
return newTotal
}
return { total, add }
}
The test could look as follows:
import { renderHook } from '@cedarjs/testing/web'
import { useAccumulator } from './useAccumulator'
describe('useAccumulator hook example in docs', () => {
it('has the correct initial state', () => {
const { result } = renderHook(() => useAccumulator(42))
expect(result.current.total).toBe(42)
})
it('adds a value', () => {
const { result } = renderHook(() => useAccumulator(1))
result.current.add(5)
expect(result.current.total).toBe(6)
})
it('adds multiple values', () => {
const { result } = renderHook(() => useAccumulator(0))
result.current.add(5)
result.current.add(10)
expect(result.current.total).toBe(15)
})
it('re-initializes the accumulator if passed a new initializing value', () => {
const { result, rerender } = renderHook(
(initialValue) => useAccumulator(initialValue),
{
initialProps: 0,
}
)
result.current.add(5)
rerender(99)
expect(result.current.total).toBe(99)
})
})
While renderHook
lets you test a custom hook directly, there are cases where encapsulating the custom hook in a component is more robust. See https://kentcdodds.com/blog/how-to-test-custom-react-hooks.
Testing Pages & Layouts
Pages and Layouts are just regular components so all the same techniques apply!
Testing Cells
Testing Cells is very similar to testing components: something is rendered to the DOM and you generally want to make sure that certain expected elements are present.
Two situations make testing Cells unique:
- A single Cell can export up to four separate components
- There's a GraphQL query taking place
The first situation is really no different from regular component testing: you just test more than one component in your test. For example:
import Article from 'src/components/Article'
export const QUERY = gql`
query GetArticle($id: Int!) {
article(id: $id) {
id
title
body
}
}
`
export const Loading = () => <div>Loading...</div>
export const Empty = () => <div>Empty</div>
export const Failure = ({ error }) => <div>Error: {error.message}</div>
export const Success = ({ article }) => {
return <Article article={article} />
}
Here we're exporting four components and if you created this Cell with the Cell generator then you'll already have four tests that make sure that each component renders without errors:
import { render, screen } from '@cedarjs/testing/web'
import { Loading, Empty, Failure, Success } from './ArticleCell'
import { standard } from './ArticleCell.mock'
describe('ArticleCell', () => {
it('renders Loading successfully', () => {
expect(() => {
render(<Loading />)
}).not.toThrow()
})
it('renders Empty successfully', async () => {
expect(() => {
render(<Empty />)
}).not.toThrow()
})
it('renders Failure successfully', async () => {
expect(() => {
render(<Failure error={new Error('Oh no')} />)
}).not.toThrow()
})
it('renders Success successfully', async () => {
expect(() => {
render(<Success article={standard().article} />)
}).not.toThrow()
})
})
You might think that "rendering without errors" is a pretty lame test, but it's actually a great start. In React something usually renders successfully or fails spectacularly, so here we're making sure that there are no obvious issues with each component.
You can expand on these tests just as you would with a regular component test: by checking that certain text in each component is present.
Cell Mocks
When the <Success> component is tested, what's this standard()
function that's passed as the article
prop?
If you used the Cell generator, you'll get a mocks.js
file along with the cell component and the test file:
export const standard = () => ({
article: {
__typename: 'Article',
id: 42,
},
})
Each mock will start with a standard()
function which has special significance (more on that later). The return of this function is the data you want to be returned from the GraphQL QUERY
defined at the top of your cell.
Something to note is that the structure of the data returned by your
QUERY
and the structure of the object returned by the mock is in no way required to be identical as far as Redwood is concerned. You could be querying for anarticle
but have the mock return ananimal
and the test will happily pass. Redwood just intercepts the GraphQL query and returns the mock data. This is something to keep in mind if you make major changes to yourQUERY
—be sure to make similar changes to your returned mock data or you could get falsely passing tests!
Why not just include this data inline in the test? We're about to reveal the answer in the next section, but before we do just a little more info about working with these mocks.js
file...
Once you start testing more scenarios you can add custom mocks with different names for use in your tests. For example, maybe you have a case where an article has no body, only a title, and you want to be sure that your component still renders correctly. You could create an additional mock that simulates this condition:
export const standard = () => ({
article: {
__typename: 'Article',
id: 1,
title: 'Foobar',
body: 'Lorem ipsum...',
},
})
export const missingBody = {
article: {
__typename: 'Article',
id: 2,
title: 'Barbaz',
body: null,
},
}
And then you just reference that new mock in your test:
import { render, screen } from '@cedarjs/testing/web'
import { Loading, Empty, Failure, Success } from './ArticleCell'
import { standard, missingBody } from './ArticleCell.mock'
describe('ArticleCell', () => {
/// other tests...
it('Success renders successfully', async () => {
expect(() => {
render(<Success article={standard().article} />)
}).not.toThrow()
})
it('Success renders successfully without a body', async () => {
expect(() => {
render(<Success article={missingBody.article} />)
}).not.toThrow()
})
})
Note that this second mock simply returns an object instead of a function. In the simplest case all you need your mock to return is an object. But there are cases where you may want to include logic in your mock, and in these cases you'll appreciate the function container. Especially in the following scenario...
If using fragments it is important to include the __typename
otherwise Apollo client will not be able to map the mocked data to the fragment attributes.
Testing Components That Include Cells
Consider the case where you have a page which renders a cell inside of it. You write a test for the page (using regular component testing techniques mentioned above). But if the page includes a cell, and a cell wants to run a GraphQL query, what happens when the page is rendered?
This is where the specially named standard()
mock comes into play: the GraphQL query in the cell will be intercepted and the response will be the content of the standard()
mock. This means that no matter how deeply nested your component/cell structure becomes, you can count on every cell in that stack rendering in a predictable way.
And this is where standard()
being a function becomes important. The GraphQL call is intercepted behind the scenes with the same mockGraphQLQuery()
function we learned about earlier. And since it's using that same function, the second argument (the function which runs to return the mocked data) receives the same arguments (variables
and an object with keys like ctx
).
So, all of that is to say that when standard()
is called it will receive the variables and context that goes along with every GraphQL query, and you can make use of that data in the standard()
mock. That means it's possible to, for example, look at the variables
that were passed in and conditionally return a different object.
Perhaps you have a products page that renders either in stock or out of stock products. You could inspect the status
that's passed into via variables.status
and return a different inventory count depending on whether the calling code wants in-stock or out-of-stock items:
export const standard = (variables) => {
return {
products: [
{
__typename: 'Product',
id: variables.id,
name: 'T-shirt',
inventory: variables.status === 'instock' ? 100 : 0
}
]
}
})
Assuming you had a <ProductPage> component:
import ProductCell from 'src/components/ProductCell'
const ProductPage = ({ status }) => {
return {
<div>
<h1>{ status === 'instock' ? 'In Stock' : 'Out of Stock' }</h1>
<ProductsCell status={status} />
</div>
}
}
Which, in your page test, would let you do something like:
import { render, screen } from '@cedarjs/testing/web'
import ArticleCell from 'src/components/ArticleCell'
describe('ProductPage', () => {
it('renders in stock products', () => {
render(<ProductPage status='instock' />)
expect(screen.getByText('In Stock')).toBeInTheDocument()
})
it('renders out of stock products', async () => {
render(<ProductPage status='outofstock' />)
expect(screen.getByText('Out of Stock')).toBeInTheDocument()
})
})
Be aware that if you do this, and continue to use the standard()
mock in your regular cell tests, you'll either need to start passing in variables
yourself:
describe('ArticleCell', () => {
/// other tests...
test('Success renders successfully', async () => {
expect(() => {
render(<Success article={standard({ status: 'instock' }).article} />)
}).not.toThrow()
})
})
Or conditionally check that variables
exists at all before basing any logic on them:
export const standard = (variables) => {
return {
product: {
__typename: 'Product',
id: variables?.id || 1,
name: 'T-shirt',
inventory: variables && variables.status === 'instock' ? 100 : 0
}
}
})