DEV Community

loading...

Testing UI - Next JS, Typescript, Jest and React Testing Library

maciekgrzybek profile image Maciek Grzybek Updated on ・10 min read

Intro

As you know, React is just JavaScript. That means it can be tested just like any other JS application. There are lots of testing libraries and test runners out there, but I find that the best setup is Jest + React Testing Library. I use it on a daily basis at work and on my side projects. Worth mentioning that this is also a stack recommended by the React Team itself.

The thing I love about RTL is the fact that it's focused on testing how your application behaves, not how it's implemented. That gives you higher confidence that the user won't be surprised by some weird bugs etc. If you want to learn more about this approach, I strongly recommend these two articles by Kent C. Dodds

Now let's see how we can use all that knowledge in a working example.

What we are going to test?

For the purpose of this tutorial, I've created a simple shopping cart with NextJS. You can see the live demo here or if you want to see the code behind it check the repo.

Functionally, the cart is pretty standard, the user will be able to change the number of items, place an order, remove items from the cart or add a promo code. We're not going to be worried about adding new items to the cart, closing cart etc as we want to focus strictly on the cart behaviour.

https://react-ts-testing-tutorial.vercel.app/images/components.jpg

Let's treat our cart as a widget with some nested components inside. When thinking about what we want to test, I usually go from the most nested component, up to the root and write down all the things that can happen while using the product.

Not all the components have to have their own spec file. For example, Promo and Prices components are purely presentational, they just render whatever props are passed to them. In that case, we don't have to write specific tests.

For this tutorial, we will create two spec files, Product.spec.tsx and Cart.spec.tsx. That should cover all our scenarios.

Having our user's experience in mind let's write test cases (empty for now, we will add the body later).

//Product.spec.tsx
test('shows the correct name', () => {});
test('shows the correct color', () => {});
test('shows the correct price', () => {});
test('shows the correct quantity', () => {});
test('shows the discounted price', () => {});
test('does not show the discounted price', () => {});
test('disables the decrease button when the quantity equals 1', () => {});

Enter fullscreen mode Exit fullscreen mode
//Cart.spec.tsx
test('shows the correct products', () => {});
test('shows the correct order price', () => {});
test('shows the correct delivery price', () => {});
test('shows the correct total price', () => {});
test('allows to apply a valid promo code', () => {});
test('allows to insert new promo code', () => {});
test('does not allow to apply invalid promo code', () => {});
test('updates the prices accordingly when valid promo code is applied', () => {});
Enter fullscreen mode Exit fullscreen mode

We could probably come up with even more test cases but these here are covering the main functionalities of our app.

Writing code

Product component

Let's start with the Product component. First of all, we'll create a default props object that will be passed to the rendered component. For the data source in our project, we are using a mock file. We can use the same data in our tests.

//Product.spec.tsx
import  React  from  'react';
import { render } from  '@testing-library/react';
import  Product, { Props } from  './Product';
import  { mockData }  from  '../../../mock-data';

const  DEFAULT_PROPS:  Props  = {
    product: mockData.products[0],
    handleRemove:  jest.fn(),
    handleAdd:  jest.fn(),
    handleSubtract:  jest.fn(),
};
Enter fullscreen mode Exit fullscreen mode

Most basic tests

Now, let's tackle the first four tests together, as they are very similar - they just check if the passed props are currently rendered on the screen.

//Product.spec.tsx
test('shows the correct name', () => {
    render(<Product {...DEFAULT_PROPS} />);
    expect(screen.getByText(DEFAULT_PROPS.product.name)).toBeInTheDocument();
});

test('shows the correct color', () => {
    render(<Product {...DEFAULT_PROPS} />);
    expect(screen.getByText(DEFAULT_PROPS.product.color)).toBeInTheDocument();});

test('shows the correct price', () => {
    render(<Product {...DEFAULT_PROPS} />);
    expect(screen.getByText(DEFAULT_PROPS.product.price.toString(), { exact: false })).toBeInTheDocument();
});

test('shows the correct quantity', () => {
    render(<Product {...DEFAULT_PROPS} />);
    expect(screen.getByText(DEFAULT_PROPS.product.quantity.toString())).toBeInTheDocument();
});

Enter fullscreen mode Exit fullscreen mode

As you can see, we pass the Product component with DEFAULT_PROPS to the rtl's render method. As you probably guessed, this method will render our component (check the docs for more info).
For better re-usability we can extract the render method like this:

//Product.spec.tsx
const  renderComponent  = (props = {}) => {
    return {
        ...render(<Product {...DEFAULT_PROPS} {...props} />),
        props: {
            ...DEFAULT_PROPS,
            ...props,
        },
    };
};

test('shows the correct name', () => {
    renderComponent();
    expect(screen.getByText(DEFAULT_PROPS.product.name)).toBeInTheDocument();
});

test('shows the correct color', () => {
    renderComponent();
    expect(screen.getByText(DEFAULT_PROPS.product.color)).toBeInTheDocument();
});

test('shows the correct price', () => {
    renderComponent();
    expect(screen.getByText(DEFAULT_PROPS.product.price.toString(), { exact:  false })).toBeInTheDocument();
});

test('shows the correct quantity', () => {
    renderComponent();
    expect(screen.getByText(DEFAULT_PROPS.product.quantity.toString())).toBeInTheDocument();
});

Enter fullscreen mode Exit fullscreen mode

This way:

  • we don't have to pass the component every time,
  • we have an access to the props that were used for rendering,
  • we can pass custom props and overwrite the default ones

Test each

Let's move on to the next test:

//Product.spec.tsx
test.each`
    discount | price     | promoAvailable | expectedDiscountPrice
    ${20}    | ${29.99}  | ${true}        | ${23.99}
    ${25}    | ${56.72}  | ${true}        | ${42.54}
    ${15}    | ${121.55} | ${true}        | ${103.32}
    ${20}    | ${29.99}  | ${false}       | ${23.99}
    ${25}    | ${56.72}  | ${false}       | ${42.54}
    ${15}    | ${121.55} | ${false}       | ${103.32}
`(
    'shows or does not show the discounted price',
    ({ discount, price, promoAvailable, expectedDiscountPrice }) => {
    renderComponent({
        discount,
        product: { ...DEFAULT_PROPS.product, price, promoAvailable },
    });

    if (promoAvailable) {
        expect(screen.getByText(`$ ${expectedDiscountPrice}`)).toBeInTheDocument();
        screen.getByText(`${price}`);
    } else {
        expect(screen.queryByText(`$${expectedDiscountPrice}`)).toBeNull();
        screen.getByText(`$ ${price}`);
    }
);

Enter fullscreen mode Exit fullscreen mode

In this test, we are making sure that promotion is applied correctly to a product and that the discounted price is calculated correctly. You can see on the walkthrough gif, that when the user adds the correct promo code, some of the products are getting their price lowered. It's a straightforward scenario:

  • if the product can have promo applied to it, we want to check if the old and new price is being rendered
  • if the product can't have promo applied to it, we want to check if the regular price is being rendered and the discounted priced is NOT being rendered

To make sure we cover a few cases, we will use test.each function.
Every row of the table that we passed to this method, will be a separate chunk of data used in the same assertions test.

Function mocking

The last thing we want to cover in this component is testing the callback passed as props. This is an example for our developer user.

//Product.spec.tsx
describe('fires callback on button click', () => {
    test('add button', () => {
        renderComponent();
        userEvent.click(screen.getByRole('button', { name: /\\+/i }));
        expect(DEFAULT_PROPS.handleAdd).toBeCalled();
        expect(DEFAULT_PROPS.handleAdd).toBeCalledTimes(1);
        expect(DEFAULT_PROPS.handleAdd).toBeCalledWith(DEFAULT_PROPS.product.id);
    });

    test('subtract button', () => {
        renderComponent({
            product: {
                ...DEFAULT_PROPS.product,
                quantity:  2,
            },
        });
        userEvent.click(screen.getByRole('button', { name: /\\-/i }));
        expect(DEFAULT_PROPS.handleSubtract).toBeCalled();
        expect(DEFAULT_PROPS.handleSubtract).toBeCalledTimes(1);
        expect(DEFAULT_PROPS.handleSubtract).toBeCalledWith(
            DEFAULT_PROPS.product.id
        );
    });

    test('remove button', () => {
        renderComponent();
        userEvent.click(screen.getByRole('button', { name: /\\x/i }));
        expect(DEFAULT_PROPS.handleRemove).toBeCalled();
        expect(DEFAULT_PROPS.handleRemove).toBeCalledTimes(1);
        expect(DEFAULT_PROPS.handleRemove).toBeCalledWith(DEFAULT_PROPS.product.id);
    });
});

Enter fullscreen mode Exit fullscreen mode

All three tests have almost identical structure and assertions. We could probably use test.each again in this situation, but wheres in the previous test we had the same element with different data, here we have different behaviours that just happened to have the same test function body, so it's a good practice to test them separately.

Let's break down the code:

  • we render the component
  • we use userEvent library to simulate click event
  • we make 3 assertions:
    • has the callback function been called?
    • has the function been called only once?
    • has the function been called with correct product id?

Worth mentioning that, we can check the callback this way because we assign jest.fn() to it in the DEFAULT_PROPS.

Cart component

We can start in a similar way as we did with the Product component. Let's create a DEFAULT_PROPS and renderComponent function.

//Cart.spec.tsx
import React from  'react';
import { render, screen }  from  '@testing-library/react';
import Cart, { Props }  from  './Cart';
import { mockData }  from  '../../mock-data';

const DEFAULT_PROPS: Props = {
    ...mockData,
    removeProduct: jest.fn(),
    addProduct: jest.fn(),
    subtractProduct:  jest.fn(),
    freeDeliveryPrice:  500,
};

const renderComponent = (props = {}) =>  {
    return {
        ...render(<Cart {...DEFAULT_PROPS} {...props} />),
        props: {
            ...DEFAULT_PROPS,
            ...props,
        },
    };
};

Enter fullscreen mode Exit fullscreen mode

Let's start with the basics and check if products are rendered. We already know that Product component is displaying correct info, so here we can just ensure that the Cart is actually rendering the products. For each product, let's test if its name is shown.

//Cart.spec.tsx
test('shows the correct products', () => {
    renderComponent();
    DEFAULT_PROPS.products.forEach(({ name }) => {
        expect(screen.getByText(name)).toBeInTheDocument();
    });
});

Enter fullscreen mode Exit fullscreen mode

In our UI we have three main prices - order price (sum of products prices), delivery price and a total price (order + delivery prices). Let's make sure they are calculated and displayed correctly.

Price testing

test('shows the correct order price', () => {
    renderComponent();
    const  expectedPrice = 354.65;

    expect(screen.getByText(new RegExp(`${expectedPrice}`, 'i'))).toBeInTheDocument();
});

Enter fullscreen mode Exit fullscreen mode

We can calculate the price and check if it exists in the document.

//Cart.spec.tsx
describe('shows the correct delivery price', () => {

  test('when free delivery price was not exceed', () => {
    renderComponent();
    expect(screen.getByText(/30.00/i)).toBeInTheDocument();
  });

  test('when free delivery price was exceed', () => {
    renderComponent({
      products: [
        ...DEFAULT_PROPS.products,
        {
          id: '5',
          name: 'Blazer',
          color: 'yellow',
          price: 150,
          image: 'images/air-force.png',
          promoAvailable: true,
          quantity: 1,
        },
      ],
    });
    expect(screen.getByText(/free/i)).toBeInTheDocument();
  });
});
Enter fullscreen mode Exit fullscreen mode

In our app, the delivery price can have to values - $30 or Free. It is free if the order price exceeds the freeDeliveryPrice value (we default it to 500 in Cart component). First, we test the situation when the order value is less than 500, in the second we add an additional product to increase the order price and we expect to deliver value to change.

//Cart.spec.tsx
describe('shows the correct total price', () => {

  test('when free delivery price was not exceed', () => {
    renderComponent();
    const expectedPrice = 384.65;

      expect(screen.getByText(/in total:/i)).toHaveTextContent(
        new RegExp(`${expectedPrice}`, 'i')
      );
  });

  test('when free delivery price was exceed', () => {
    const { props } = renderComponent({
      products: [
        ...DEFAULT_PROPS.products,
        {
          id: '5',
          name: 'Blazer',
          color: 'yellow',
          price: 150,
          image: 'images/air-force.png',
          promoAvailable: true,
          quantity: 1,
        },
      ],
    });
    const expectedPrice = 504.65;

    expect(screen.getByText(/in total:/i)).toHaveTextContent(
      new RegExp(`${expectedPrice}`, 'i')
    );
  });
Enter fullscreen mode Exit fullscreen mode

We're doing a similar thing in this test. In both cases, we calculate the expected total price beforehand (with and without the delivery price) and then we query the UI to see if the correct value is rendered.

Promo codes

The last functionality we want to test is adding promotion codes. If the user will input the correct code (they are defined in mocked data), the prices will be reduced accordingly. We already tested that in the Product component, so this time we can focus on order and total price. Specifically, we want to check 3 things:

  • can we apply a valid code
  • can we apply an invalid code
  • are the prices are updated accordingly
//Cart.spec.tsx
test('allows to apply a valid promo code', () => {
  renderComponent();
  const { name, discount } = DEFAULT_PROPS.promoCodes[0];

  userEvent.type(screen.getByRole('textbox'), name);
  userEvent.click(screen.getByRole('button', { name: /apply/i }));

  expect(screen.getByText(/discount applied: /i)).toHaveTextContent(
    discount.toString()
  );
});

test('does not allow to apply invalid promo code', () => {
  renderComponent();

  userEvent.type(screen.getByRole('textbox'), 'INVALID_PROMO_CODE');
  userEvent.click(screen.getByRole('button', { name: /apply/i }));

  expect(screen.getByRole('alert')).toMatchInlineSnapshot();
});

test('updates the prices accordingly when valid promo code is applied', () => {
  renderComponent();

  const { name } = DEFAULT_PROPS.promoCodes[0];

  userEvent.type(screen.getByRole('textbox'), name);
  userEvent.click(screen.getByRole('button', { name: /apply/i }));

  const orderPrice = 314.21;

  expect(
    screen.getByText(new RegExp(`${orderPrice}`, 'i'))
  ).toBeInTheDocument();
  expect(screen.getByText(/in total:/i)).toHaveTextContent(
    new RegExp(`${orderPrice + 30}`, 'i')
  );
});
Enter fullscreen mode Exit fullscreen mode

First of all, we want to input the promoCode and submit it. We can use userEvent library to do both of those tasks.

In the first case, we want to check if the code is correctly applied by querying for the success text and checking its discount value.

In the second test, we're inserting an incorrect code and then check for the alert rendered in the UI. This time we want to use this very cool jest assertion - toMatchInlineSnapshot. I'm not a big fan of snapshot testing, but in this case, it's a perfect match. The alert text can be considered as an implementation detail, so we don't want to hardcode its value. Notice that, after the first run the snapshot will be generated right in the test. If someone is going to change the text of the alert message, the test will catch it and you will have the option to decide if it's correct by updating the snapshot.

The third test is pretty much the copy of previous tests. We just look for an updated order and total price value.

To sum up

As you can see, testing React applications with Jest and RTL it's really cool and not that complicated. Thanks to them we got a lot of great tools to make sure our applications works as it's expected. Like I've mentioned at the beginning, because of the approach to not to test the implementation details, specs give us a lot of confidence before shipping the app/website/product.

Hope this short article gives you some more understanding of Rect testing and will help you with your future projects. Last one thing at the end, and I can't stress this enough, when in doubt always check Kent's materials about testing. They are great and will definitely help you to answer most of the questions and concerns you have when writing tests.

Discussion (7)

pic
Editor guide
Collapse
adrien profile image
Adrien Rahier

This is exactly what I needed thanks a lot Maciek for writing this comprehensive article!
One suggestion: use the tag #nextJS (the official one) and not #next which will help referencing your post even better.

Collapse
maciekgrzybek profile image
Maciek Grzybek Author

Glad you liked it and thank you :) I'll change the tag, didn't know about it :)

Collapse
adrien profile image
Adrien Rahier

Great :-)
Another question actually: looking at the files's naming I see that you prefix them with spec. I have seen several people just using test
=> So is this a personal convention or more like a best practice?

Thread Thread
maciekgrzybek profile image
Maciek Grzybek Author

Just a personal convention to be honest. We use this at my work, so I just got used to it :)

Thread Thread
adrien profile image
Adrien Rahier

Got it! Thanks for your reply :-)

Collapse
xavierbrinonecs profile image
Xavier Brinon

Amazing article, not sure Dev.to is the best medium for that though :)
I could subscribe to a newsletter or a blog of yours, develop your brand !

Collapse
maciekgrzybek profile image
Maciek Grzybek Author

Thanks, Xavier :) I am in the middle of building my own website, but you know how it is :)