Gordon  Matlala

Gordon Matlala

1671194580

Complete Guide to Testing React Hooks

A relatively recent addition to React, hooks have already changed React development for the better through improved code readability and state management. But how do we test them? In this article, Toptal React Developer Avi Aryan outlines why it is crucial to test hooks and introduces us to his React Hooks testing routine.

Hooks were introduced in React 16.8 in late 2018. They are functions that hook into a functional component and allow us to use state and component features like componentDidUpdate, componentDidMount, and more. This was not possible before.

Also, hooks allow us to reuse component and state logic across different components. This was tricky to do before. Therefore, hooks have been a game-changer.

In this article, we will explore how to test React Hooks. We will pick a sufficiently complex hook and work on testing it.

We expect that you are an avid React developer already familiar with React Hooks. In case you want to brush up your knowledge, you should check out our tutorial, and here’s the link to the official documentation.

The Hook We Will Use for Testing

For this article, we will use a hook that I wrote in my previous article, Stale-while-revalidate Data Fetching with React Hooks. The hook is called useStaleRefresh. If you haven’t read the article, don’t worry as I will recap that part here.

This is the hook we will be testing:

import { useState, useEffect } from "react";
const CACHE = {};

export default function useStaleRefresh(url, defaultValue = []) {
  const [data, setData] = useState(defaultValue);
  const [isLoading, setLoading] = useState(true);

  useEffect(() => {
    // cacheID is how a cache is identified against a unique request
    const cacheID = url;
    // look in cache and set response if present
    if (CACHE[cacheID] !== undefined) {
      setData(CACHE[cacheID]);
      setLoading(false);
    } else {
      // else make sure loading set to true
      setLoading(true);
      setData(defaultValue);
    }
    // fetch new data
    fetch(url)
      .then((res) => res.json())
      .then((newData) => {
        CACHE[cacheID] = newData;
        setData(newData);
        setLoading(false);
      });
  }, [url, defaultValue]);

  return [data, isLoading];
}

As you can see, useStaleRefresh is a hook that helps fetch data from a URL while returning a cached version of the data, if it exists. It uses a simple in-memory store to hold the cache.

It also returns an isLoading value that is true if no data or cache is available yet. The client can use it to show a loading indicator. The isLoading value is set to false when cache or fresh response is available.

 

A flowchart tracking the stale-while-refresh logic

 

At this point, I will suggest you spend some time reading the above hook to get a complete understanding of what it does.

In this article, we will see how we can test this hook, first using no test libraries (only React Test Utilities and Jest) and then by using react-hooks-testing-library.

The motivation behind using no test libraries, i.e., only a test runner Jest, is to demonstrate how testing a hook works. With that knowledge, you will be able to debug any issues that may arise when using a library that provides testing abstraction.

Defining the Test Cases

Before we begin testing this hook, let’s come up with a plan of what we want to test. Since we know what the hook is supposed to do, here’s my eight-step plan for testing it:

  1. When the hook is mounted with URL url1, isLoading is true and data is defaultValue.
  2. After an asynchronous fetch request, the hook is updated with data data1 and isLoading is false.
  3. When the URL is changed to url2, isLoading becomes true again and data is defaultValue.
  4. After an asynchronous fetch request, the hook is updated with new data data2.
  5. Then, we change the URL back to url1. The data data1 is instantly received since it is cached. isLoading is false.
  6. After an asynchronous fetch request, when a fresh response is received, the data is updated to data3.
  7. Then, we change the URL back to url2. The data data2 is instantly received since it is cached. isLoading is false.
  8. After an asynchronous fetch request, when a fresh response is received, the data is updated to data4.

The test flow mentioned above clearly defines the trajectory of how the hook will function. Therefore, if we can ensure this test works, we are good.

 

Test flow

 

Testing Hooks Without a Library

In this section, we will see how to test hooks without using any libraries. This will provide us with an in-depth understanding of how to test React Hooks.

To begin this test, first, we would like to mock fetch. This is so we can have control over what the API returns. Here is the mocked fetch.

function fetchMock(url, suffix = "") {
  return new Promise((resolve) =>
    setTimeout(() => {
      resolve({
        json: () =>
          Promise.resolve({
            data: url + suffix,
          }),
      });
    }, 200 + Math.random() * 300)
  );
}

This modified fetch assumes that the response type is always JSON and it, by default, returns the parameter url as the data value. It also adds a random delay of between 200ms and 500ms to the response.

If we want to change the response, we simply set the second argument suffix to a non-empty string value.

At this point, you might ask, why the delay? Why don’t we just return the response instantly? This is because we want to replicate the real world as much as possible. We can’t test the hook correctly if we return it instantly. Sure, we can reduce the delay to 50-100ms for faster tests, but let’s not worry about that in this article.

With our fetch mock ready, we can set it to the fetch function. We use beforeAll and afterAll for doing so because this function is stateless so we don’t need to reset it after an individual test.

// runs before any tests start running
beforeAll(() => {
  jest.spyOn(global, "fetch").mockImplementation(fetchMock);
});

// runs after all tests have finished
afterAll(() => {
  global.fetch.mockClear();
});

Then, we need to mount the hook in a component. Why? Because hooks are just functions on their own. Only when used in components can they respond to useState, useEffect, etc.

So, we need to create a TestComponent that helps us mount our hook.

// defaultValue is a global variable to avoid changing the object pointer on re-render
// we can also deep compare `defaultValue` inside the hook's useEffect
const defaultValue = { data: "" };

function TestComponent({ url }) {
  const [data, isLoading] = useStaleRefresh(url, defaultValue);
  if (isLoading) {
    return <div>loading</div>;
  }
  return <div>{data.data}</div>;
}

This is a simple component that either renders the data or renders a “Loading” text prompt if data is loading (being fetched).

Once we have the test component, we need to mount it on the DOM. We use beforeEach and afterEach to mount and unmount our component for each test because we want to start with a fresh DOM before each test.

let container = null;

beforeEach(() => {
  // set up a DOM element as a render target
  container = document.createElement("div");
  document.body.appendChild(container);
});

afterEach(() => {
  // cleanup on exiting
  unmountComponentAtNode(container);
  container.remove();
  container = null;
});

Notice that container has to be a global variable since we want to have access to it for test assertions.

With that set, let’s do our first test where we render a URL url1, and since fetching the URL will take some time (see fetchMock), it should render “loading” text initially.

it("useStaleRefresh hook runs correctly", () => {
  act(() => {
    render(<TestComponent url="url1" />, container);
  });
  expect(container.textContent).toBe("loading");
})

Run the test using yarn test, and it works as expected. Here’s the complete code on GitHub.

Now, let’s test when this loading text changes to the fetched response data, url1.

How do we do that? If you look at fetchMock, you see we wait for 200-500 milliseconds. What if we put a sleep in the test that waits for 500 milliseconds? It will cover all possible wait times. Let’s try that.

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

it("useStaleRefresh hook runs correctly", async () => {
  act(() => {
    render(<TestComponent url="url1" />, container);
  });
  expect(container.textContent).toBe("loading");

  await sleep(500);
  expect(container.textContent).toBe("url1");
});

The test passes, but we see an error as well (code).

 PASS  src/useStaleRefresh.test.js
  ✓ useStaleRefresh hook runs correctly (519ms)

  console.error node_modules/react-dom/cjs/react-dom.development.js:88
    Warning: An update to TestComponent inside a test was not wrapped in act(...).

This is because the state update in useStaleRefresh hook happens outside act(). To make sure DOM updates are processed timely, React recommends you use act() around every time a re-render or UI update might happen. So, we need to wrap our sleep with act as this is the time the state update happens. After doing so, the error goes away.

import { act } from "react-dom/test-utils";
// ...
await act(() => sleep(500));

Now, run it again (code on GitHub). As expected, it passes without errors.

Let’s test the next situation where we first change the URL to url2, then check the loading screen, then wait for fetch response, and finally check the url2 text. Since we now know how to correctly wait for async changes, this should be easy.

act(() => {
  render(<TestComponent url="url2" />, container);
});
expect(container.textContent).toContain("loading");

await act(() => sleep(500));
expect(container.textContent).toBe("url2");

Run this test, and it passes as well. Now, we can also test the case where response data changes and the cache comes into play.

You will notice that we have an additional argument suffix in our fetchMock function. This is for changing the response data. So we update our fetch mock to use the suffix.

global.fetch.mockImplementation((url) => fetchMock(url, "__"));

Now, we can test the case where the URL is set to url1 again. It first loads url1 and then url1__. We can do the same for url2, and there should be no surprises.

it("useStaleRefresh hook runs correctly", async () => {
  // ...
  // new response
  global.fetch.mockImplementation((url) => fetchMock(url, "__"));

  // set url to url1 again
  act(() => {
    render(<TestComponent url="url1" />, container);
  });
  expect(container.textContent).toBe("url1");
  await act(() => sleep(500));
  expect(container.textContent).toBe("url1__");

  // set url to url2 again
  act(() => {
    render(<TestComponent url="url2" />, container);
  });
  expect(container.textContent).toBe("url2");
  await act(() => sleep(500));
  expect(container.textContent).toBe("url2__");
});

This entire test gives us the confidence that the hook does indeed work as expected (code). Hurray! Now, let’s take a quick look at optimization using helper methods.

Optimizing Testing by Using Helper Methods

So far, we have seen how to completely test our hook. The approach is not perfect but it works. And yet, can we do better?

Yes. Notice that we are waiting for a fixed 500ms for each fetch to be completed, but each request takes anything from 200 to 500ms. So, we are clearly wasting time here. We can handle this better by just waiting for the time each request takes.

How do we do that? A simple technique is executing the assertion until it passes or a timeout is reached. Let’s create a waitFor function that does that.

async function waitFor(cb, timeout = 500) {
  const step = 10;
  let timeSpent = 0;
  let timedOut = false;

  while (true) {
    try {
      await sleep(step);
      timeSpent += step;
      cb();
      break;
    } catch {}
    if (timeSpent >= timeout) {
      timedOut = true;
      break;
    }
  }

  if (timedOut) {
    throw new Error("timeout");
  }
}

This function simply runs a callback (cb) inside a try...catch block every 10ms, and if the timeout is reached, it throws an error. This allows us to run an assertion until it passes in a safe manner (i.e., no infinite loops).

We can use it in our test as follows: Instead of sleeping for 500ms and then asserting, we use our waitFor function.

// INSTEAD OF 
await act(() => sleep(500));
expect(container.textContent).toBe("url1");
// WE DO
await act(() =>
  waitFor(() => {
    expect(container.textContent).toBe("url1");
  })
);

Do it in all such assertions, and we can see a considerable difference in how fast our test runs (code).

Now, all this is great, but maybe we don’t want to test the hook via UI. Maybe we want to test a hook using its return values. How do we do that?

It won’t be difficult because we already have access to our hook’s return values. They are just inside the component. If we can take those variables out to the global scope, it will work. So let’s do that.

Since we will be testing our hook via its return value and not rendered DOM, we can remove the HTML render from our component and make it render null. We should also remove the destructuring in hook’s return to make it more generic. Thus, we have this updated test component.

// global variable
let result;

function TestComponent({ url }) {
  result = useStaleRefresh(url, defaultValue);
  return null;
}

Now the hook’s return value is stored in result, a global variable. We can query it for our assertions.

// INSTEAD OF
expect(container.textContent).toContain("loading");
// WE DO
expect(result[1]).toBe(true);

// INSTEAD OF 
expect(container.textContent).toBe("url1");
// WE DO
expect(result[0].data).toBe("url1");

After we change it everywhere, we can see our tests are passing (code).

At this point, we get the gist of testing React Hooks. There are a few improvements we can still make, such as:

  1. Moving result variable to a local scope
  2. Removing the need to create a component for every hook we want to test

We can do it by creating a factory function that has a test component inside it. It should also render the hook in the test component and give us access to the result variable. Let’s see how we can do that.

First, we move TestComponent and result inside the function. We will also need to pass Hook and the Hook arguments as function’s arguments so that they can be used in our test component. Using that, here’s what we have. We are calling this function renderHook.

function renderHook(hook, args) {
  let result = {};

  function TestComponent({ hookArgs }) {
    result.current = hook(...hookArgs);
    return null;
  }

  act(() => {
    render(<TestComponent hookArgs={args} />, container);
  });

  return result;
}

The reason we have result as an object that stores data in result.current is because we want the return values to be updated as the test runs. The return value of our hook is an array, so it would have been copied by value if we returned it directly. By storing it in an object, we return a reference to that object so the return values can be updated by updating result.current.

Now, how do we go about updating the hook? Since we are already using a closure, let’s enclose another function rerender that can do that.

The final renderHook function looks like this:

function renderHook(hook, args) {
  let result = {};

  function TestComponent({ hookArgs }) {
    result.current = hook(...hookArgs);
    return null;
  }

  function rerender(args) {
    act(() => {
      render(<TestComponent hookArgs={args} />, container);
    });
  }

  rerender(args);
  return { result, rerender };
}

Now, we can use it in our test. Instead of using act and render, we do the following:

const { rerender, result } = renderHook(useStaleRefresh, [
  "url1",
  defaultValue,
]);

Then, we can assert using result.current and update the hook using rerender. Here’s a simple example:

rerender(["url2", defaultValue]);
expect(result.current[1]).toBe(true); // check isLoading is true

Once you change it in all places, you will see it works without any problems (code).

Brilliant! Now we have a much cleaner abstraction to test hooks. We can still do better - for example, defaultValue needs to be passed every time to rerender even though it doesn’t change. We can fix that.

But let’s not beat around the bush too much as we already have a library that improves this experience significantly.

Enter react-hooks-testing-library.

Testing Using React-hooks-testing-library

React-hooks-testing-library does everything we have talked about before and then some. For example, it handles container mounting and unmounting so you don’t have to do that in your test file. This allows us to focus on testing our hooks without getting distracted.

It comes with a renderHook function that returns rerender and result. It also returns wait, which is similar to waitFor, so you don’t have to implement it yourself.

Here is how we render a hook in React-hooks-testing-library. Notice the hook is passed in the form of a callback. This callback is run every time the test component re-renders.

const { result, wait, rerender } = renderHook(
  ({ url }) => useStaleRefresh(url, defaultValue),
  {
    initialProps: {
      url: "url1",
    },
  }
);

Then, we can test if the first render resulted in isLoading as true and return value as defaultValue by doing this. Exactly similar to what we implemented above.

expect(result.current[0]).toEqual(defaultValue);
expect(result.current[1]).toBe(true);

To test for async updates, we can use the wait method that renderHook returned. It comes wrapped with act() so we don’t need to wrap act() around it.

await wait(() => {
  expect(result.current[0].data).toEqual("url1");
});
expect(result.current[1]).toBe(false);

Then, we can use rerender to update it with new props. Notice we don’t need to pass defaultValue here.

rerender({ url: "url2" });

Finally, the rest of the test will proceed similarly (code).

Wrapping Up

My aim was to show you how to test React Hooks by taking an example of an async hook. I hope this helps you confidently tackle the testing of any kind of hook, as the same approach should apply to most of them.

I would recommend you use React-hooks-testing-library since it’s complete, and I haven’t run into significant problems with it thus far. In case you do encounter a problem, you now know how to approach it using the intricacies of testing hooks described in this article.

Original article source at: https://www.toptal.com/

#react #hooks #testing 

What is GEEK

Buddha Community

Complete Guide to Testing React Hooks
Autumn  Blick

Autumn Blick

1598839687

How native is React Native? | React Native vs Native App Development

If you are undertaking a mobile app development for your start-up or enterprise, you are likely wondering whether to use React Native. As a popular development framework, React Native helps you to develop near-native mobile apps. However, you are probably also wondering how close you can get to a native app by using React Native. How native is React Native?

In the article, we discuss the similarities between native mobile development and development using React Native. We also touch upon where they differ and how to bridge the gaps. Read on.

A brief introduction to React Native

Let’s briefly set the context first. We will briefly touch upon what React Native is and how it differs from earlier hybrid frameworks.

React Native is a popular JavaScript framework that Facebook has created. You can use this open-source framework to code natively rendering Android and iOS mobile apps. You can use it to develop web apps too.

Facebook has developed React Native based on React, its JavaScript library. The first release of React Native came in March 2015. At the time of writing this article, the latest stable release of React Native is 0.62.0, and it was released in March 2020.

Although relatively new, React Native has acquired a high degree of popularity. The “Stack Overflow Developer Survey 2019” report identifies it as the 8th most loved framework. Facebook, Walmart, and Bloomberg are some of the top companies that use React Native.

The popularity of React Native comes from its advantages. Some of its advantages are as follows:

  • Performance: It delivers optimal performance.
  • Cross-platform development: You can develop both Android and iOS apps with it. The reuse of code expedites development and reduces costs.
  • UI design: React Native enables you to design simple and responsive UI for your mobile app.
  • 3rd party plugins: This framework supports 3rd party plugins.
  • Developer community: A vibrant community of developers support React Native.

Why React Native is fundamentally different from earlier hybrid frameworks

Are you wondering whether React Native is just another of those hybrid frameworks like Ionic or Cordova? It’s not! React Native is fundamentally different from these earlier hybrid frameworks.

React Native is very close to native. Consider the following aspects as described on the React Native website:

  • Access to many native platforms features: The primitives of React Native render to native platform UI. This means that your React Native app will use many native platform APIs as native apps would do.
  • Near-native user experience: React Native provides several native components, and these are platform agnostic.
  • The ease of accessing native APIs: React Native uses a declarative UI paradigm. This enables React Native to interact easily with native platform APIs since React Native wraps existing native code.

Due to these factors, React Native offers many more advantages compared to those earlier hybrid frameworks. We now review them.

#android app #frontend #ios app #mobile app development #benefits of react native #is react native good for mobile app development #native vs #pros and cons of react native #react mobile development #react native development #react native experience #react native framework #react native ios vs android #react native pros and cons #react native vs android #react native vs native #react native vs native performance #react vs native #why react native #why use react native

What are hooks in React JS? - INFO AT ONE

In this article, you will learn what are hooks in React JS? and when to use react hooks? React JS is developed by Facebook in the year 2013. There are many students and the new developers who have confusion between react and hooks in react. Well, it is not different, react is a programming language and hooks is a function which is used in react programming language.
Read More:- https://infoatone.com/what-are-hooks-in-react-js/

#react #hooks in react #react hooks example #react js projects for beginners #what are hooks in react js? #when to use react hooks

React-hooks-testing-library: Simple & Complete React Hooks Testing

react-hooks-testing-library 

Simple and complete React hooks testing utilities that encourage good testing practices.


Note about React 18 Support

As part of the changes for React 18, it has been decided that the renderHook API provided by this library will instead be included as official additions to both react-testing-library (PR) and react-native-testing-library (PR) with the intention being to provide a more cohesive and consistent implementation for our users.

Please be patient as we finalise these changes in the respective testing libraries.


The problem

You're writing an awesome custom hook and you want to test it, but as soon as you call it you see the following error:

Invariant Violation: Hooks can only be called inside the body of a function component.

You don't really want to write a component solely for testing this hook and have to work out how you were going to trigger all the various ways the hook can be updated, especially given the complexities of how you've wired the whole thing together.

The solution

The react-hooks-testing-library allows you to create a simple test harness for React hooks that handles running them within the body of a function component, as well as providing various useful utility functions for updating the inputs and retrieving the outputs of your amazing custom hook. This library aims to provide a testing experience as close as possible to natively using your hook from within a real component.

Using this library, you do not have to concern yourself with how to construct, render or interact with the react component in order to test your hook. You can just use the hook directly and assert the results.

When to use this library

  1. You're writing a library with one or more custom hooks that are not directly tied to a component
  2. You have a complex hook that is difficult to test through component interactions

When not to use this library

  1. Your hook is defined alongside a component and is only used there
  2. Your hook is easy to test by just testing the components using it

Example

useCounter.js

import { useState, useCallback } from 'react'

function useCounter() {
  const [count, setCount] = useState(0)

  const increment = useCallback(() => setCount((x) => x + 1), [])

  return { count, increment }
}

export default useCounter

useCounter.test.js

import { renderHook, act } from '@testing-library/react-hooks'
import useCounter from './useCounter'

test('should increment counter', () => {
  const { result } = renderHook(() => useCounter())

  act(() => {
    result.current.increment()
  })

  expect(result.current.count).toBe(1)
})

More advanced usage can be found in the documentation.

Installation

npm install --save-dev @testing-library/react-hooks

Peer Dependencies

react-hooks-testing-library does not come bundled with a version of react to allow you to install the specific version you want to test against. It also does not come installed with a specific renderer, we currently support react-test-renderer and react-dom. You only need to install one of them, however, if you do have both installed, we will use react-test-renderer as the default. For more information see the installation docs. Generally, the installed versions for react and the selected renderer should have matching versions:

npm install react@^16.9.0
npm install --save-dev react-test-renderer@^16.9.0

NOTE: The minimum supported version of react, react-test-renderer and react-dom is ^16.9.0.

API

See the API reference.

Issues

Looking to contribute? Look for the Good First Issue label.

🐛 Bugs

Please file an issue for bugs, missing documentation, or unexpected behavior.

See Bugs

💡 Feature Requests

Please file an issue to suggest new features. Vote on feature requests by adding a 👍. This helps maintainers prioritize what to work on.

See Feature Requests

❓ Questions

For questions related to using the library, you can raise issue here, or visit a support community:

Read The Docs 

Author: Testing-library
Source Code: https://github.com/testing-library/react-hooks-testing-library 
License: MIT license

#react #javascript #typescript #hooks #testing 

Hayden Slater

1599277908

Validating React Forms With React-Hook-Form

Validating inputs is very often required. For example, when you want to make sure two passwords inputs are the same, an email input should in fact be an email or that the input is not too long. This is can be easily done using React Hook From. In this article, I will show you how.

Required Fields

The most simple, yet very common, validation is to make sure that an input component contains input from the user. React Hook Form basic concept is to register input tags to the form by passing register() to the tag’s ref attribute. As we can see here:

#react-native #react #react-hook-form #react-hook

Gordon  Matlala

Gordon Matlala

1671194580

Complete Guide to Testing React Hooks

A relatively recent addition to React, hooks have already changed React development for the better through improved code readability and state management. But how do we test them? In this article, Toptal React Developer Avi Aryan outlines why it is crucial to test hooks and introduces us to his React Hooks testing routine.

Hooks were introduced in React 16.8 in late 2018. They are functions that hook into a functional component and allow us to use state and component features like componentDidUpdate, componentDidMount, and more. This was not possible before.

Also, hooks allow us to reuse component and state logic across different components. This was tricky to do before. Therefore, hooks have been a game-changer.

In this article, we will explore how to test React Hooks. We will pick a sufficiently complex hook and work on testing it.

We expect that you are an avid React developer already familiar with React Hooks. In case you want to brush up your knowledge, you should check out our tutorial, and here’s the link to the official documentation.

The Hook We Will Use for Testing

For this article, we will use a hook that I wrote in my previous article, Stale-while-revalidate Data Fetching with React Hooks. The hook is called useStaleRefresh. If you haven’t read the article, don’t worry as I will recap that part here.

This is the hook we will be testing:

import { useState, useEffect } from "react";
const CACHE = {};

export default function useStaleRefresh(url, defaultValue = []) {
  const [data, setData] = useState(defaultValue);
  const [isLoading, setLoading] = useState(true);

  useEffect(() => {
    // cacheID is how a cache is identified against a unique request
    const cacheID = url;
    // look in cache and set response if present
    if (CACHE[cacheID] !== undefined) {
      setData(CACHE[cacheID]);
      setLoading(false);
    } else {
      // else make sure loading set to true
      setLoading(true);
      setData(defaultValue);
    }
    // fetch new data
    fetch(url)
      .then((res) => res.json())
      .then((newData) => {
        CACHE[cacheID] = newData;
        setData(newData);
        setLoading(false);
      });
  }, [url, defaultValue]);

  return [data, isLoading];
}

As you can see, useStaleRefresh is a hook that helps fetch data from a URL while returning a cached version of the data, if it exists. It uses a simple in-memory store to hold the cache.

It also returns an isLoading value that is true if no data or cache is available yet. The client can use it to show a loading indicator. The isLoading value is set to false when cache or fresh response is available.

 

A flowchart tracking the stale-while-refresh logic

 

At this point, I will suggest you spend some time reading the above hook to get a complete understanding of what it does.

In this article, we will see how we can test this hook, first using no test libraries (only React Test Utilities and Jest) and then by using react-hooks-testing-library.

The motivation behind using no test libraries, i.e., only a test runner Jest, is to demonstrate how testing a hook works. With that knowledge, you will be able to debug any issues that may arise when using a library that provides testing abstraction.

Defining the Test Cases

Before we begin testing this hook, let’s come up with a plan of what we want to test. Since we know what the hook is supposed to do, here’s my eight-step plan for testing it:

  1. When the hook is mounted with URL url1, isLoading is true and data is defaultValue.
  2. After an asynchronous fetch request, the hook is updated with data data1 and isLoading is false.
  3. When the URL is changed to url2, isLoading becomes true again and data is defaultValue.
  4. After an asynchronous fetch request, the hook is updated with new data data2.
  5. Then, we change the URL back to url1. The data data1 is instantly received since it is cached. isLoading is false.
  6. After an asynchronous fetch request, when a fresh response is received, the data is updated to data3.
  7. Then, we change the URL back to url2. The data data2 is instantly received since it is cached. isLoading is false.
  8. After an asynchronous fetch request, when a fresh response is received, the data is updated to data4.

The test flow mentioned above clearly defines the trajectory of how the hook will function. Therefore, if we can ensure this test works, we are good.

 

Test flow

 

Testing Hooks Without a Library

In this section, we will see how to test hooks without using any libraries. This will provide us with an in-depth understanding of how to test React Hooks.

To begin this test, first, we would like to mock fetch. This is so we can have control over what the API returns. Here is the mocked fetch.

function fetchMock(url, suffix = "") {
  return new Promise((resolve) =>
    setTimeout(() => {
      resolve({
        json: () =>
          Promise.resolve({
            data: url + suffix,
          }),
      });
    }, 200 + Math.random() * 300)
  );
}

This modified fetch assumes that the response type is always JSON and it, by default, returns the parameter url as the data value. It also adds a random delay of between 200ms and 500ms to the response.

If we want to change the response, we simply set the second argument suffix to a non-empty string value.

At this point, you might ask, why the delay? Why don’t we just return the response instantly? This is because we want to replicate the real world as much as possible. We can’t test the hook correctly if we return it instantly. Sure, we can reduce the delay to 50-100ms for faster tests, but let’s not worry about that in this article.

With our fetch mock ready, we can set it to the fetch function. We use beforeAll and afterAll for doing so because this function is stateless so we don’t need to reset it after an individual test.

// runs before any tests start running
beforeAll(() => {
  jest.spyOn(global, "fetch").mockImplementation(fetchMock);
});

// runs after all tests have finished
afterAll(() => {
  global.fetch.mockClear();
});

Then, we need to mount the hook in a component. Why? Because hooks are just functions on their own. Only when used in components can they respond to useState, useEffect, etc.

So, we need to create a TestComponent that helps us mount our hook.

// defaultValue is a global variable to avoid changing the object pointer on re-render
// we can also deep compare `defaultValue` inside the hook's useEffect
const defaultValue = { data: "" };

function TestComponent({ url }) {
  const [data, isLoading] = useStaleRefresh(url, defaultValue);
  if (isLoading) {
    return <div>loading</div>;
  }
  return <div>{data.data}</div>;
}

This is a simple component that either renders the data or renders a “Loading” text prompt if data is loading (being fetched).

Once we have the test component, we need to mount it on the DOM. We use beforeEach and afterEach to mount and unmount our component for each test because we want to start with a fresh DOM before each test.

let container = null;

beforeEach(() => {
  // set up a DOM element as a render target
  container = document.createElement("div");
  document.body.appendChild(container);
});

afterEach(() => {
  // cleanup on exiting
  unmountComponentAtNode(container);
  container.remove();
  container = null;
});

Notice that container has to be a global variable since we want to have access to it for test assertions.

With that set, let’s do our first test where we render a URL url1, and since fetching the URL will take some time (see fetchMock), it should render “loading” text initially.

it("useStaleRefresh hook runs correctly", () => {
  act(() => {
    render(<TestComponent url="url1" />, container);
  });
  expect(container.textContent).toBe("loading");
})

Run the test using yarn test, and it works as expected. Here’s the complete code on GitHub.

Now, let’s test when this loading text changes to the fetched response data, url1.

How do we do that? If you look at fetchMock, you see we wait for 200-500 milliseconds. What if we put a sleep in the test that waits for 500 milliseconds? It will cover all possible wait times. Let’s try that.

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

it("useStaleRefresh hook runs correctly", async () => {
  act(() => {
    render(<TestComponent url="url1" />, container);
  });
  expect(container.textContent).toBe("loading");

  await sleep(500);
  expect(container.textContent).toBe("url1");
});

The test passes, but we see an error as well (code).

 PASS  src/useStaleRefresh.test.js
  ✓ useStaleRefresh hook runs correctly (519ms)

  console.error node_modules/react-dom/cjs/react-dom.development.js:88
    Warning: An update to TestComponent inside a test was not wrapped in act(...).

This is because the state update in useStaleRefresh hook happens outside act(). To make sure DOM updates are processed timely, React recommends you use act() around every time a re-render or UI update might happen. So, we need to wrap our sleep with act as this is the time the state update happens. After doing so, the error goes away.

import { act } from "react-dom/test-utils";
// ...
await act(() => sleep(500));

Now, run it again (code on GitHub). As expected, it passes without errors.

Let’s test the next situation where we first change the URL to url2, then check the loading screen, then wait for fetch response, and finally check the url2 text. Since we now know how to correctly wait for async changes, this should be easy.

act(() => {
  render(<TestComponent url="url2" />, container);
});
expect(container.textContent).toContain("loading");

await act(() => sleep(500));
expect(container.textContent).toBe("url2");

Run this test, and it passes as well. Now, we can also test the case where response data changes and the cache comes into play.

You will notice that we have an additional argument suffix in our fetchMock function. This is for changing the response data. So we update our fetch mock to use the suffix.

global.fetch.mockImplementation((url) => fetchMock(url, "__"));

Now, we can test the case where the URL is set to url1 again. It first loads url1 and then url1__. We can do the same for url2, and there should be no surprises.

it("useStaleRefresh hook runs correctly", async () => {
  // ...
  // new response
  global.fetch.mockImplementation((url) => fetchMock(url, "__"));

  // set url to url1 again
  act(() => {
    render(<TestComponent url="url1" />, container);
  });
  expect(container.textContent).toBe("url1");
  await act(() => sleep(500));
  expect(container.textContent).toBe("url1__");

  // set url to url2 again
  act(() => {
    render(<TestComponent url="url2" />, container);
  });
  expect(container.textContent).toBe("url2");
  await act(() => sleep(500));
  expect(container.textContent).toBe("url2__");
});

This entire test gives us the confidence that the hook does indeed work as expected (code). Hurray! Now, let’s take a quick look at optimization using helper methods.

Optimizing Testing by Using Helper Methods

So far, we have seen how to completely test our hook. The approach is not perfect but it works. And yet, can we do better?

Yes. Notice that we are waiting for a fixed 500ms for each fetch to be completed, but each request takes anything from 200 to 500ms. So, we are clearly wasting time here. We can handle this better by just waiting for the time each request takes.

How do we do that? A simple technique is executing the assertion until it passes or a timeout is reached. Let’s create a waitFor function that does that.

async function waitFor(cb, timeout = 500) {
  const step = 10;
  let timeSpent = 0;
  let timedOut = false;

  while (true) {
    try {
      await sleep(step);
      timeSpent += step;
      cb();
      break;
    } catch {}
    if (timeSpent >= timeout) {
      timedOut = true;
      break;
    }
  }

  if (timedOut) {
    throw new Error("timeout");
  }
}

This function simply runs a callback (cb) inside a try...catch block every 10ms, and if the timeout is reached, it throws an error. This allows us to run an assertion until it passes in a safe manner (i.e., no infinite loops).

We can use it in our test as follows: Instead of sleeping for 500ms and then asserting, we use our waitFor function.

// INSTEAD OF 
await act(() => sleep(500));
expect(container.textContent).toBe("url1");
// WE DO
await act(() =>
  waitFor(() => {
    expect(container.textContent).toBe("url1");
  })
);

Do it in all such assertions, and we can see a considerable difference in how fast our test runs (code).

Now, all this is great, but maybe we don’t want to test the hook via UI. Maybe we want to test a hook using its return values. How do we do that?

It won’t be difficult because we already have access to our hook’s return values. They are just inside the component. If we can take those variables out to the global scope, it will work. So let’s do that.

Since we will be testing our hook via its return value and not rendered DOM, we can remove the HTML render from our component and make it render null. We should also remove the destructuring in hook’s return to make it more generic. Thus, we have this updated test component.

// global variable
let result;

function TestComponent({ url }) {
  result = useStaleRefresh(url, defaultValue);
  return null;
}

Now the hook’s return value is stored in result, a global variable. We can query it for our assertions.

// INSTEAD OF
expect(container.textContent).toContain("loading");
// WE DO
expect(result[1]).toBe(true);

// INSTEAD OF 
expect(container.textContent).toBe("url1");
// WE DO
expect(result[0].data).toBe("url1");

After we change it everywhere, we can see our tests are passing (code).

At this point, we get the gist of testing React Hooks. There are a few improvements we can still make, such as:

  1. Moving result variable to a local scope
  2. Removing the need to create a component for every hook we want to test

We can do it by creating a factory function that has a test component inside it. It should also render the hook in the test component and give us access to the result variable. Let’s see how we can do that.

First, we move TestComponent and result inside the function. We will also need to pass Hook and the Hook arguments as function’s arguments so that they can be used in our test component. Using that, here’s what we have. We are calling this function renderHook.

function renderHook(hook, args) {
  let result = {};

  function TestComponent({ hookArgs }) {
    result.current = hook(...hookArgs);
    return null;
  }

  act(() => {
    render(<TestComponent hookArgs={args} />, container);
  });

  return result;
}

The reason we have result as an object that stores data in result.current is because we want the return values to be updated as the test runs. The return value of our hook is an array, so it would have been copied by value if we returned it directly. By storing it in an object, we return a reference to that object so the return values can be updated by updating result.current.

Now, how do we go about updating the hook? Since we are already using a closure, let’s enclose another function rerender that can do that.

The final renderHook function looks like this:

function renderHook(hook, args) {
  let result = {};

  function TestComponent({ hookArgs }) {
    result.current = hook(...hookArgs);
    return null;
  }

  function rerender(args) {
    act(() => {
      render(<TestComponent hookArgs={args} />, container);
    });
  }

  rerender(args);
  return { result, rerender };
}

Now, we can use it in our test. Instead of using act and render, we do the following:

const { rerender, result } = renderHook(useStaleRefresh, [
  "url1",
  defaultValue,
]);

Then, we can assert using result.current and update the hook using rerender. Here’s a simple example:

rerender(["url2", defaultValue]);
expect(result.current[1]).toBe(true); // check isLoading is true

Once you change it in all places, you will see it works without any problems (code).

Brilliant! Now we have a much cleaner abstraction to test hooks. We can still do better - for example, defaultValue needs to be passed every time to rerender even though it doesn’t change. We can fix that.

But let’s not beat around the bush too much as we already have a library that improves this experience significantly.

Enter react-hooks-testing-library.

Testing Using React-hooks-testing-library

React-hooks-testing-library does everything we have talked about before and then some. For example, it handles container mounting and unmounting so you don’t have to do that in your test file. This allows us to focus on testing our hooks without getting distracted.

It comes with a renderHook function that returns rerender and result. It also returns wait, which is similar to waitFor, so you don’t have to implement it yourself.

Here is how we render a hook in React-hooks-testing-library. Notice the hook is passed in the form of a callback. This callback is run every time the test component re-renders.

const { result, wait, rerender } = renderHook(
  ({ url }) => useStaleRefresh(url, defaultValue),
  {
    initialProps: {
      url: "url1",
    },
  }
);

Then, we can test if the first render resulted in isLoading as true and return value as defaultValue by doing this. Exactly similar to what we implemented above.

expect(result.current[0]).toEqual(defaultValue);
expect(result.current[1]).toBe(true);

To test for async updates, we can use the wait method that renderHook returned. It comes wrapped with act() so we don’t need to wrap act() around it.

await wait(() => {
  expect(result.current[0].data).toEqual("url1");
});
expect(result.current[1]).toBe(false);

Then, we can use rerender to update it with new props. Notice we don’t need to pass defaultValue here.

rerender({ url: "url2" });

Finally, the rest of the test will proceed similarly (code).

Wrapping Up

My aim was to show you how to test React Hooks by taking an example of an async hook. I hope this helps you confidently tackle the testing of any kind of hook, as the same approach should apply to most of them.

I would recommend you use React-hooks-testing-library since it’s complete, and I haven’t run into significant problems with it thus far. In case you do encounter a problem, you now know how to approach it using the intricacies of testing hooks described in this article.

Original article source at: https://www.toptal.com/

#react #hooks #testing