Chapter 7: Introducing Testing to Rematch

In this chapter, we'll learn how to correctly test a real application using the best practices and the latest technologies out there. We'll learn about the differences between different types of testing and how our application can be easily refactored if our testing suite covers enough code to give us the confidence to move pieces of code without breaking the functionality.

This chapter is important to understanding testing concepts and the different libraries for testing. Also, it's important for understanding how Rematch models can be tested and learning about different concepts such as rootReducers that haven't been covered yet.

In this chapter, we'll cover the following topics:

  • Introduction to JavaScript testing
  • Preparing the environment
  • Creating tests for Rematch models
  • Creating tests for React components

By the end of the chapter, you will have learned what types of JavaScript testing exist nowadays and how they evolved to develop applications with confidence. Also, you'll learn how to properly set up these tools and how Rematch's models and React components can be tested together or independently. Also, you'll learn which metrics of testing are interesting and useful for our application.

Technical requirements

To follow along with this chapter, you will need the following:

  • Basic knowledge of vanilla JavaScript and ES6 features
  • Basic knowledge of HTML5 features
  • Node.js >= 12
  • Basic knowledge of React and CSS
  • A browser (Chrome or Firefox, for instance)
  • A code editor (Visual Studio Code, for instance)

You can find the code for this chapter in the book's GitHub repository: https://github.com/PacktPublishing/Redux-Made-Easy-with-Rematch/tree/main/packages/chapter-7.

Introduction to JavaScript testing

A couple of years ago, JavaScript testing was really obfuscated because no one used to care about testing front-end websites. HTML and CSS were not tested – no one even thought to test it – and JavaScript testing was strange to see. One of the main problems was that a lot of developers were coming from Java, where JUnit introduced everything needed to test an application – a test runner, a library to write the tests, an assertion library – and no equivalents existed for JavaScript.

We had to install three libraries to do something similar for JUnit. That meant that we had to learn how these three libraries worked, and of course there were situations where testing some edge cases was impossible due to incompatibility between libraries.

Let's start understanding how testing now works in JavaScript.

Types of tests

To understand how testing works a bit more and what types of testing exist, I'll try to explain in a few lines the three principal types of tests that exist nowadays and some terminology that is used in this chapter that is essential to know:

  • Unit tests, as the name indicates, test parts of our application individually, which means testing whether a function, module, or class that receives X input returns Y value. These tests run in isolation and independently of each other, giving us extra confidence in parts of our application that could tend to fail.
  • Even if our entire application is unit tested, it will still mean that parts of our application are not being tested together. Integration tests try to combine all the modules, dependencies, and functions and test how they work together. Thanks to these tests, we can make sure that our application will work correctly as a whole.
  • End-to-end tests let us test our application in a real browser environment. The point of these tests is to simulate an actual user within our application. When using these types of tests, we can simulate behaviors such as clicking on elements, typing inputs, and checking whether everything is rendered correctly from the point of view of an actual user.

Test frameworks

To be able to introduce any of these testing methodologies, we'll benefit from interesting frameworks such as Jest, which is a JavaScript testing framework maintained by Facebook. It works practically out of the box with any project written in React, Angular, Svelte, or Vue.js.

Jest exposes some global functions, called matchers, that let us compare easily what type or which value to expect:

test('two plus two is four', () => {

  expect(2 + 2).toBe(4);

});

This test is expecting that 2+2 will equal 4. There are tons of matchers and they can be extended easily through other libraries such as Testing Library.

Testing Library is a testing utility built to test the Document Object Model (DOM) tree rendered by React on the browser, letting us write tests that resemble how a user would use our application. Instead of running these tests on a real browser, Jest offers a solution that is called JS-DOM that lets Jest render any component to a virtual browser that can be queried later with the utility functions that Testing Library exposes. To understand this concept a bit more, imagine a scenario where we have a button for the user with the text Buy!. React's Testing Library can expose a utility called screen.getByText("Buy!") that will return us the DOM element associated with that text. If it's found, it will imply that our application is rendered correctly and as a consequence, the user will see that button in the browser.

Throughout the chapter, I'll explain which queries we're using and how they work.

Mocking in tests

Mocking functions allow us to test the links between code by erasing the actual implementation of a module and returning a new implementation. To be clear, it's for overwriting implementations of our source code in our tests by keeping the source code with the original implementation.

Mocking in the past was used to mock side effect operations. We mocked what our API was returning by mocking the fetch() interface or the custom library that we were using.

Instead of mocking all these fetch() functions, a new generation of mocking for API appeared called Mock Service Worker (MSW).

These mocks are based on intercepting requests at the network level instead of the implementation level, which is super cool because if we decide to change our internal implementation of how our API requests are done, these tests will still work, and we won't need to change anything in our tests because it doesn't touch the implementation details.

We'll configure the MSW library later in the chapter, and we'll explain in a bit more depth how this library works and how it handles the requests done by our application.

Coverage

Testing coverage is a metric used to measure the amount of source code tested by our testing suite. Coverage will find areas of our code that are not covered by any test case, which means that increasing the coverage value will directly increase the quality of our testing suite.

In this chapter, we'll try to achieve 100% coverage of our application and see how Jest handles this out of the box with an amazing report and detailed analysis.

In the next section, we'll introduce the new dev dependencies required to make Jest work and how they're configured to work with any React application.

Preparing the environment

To prepare the environment, we'll take the previous code we developed in Chapter 6, React with Rematch – The Best Couple – Part II, as the base application that is going to be tested with testing libraries such as Jest and Testing Library.

To make use of these libraries, first we must install them as dev dependencies using Yarn as we did in Chapter 6, React with Rematch – The Best Couple – Part II:

yarn add --dev jest esbuild-jest msw whatwg-fetch

We're installing some interesting dependencies. Let's explain them:

  • jest: As we explained in the previous section, Jest is the most powerful JavaScript testing framework out there, with an amazing focus on simplicity.
  • esbuild-jest: Since we're using Vite, which uses esbuild under the hood, an extremely fast JavaScript bundler written in Go, the recommended approach is to transform our source code in the Jest tests in the same way that our application will be built. In some cases where Vite isn't used, such as in Babel projects, we can use the babel-jest package directly to transform our source code.
  • msw: As we explained previously, instead of mocking every function that contains a request, we just intercept these requests and mock them on the network level.
  • whatwg-fetch: Since Jest runs in a Node.js environment where the browser API isn't available, whatwg-fetch is a polyfill to make it available. To be clear, a polyfill is a piece of code that provides the technology that the developer expects the system to provide natively, but since the fetch() interface only works in browser environments, this polyfill makes fetch() also works in Node.js environments. This won't translate into an impact on our bundle size, since it's just a dev dependency for our Jest tests.

Now, we can start developing our model's tests, but since we're also going to use the Testing Library tool, let's install all the required packages:

yarn add --dev @testing-library/{dom,jest-dom,react,user-event}

Like we did for the previous script, let's define the packages:

  • @testing-library/react: This is the main library package. It's a very lightweight solution for testing React components with functions on top of react-dom and react-dom/test-utils, in a way that encourages better testing practices.
  • @testing-library/dom: The DOM Testing Library package helps us in testing DOM nodes simulated by JS-DOM provided by Jest or in the browser, and provides a set of utilities for querying the DOM for nodes in a similar way to how the user finds elements on the page; for instance, getByText("Cart") will search for a DOM element where the text is Cart.
  • @testing-library/jest-dom: This library provides a set of custom Jest matchers that you can use to extend Jest with Testing Library methods.
  • @testing-library/user-event: This library tries to simulate real events that would happen in the browser as the user interacts with it. For example, userEvent.click(checkbox) would change the state of the checkbox.

    Important note

    If some definitions are unclear or you need to extend these definitions, feel free to check the official website of Testing Library (https://testing-library.com), which is great and will help you to understand this chapter even more. In any case, I'll explain with examples in the Creating tests for React components section of this chapter, where we'll learn when to use this code, how it works, and how it tests our components.

Jest needs some initial configuration to make it work, so we need to create a jest.config.js file to configure Jest a bit for our needs, transforming our source code to make it interpretable by Jest and setting up MSW.

Configuring Jest

First, create a jest.config.js file in root of your website that contains the following:

module.exports = {

  transform: {

    "^.+\.jsx?$": "esbuild-jest"

  },

  setupFilesAfterEnv: ["./test/setup-env.js"]

}

This exports a simple configuration of Jest that will transform all the .js and .jsx files using the esbuild-jest transformer. setupFilesAfterEnv allows us to define a list of paths to modules that will run some code to set up our testing suite properly. This code will run before each test, so this is extremely useful for configuring our MSW mocks.

Now, we can add some additional scripts to package.json to run the Jest framework when we end up configuring these frameworks:

"scripts": {

    "test": "jest",

    "test:watch": "jest --watch",

    "test:coverage": "jest --coverage",

    "lint": "eslint src --ext .js,.jsx",

    "start:api": "json-server --watch api/db.json --port 8000",

    "dev": "concurrently 'yarn start:api' 'vite'",

    "build": "vite build",

    "serve": "concurrently 'yarn start:api' 'vite preview'"

  },

We modified our package.json file to include three new scripts:

  • yarn test: for running Jest as a single run, will run the whole testing suite just once.
  • yarn test:watch: will run in watch mode; this mean that Jest framework will open a tool inside your terminal and will auto run the tests you introduce or change.
  • yarn test:coverage: to check that our application is correctly covered, Jest offers a Command-Line Interface (CLI) argument called --coverage to analyze and compare whether there's something that we didn't test and we should. A safe coverage value should be between 70 and 80%, though we'll try to get 100% in this chapter.

Configuring MSW

To configure MSW, we need to create three files: setup-env.js, server.js, and server-handlers.js.

Let's start with the first one that is required by the previous jest.config.js file.

You must create a folder called test/ in the root folder, and inside this folder create the setup-env.js file. The setup-env.js file will contain all the code we want to run before each test:

import "whatwg-fetch";

import "@testing-library/jest-dom";

import { server } from "./server";

window.IntersectionObserver = jest.fn(function () {

  this.observe = jest.fn();

  this.disconnect = jest.fn();

});

beforeAll(() => server.listen());

afterEach(() => server.resetHandlers());

afterAll(() => server.close());

In this file, we're doing three things: the first one is is importing the whatwg-fetch polyfill, which as you'll remember is required to make the fetch() API work in Jest, and the second import is importing the jest-dom utility function to our Jest framework; we'll see the benefit of this function later.

The second one is importing the ./server file, which is still not created. We're going to create a server to intercept the requests of our application and mock the results, which is why we use the beforeAll(), afterEach(), and afterAll() methods to listen before any test, to reset the handler after each test, and to close when all the tests end.

And the last one is mocking the IntersectionObserver API, like the fetch() API, because IntersectionObserver isn't available in the browser, so we must mock these methods to be able to run the testing suite correctly.

Now, let's create the server.js file:

import { setupServer } from "msw/node";

import { handlers } from "./server-handlers";

const server = setupServer(...handlers);

export { server };

server.js is the file responsible for importing the main code required to make MSW work. We import setupServer(…handlers) to launch the server with the mocked handlers. As you remember, in the setup-env file we were running some methods of this exported server property, .listen(), .resetHandlers(), and .close(), so remember to export it.

Now, we're ready to intercept the requests that our application is making when entering a page; MSW uses this server to return mocked data for testing purposes. You might think that this type of mocking makes no sense when we're already mocking the data when launching the json-server library, but in real-world applications, you won't be mocking the data, so this chapter will be really useful to help you migrate real-world applications.

Of course, feel free to check the documentation of MSW if you have any additional queries about how this mocking system works: https://mswjs.io.

Now, let's create the server-handlers.js file, probably the most important since it will be responsible for defining the mocked return of our API:

import { rest } from "msw";

import { products } from "../api/db.json";

export const handlers = [];

We're importing the rest handler. This request handler exports some methods such as .get(), .post(), and .patch() as a REST API exposes, so we can easily define the first method we're calling in our application:

export const handlers = [

  rest.get("http://localhost:8000/products", (req, res, ctx) => {

    const limit = req.url.searchParams.get("_limit");

    const header = { "x-total-count": products.length };

    return res(ctx.set(header), ctx.json(products.slice(0,     limit)));

  }),

We're adding to the handlers array a new request handler for the .get() method and providing a request path, in our case, http://localhost:8000/products because our application will use this URL to recover the products. We define the domain to be able to intercept the requests as they come, like a real API. Then, this function has a response resolver, which means that we're returning a header with products.length and the products sliced to the limit passed to this request. Now, we're able to test any part of our code that calls asynchronously to this resource.

But, as you'll remember, we also implemented a .patch() method in our application to add products to the favorites or remove them, so let's mock that too:

rest.patch("http://localhost:8000/products/:id", (req, res, ctx) => {

    const { favorite } = req.body;

    const { id } = req.params;

    const modifiedProduct = products.find((p) => p.id === id);

    modifiedProduct.favorite = favorite;

    return res(ctx.json(modifiedProduct));

  }),

];

We just need to add a new method following another request method, inside the handlers array, but in this case with rest.patch(). This request path contains a dynamic :id parameter, which will be used later to find that product in the JSON and modify it to finally return the modified product to the request with the res parameter.

Now we have all our application requests mocked and ready to be tested. Also, we configured all the minimal configuration that Jest and Testing Library need, so let's move on to start writing our first test.

In the next section, we'll see how to test Rematch models and how easy it is to dispatch and test business logic that's encapsulated inside Rematch models.

Creating tests for Rematch models

Tip

Before getting started with this section, I recommend taking a first look at the Jest documentation, https://jestjs.io, and discovering which assertions are available to understand this chapter even more. Along the chapter we'll explain in detail what they do and how Jest simplifies the testing process.

To get started, take a look at this code, which will be the pattern that we'll reuse for our tests:

describe("Describe the suite", () => {

  beforeEach(() => {})

  beforeAll(() => {})

  afterEach(() => {})

  afterAll(() => {})

  it("should do ...", () => {

    expect(1).toEqual(1)

  })

})

In all the tests that we are going to create, we'll define the describe() method, which allows us to gather our tests into separate groupings within the same file. In this way, we can describe the component name and just write in the it() method what the component should do.

Also, Jest exposes some helper functions to handle situations where our tests need to do or execute some code before tests run, or after they end. In our case, we'll use beforeEach() to reset the Rematch store to its initial state, so every it() test will contain a restored state of every model so subsequent tests will be independently of each other.

Now we can use one of two ways of organizing our testing files structure:

  • Create a folder that contains all the tests.
  • Create them together with the source code file.

We'll pick the last one, both for simplicity purposes and also for discovering quickly which parts of our code are being untested.

So, let's start by creating a cart.test.js file inside /src/store/models/. We'll have the cart.js source code and cart.test.js together.

We can start by testing whether the initial state of our cart is correctly returned and contains what we expect it to contain:

import { store, dispatch } from "../index";

const getCart = () => store.getState().cart;

describe("Cart model", () => {

  it("should return the initial state correctly", () => {

    expect(getCart()).toEqual({ addedIds: [], quantityById: {}     });

  });

});

As we'll use the store.getState().cart method a lot in our tests, we can write little functions to make it a bit easier to access these values. We can do this so our test is expecting that our cart state will be equal to a property called addedIds with an empty array and a quantityById property with an empty object.

We can test whether this code works by just running the following in our terminal:

yarn test

We'll see that our terminal will log something like this:

$ jest

PASS  src/store/models/cart.test.js

  Cart model

    should return the initial state correctly (2 ms)

Tests:       1 passed, 1 total

We already have our first model test and it's correctly passing because our state returns these values correctly.

Now, we can test a critical situation. Imagine some developer introduces some extra logic to the ADD_TO_CART reducer and breaks the current implementation of adding a product to the cart. We would lose sales and revenue by not having this tested, so let's test it:

  it("should ADD_TO_CART", () => {

    dispatch.cart.ADD_TO_CART({ id: "ID" });

    expect(getCart()).toEqual({

      addedIds: ["ID"],

      quantityById: {

        ID: 1,

      },

    });

  });

We're dispatching the ADD_TO_CART reducer of the store that was previously imported; we expect that when this reducer is dispatched, the cart will contain the corresponding ID and the correct quantity:

PASS  src/store/models/cart.test.js

  Cart model

     should return the initial state correctly (2 ms)

     should ADD_TO_CART

Tests:       2 passed, 2 total

Our tests passed and we're sure that this logic won't be broken by any change. But there's a lot of logic yet to test, so let's test the scenario where we add the same product twice. As you'll remember, this reducer modifies the quantityById object, increasing the current value, but doesn't add the same ID to the addedIds array because it is already in the array, so let's do this:

it("should increase an already added item to the cart", () => {

    dispatch.cart.ADD_TO_CART({ id: "ID" });

    dispatch.cart.ADD_TO_CART({ id: "ID" });

    expect(getCart()).toEqual({

      addedIds: ["ID"],

      quantityById: {

        ID: 2,

      },

    });

});

Will this test pass? I can tell you that this test is not going to pass… because we're not resetting the state of our model, beforeEach(). So, if we run our test suite, we'll see that this test is failing because instead of quantityById being 2, it will be 3 because the previous it() function already added the same product ID.

It's safer to reset the previous state on each it() function. Also, you could create a brand-new store before every test, rather than having one store and resetting it. Since we haven't tried rootReducers yet, we're going with the first approach.

In this way, we achieve encapsulated it() functions with their own testing logic and state, which are easy to predict and debug. To do this, we just need to use rootReducers, but what is rootReducers? Sometimes, we don't need an entire model to create a Redux reducer, so Rematch allows us to introduce a new section in the init() method with rootReducers. This rootReducers object can be called from anywhere and works as it works in Redux.

So, now our store init() method will look like this:

export const store = init({

  models: { shop, cart },

  redux: {

    rootReducers: {

      RESET: () => undefined,

    },

  },

});

We created a root reducer called RESET that returns undefined. When a root reducer returns undefined, it resets every Rematch model to its initial state.

Now, in cart.test.js, we can add a beforeEach() line to reset the state before each it() test:

describe("Cart model", () => {

  beforeEach(() => dispatch({ type: "RESET" }));

If we run yarn test again, we'll see that our testing suite is passing correctly.

To avoid making this chapter longer, I encourage you to introduce the tests I've given here and compare them with the implementation in the code provided in the official Redux Made Easy with Rematch repository on GitHub:

  • Decrease an already added product of the cart.
  • Remove completely a product from the cart.
  • Reset the cart to its initial state via an internal reducer.

If you're able to create all of these tests, you'll have the cart model with 100% coverage and be one step closer to our main objective, getting a complete application fully tested.

Now, we can move on to the next model test, the shop model, where we'll test asynchronous code and run effects inside tests. As you'll remember, we implemented an interceptor to be able to test this test since it uses the fetch() interface to recover products from the API.

In this test, we're going to make sure that the getProducts() effect returns the expected products correctly, and we'll test that adding a product to the favorites dispatches the effect and correctly returns the product with the favorite attribute changed.

Let's start by creating a new file called shop.test.js inside /src/store/models. Remember to define the beforeEach method to reset the store on every it() function.

As Rematch is compatible with async/await keywords, and Jest is also compatible, we can directly use an async keyword in our it() function and await our effect to be dispatched:

  it("should run getProducts and recover 10 products", async ()   => {

    await dispatch.shop.getProducts();

    expect(getShop().products).toHaveLength(10);

    expect(getShop().totalCount).toEqual(1000);

  });

It seems incredible that these two lines of code fully test that our effect is calling to our API, which is intercepted by MSW, which returns the db.json products and then passes to the reducer and adds the returned products to the desired state, but yes, it's doing that.

This test was easy, but what about adding a product to favorites and checking that it's correctly added?

  it("should run setFavorite affect and modify the favorite   property", async () => {

    await dispatch.shop.getProducts();

    const productToFind = getShop().products[0];

    expect(productToFind.favorite).toEqual(true);

    await dispatch.shop.setToFavorite({

      id: productToFind.id,

    });

    expect(getShop().products[0].favorite).toEqual(false);

  });

As we reset our state on every test, the first step of our test after the cleanup is to run the getProducts effect again to return the first 10 products. After that, we take the first product of our list and we assign it to a variable called productToFind. In this case, we expect that this product was already a favorite in our list, so we expect that after running the setToFavorite effect, our state should contain the same product but with favorite set to true or false given the original value; we're just toggling the initial value to be the opposite.

As you can see, testing asynchronous code with Rematch is as simple as using an async/await keyword. The business logic is clear and maintainable.

There's a pending test that I recommend doing to achieve 100% coverage of this test, which is testing the filterByName() utility function we use in the getProducts effect when the user searches in the header. It expects rootState as the first parameter and the second parameter is the product name to search. Give it a try; if you get stuck, feel free to check the official code in the GitHub repository.

If we run the yarn test:coverage command, we'll see that our models are near to being totally covered. Anyway, a value between 70 and 80% is already high, so 95.65% is incredible because sometimes it's impossible to test 100% of the code:

src/store/models           |   95.65 |    66.67 |     100 |     100 |

  cart.js                   |   95.45 |       75 |     100 |     100 |

  shop.js                   |   95.83 |       50 |     100 |     100 |

In the next section, we'll push forward to improve our testing suite even more, and we're going to use a Testing Library package to test our React components as the user will see them in the browser. These tests will give us the confidence to refactor the user interface and be sure that the functionality of the application isn't broken.

Creating tests for React components

As developers, we don't want complex development experiences where we get slowed down or, even worse, the entire team gets slowed down by complex architectures and libraries that make software unmaintainable. The React Testing Library is a lightweight solution for testing React components. It provides utility functions on top of react-dom to query the DOM in the same way the user would.

Understanding this as we did in the previous section, I'm going to explain the principal methods we're going to use, but I encourage you to have a read of the Testing Library website:

import { render, screen } from "@testing-library/react"

describe("described suite", () => {

  it("should render correctly", () => {

    render(<SomeComponent />)

    expect(screen.queryByText("some text in the screen")).    toBeInTheDocument()

  })

})

One of the benefits of using Jest is that Testing Library doesn't work on its own because it needs a test runner, so Jest, together with JS-DOM, a pure JavaScript implementation of many web standards, is able to emulate a subset of the browser features in Node.js environments well enough.

We can use describe() and it() in the same way that we did in the previous tests, but now, instead of testing the store, we use a utility function called render() exported by the Testing Library package. It's self-explanatory since this function will create a div element and append that div element to document.body, and there, the component we pass to this function will be rendered, as we do in real React applications.

Also, you'll check that we're using the screen. object, which has every query that is pre-bound to document.body, so you can use any Testing Library query to the previously rendered component.

Queries are the methods that Testing Library exports to find elements on the page. Depending on what page content you're selecting, different queries may be more or less appropriate, so feel free to check the official Testing Library documentation about queries to make use of the most accessible ones.

To get started with the easier ones, let's start with the Header component and check that it is rendered correctly with the values we expect to see.

Create a file inside src/components called Header.test.jsx:

describe("Header", () => {

  it("should be rendered correctly", () => {

    render(<Header />);

    expect(screen.queryByText("Ama")).toBeInTheDocument();

    expect(screen.queryByText("zhop")).toBeInTheDocument();

    expect(screen.queryByRole("textbox")).toBeInTheDocument();

    expect(screen.queryByRole("button")).toBeInTheDocument();

  });

We're checking with these assertions that our logo is split into two elements, which is why we use two queryByText functions, then we test whether one textbox is a renderer and that the RESET button of our textbox is also rendered.

Now, we can test that writing some text inside our textbox correctly changes the value of the textbox. This is a good test since our component is a controlled one, so this behavior could fail easily to add new features to this component:

  it("should change the input text value", () => {

    render(<Header />);

    const input = screen.getByRole("textbox");

    userEvent.type(input, "Some search value");

    expect(input).toHaveValue("Some search value");

  });

We're rendering the Header component again, then after that, we get the textbox and use it to type as the user does, and we expect that the value of the input will be equal to the typed text of the user. To be able to test these scenarios where we need to test an user interaction, we must install and import @testing-library/user-event.

userEvent is the import of the following:

import userEvent from "@testing-library/user-event";

Testing Library offers a lot of official and unofficial solutions to handle complex situations such as clicks, double-clicks, keyboard commands, tab keypresses, and even hover and unhover actions. The user-event library exports a lot of utility methods to make these things easier.

As we did in the previous section, give it a go and try to implement a new test that resets the input text value once something is already written, querying the RESET button and clicking it. After that, the input value should be empty. Feel free to check the solution proposed in the code located in the GitHub repository.

What about creating a test that needs or uses Redux hooks, such as useDispatch or useSelector?

We could wrap our component with the Provider component that react-redux exposes on every render() method of our tests, like this:

render(<Provider store={store}><Header /></Provider>);

It will work, yes, but is it readable? No. Since Testing Library is super customizable, it allows us to extend the render functionality by creating utility functions with shared logic to reuse those functions in our tests. That's why we're going to create a new function called renderWithRematchStore(view, store).

In this function, instead of passing the current store instance, we could create a brand-new store and return it to the test function; this way, every test will have its own store instead of sharing the same instance and resetting the state So, create a file inside src/test/ called utils.jsx:

import React from "react";

import { render } from "@testing-library/react";

import { Provider } from "react-redux";

export const renderWithRematchStore = (ui, store) =>

  render(ui, {

    wrapper: ({ children }) => (

      <Provider store={store}>

        {children}

      </Provider>

    ),

  });

We're taking any component as a first parameter and any store as a second parameter, and the render() method accepts as an option a wrapper property, which is a React component. In our case, we render the Provider component provided by react-redux.

Now, for example, we could test what happens when the user writes in the input and presses the Enter key – it should change a value in our store, right?

  it("should dispatch an action to the store when pressing   Enter", () => {

    renderWithRematchStore(<Header />, store);

    const input = screen.getByRole("textbox");

    userEvent.type(input, "Some search value");

    expect(input).toHaveValue("Some search value");

    userEvent.keyboard("[Enter]");

    expect(store.getState().shop.query).toEqual("Some search     value");

  });

We use the userEvent library to type and to handle the Enter keypress, and after that, we check that our store correctly contains the text that the user has written in the input.

There are a lot of tests that can be written using the Testing Library superpowers, which is why we're going to introduce the first ones to every component of our application, and you are free to introduce the suggested ones.

Inside src/components/Cart, we have two components, the Cart component itself and CartProduct, so let's start with the Cart component. We want to check that it's initially rendered correctly:

describe("Cart", () => {

  beforeEach(() => dispatch({ type: "RESET" }));

  it("should render the cart component", () => {

    renderWithRematchStore(<Cart />, store);

    expect(screen.queryByText("Clear")).toBeInTheDocument();

    expect(screen.queryByText("Your total cart:")).    toBeInTheDocument();

    expect(screen.queryByText("$0.00")).toBeInTheDocument();

  });

We're expecting that our Cart component, connected to the Rematch store, correctly renders a button with the text Clear, and also that it contains the Your total cart: $0.00 label. Incredibly, we're already testing whether the function that formats our numbers is working correctly.

In this component, I also tested the following:

  • Renders a product on the cart and the total price is re-calculated
  • Renders two products and the price is re-calculated
  • Should reset the cart to its initial state when clicking the Clear button
  • Should increase the quantity of the products in the cart, using the + button
  • Should decrease the quantity of the products in the cart, using the button
  • Should remove a product from the cart completely

With these tests added to Cart.test.jsx, we got 100% coverage of our Cart component, so let's move on to test CartProduct.

Create a file inside src/components/Cart, called CartProduct.test.jsx:

describe("CartProduct", () => {

  it("should render the quantity correctly", () => {

    const { container } = render(

      <CartProduct product={productJson} quantity={1423} />

    );

    expect(screen.queryByLabelText("product quantity")).    toContainHTML("1423");

    expect(container).toMatchSnapshot();

  });

});

In this test, we're just checking that passing a custom quantity correctly renders the custom quantity, and then we expect that the container element matches the snapshot.

With these tests completely introduced, we have 100% coverage of our Cart system:

src/components/Cart        |     100 |      100 |     100 |     100 |

  Cart.jsx                  |     100 |      100 |     100 |     100 |

  CartProduct.jsx           |     100 |      100 |     100 |     100 |

Now, we can test ProductList, which contains the List and Product components. Let's start by creating two new files, List.test.jsx and Product.test.jsx.

Let's start with List, which renders a list and 10 list items in the first render:

describe("List", () => {

  beforeEach(() => dispatch({ type: "RESET" }));

  it("should render the first ten products correctly", async ()   => {

    renderWithRematchStore(<List />, store);

    expect(await screen.findByRole("list", { name: "" })).    toBeInTheDocument();

    expect((await screen.findAllByRole("listitem")).length).    toEqual(10);

  });

We're using the async/await keyword because you'll remember that the getProducts() effect is a promise that will be resolved once our API returns the data coming from the backend, so we await the promise of the findByRole method to be resolved, which will mean that this method correctly waited and found the given element. The promise will be rejected if no element is found after a timeout of 1 second.

Now, to test the Product component, we can create a suite where we mix a mocked prop product and the product connected to a Rematch store. Initially, we could just check that the Product component is rendered correctly:

describe("Product", () => {

  it("should render the product correctly", () => {

    render(<Product product={productJson} />);

    expect(screen.queryByText(productJson.productName)).    toBeInTheDocument();

    expect(

      screen.queryByText(productJson.productDescription)

    ).toBeInTheDocument();

    expect(screen.queryByRole("img")).toBeInTheDocument();

    expect(screen.queryByText("$1.00")).toBeInTheDocument();

    expect(screen.queryByText("No stock")).toBeInTheDocument();

    expect(screen.queryByText("Add to cart")).    toBeInTheDocument();

    expect(screen.queryByText("Add to cart")).toBeDisabled();

  });

We expect that our screen is correctly rendering all the elements we expect our product to contain: the title, the image, the price, the button for adding to cart, and the stock indicator.

Now, as we have the productJson variable, which is just an object with one product schema (you can use one of the db.json files), we can spread this variable to fit our needs. For example, say we want to test what happens when the product has stock:

  it("should render stock if the product has stock", () => {

    render(<Product product={{ ...productJson, stock: 1 }} />);

    expect(screen.queryByText("In stock")).toBeInTheDocument();

    expect(screen.queryByText("Add to cart")).toBeEnabled();

  });

We spread the productJson variable and we modify the stock to 1, then we expect that our product must render In stock and the Add to cart button must be enabled.

In the same way, we can use it to test what happens when the product is a favorite or not:

it("should paint the favorite button red, when favorite is on", () => {

    render(<Product product={{ ...productJson, favorite: true     }} />);

    expect(screen.queryByLabelText("like")).    toBeInTheDocument();

    expect(screen.queryByLabelText("like")).toHaveClass("text-    red-500");

});

it("should NOT paint the favorite button, when favorite is off", () => {

    render(<Product product={productJson} />);

    expect(screen.queryByLabelText("like")).    toBeInTheDocument();

    expect(screen.queryByLabelText("like")).toHaveClass("text-    gray-400");

});

Here, we have two tests where we test what class has the favorite element. If it is a favorite, it must contain the text-red-500 class, which makes the heart icon red, and when it's in its default state, it must be just text-gray-400.

Testing class names as we did in the previous test is usually a bad idea since they tend to change and are not a good indicator of things going well. It's encouraged to add accessibility labels like aria-label that describe the current icon's meaning, basically you describe what behavior is occurring in that moment, and instead of doing the test against a class, you do the test against that label.

React Testing Library makes these kind of tests as easy as using screen.getByLabelText(), where you must pass the aria-label you used to describe the element as first argument.

As we did in other situations, now we can also introduce tests with the Rematch store for this component since it uses the dispatch method internally that Rematch exposes.

We can now test that our Product component correctly dispatches the ADD_TO_CART reducer and correctly changes the Cart state:

    it("should dispatch add to cart when has stock", () => {

      renderWithRematchStore(<Product product={{       ...productJson, stock: 1 }} />, store );

      expect(screen.queryByText("Add to cart")).      toBeInTheDocument();

      userEvent.click(screen.queryByText("Add to cart"));

      expect(store.getState().cart).toEqual({

        addedIds: ["b590e450-1e0c-4344-92b8-e1f6cc260587"],

        quantityById: {

          "b590e450-1e0c-4344-92b8-e1f6cc260587": 1,

        },

      });

In this test, we're querying the Add to cart button and clicking it, and then we expect that our cart model state will be changed to the correct ID and quantity. In this way, we're correctly testing that any product of our store can flow through the entire business logic. Pressing the button correctly dispatches the reducer, which correctly modifies the state, from React to Rematch.

I also recommend testing some extra behaviors such as the following:

  • Should not dispatch when the product doesn't have stock
  • Should change the favorite button when clicking on it and execute the dispatch method

With all these tests introduced, we can check whether we're near 100% coverage with these tests:

src/components/ProductList |   97.06 |    90.91 |   92.31 |   96.55 |

  List.jsx                  |   95.24 |       80 |    87.5 |   94.44 |

  Product.jsx               |     100 |      100 |     100 |     100 |

  index.jsx                 |     100 |      100 |     100 |     100 |

But what about the total coverage? Run yarn test:coverage on your terminal and check that correctly our testing suite is displaying a 98.8% level of coverage:

----------------------------|---------|----------|---------|---------

File                        | % Stmts | % Branch | % Funcs | % Lines |

----------------------------|---------|----------|---------|---------

All files                   |    98.8 |    82.76 |   98.51 |   99.29 |

We achieved an amazing 98.8%, but what's pending on our side to achieve 100% coverage? After running yarn test:coverage, a folder called coverage/ will be created in the root of your project, and inside will be an lcov-report/ folder that contains a static website for analyzing which lines aren't covered or need additional care:

Figure 7.1 – lcov-report website of this chapter's testing suite

In our case, we can check the src/components/ProductList/List component. Line 20 isn't covered since we're not testing the infinite scroll implementation, and since we're using a simulation of the browser for testing, this use case should probably be tested inside an end-to-end testing suite such as Cypress that runs our application and our tests inside a real browser environment where the scroll behavior is not simulated but is actually tested by users.

The question is whether we should use unit testing, integration testing, or end-to-end testing. You should use all of them and decide which types of testing fit your requirements and the quirks of your application.

In this section, we discovered how Testing Library makes testing React components much easier than we initially expected and how Rematch and Testing Library can work together to make the testing suite of our application as close to the reality of how our users will use our application as possible, giving us the confidence to add new features or refactor existing ones.

Summary

In this chapter, we have learned how to correctly test a complete application with side effects and complex logic as if it was a real shop with a cart system. Also, we have learned how to handle asynchronous operations to an external API with Rematch, and how this entire logic can be encapsulated inside Rematch models and tested easily for reuse in other applications.

In the next chapter, we'll iterate over this application using all the superpowers that Rematch offers thanks to its official Rematch plugin ecosystem. We'll introduce automatic loading/success behaviors, selectors for memoizing computed values, and even persisting the cart in the browser storage. In summary, we'll analyze how Rematch plugins work internally and the story behind them.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
44.200.210.43