DEV Community

Sol Lee
Sol Lee

Posted on

Unit Tests for Frontend Developers [Part 2]

Was the introduction in "Part 1. Theory" helpful to you? Part 1 covered what you need to know before you start writing the test code. As mentioned at the end of it, it's time to learn what you have learned by actually writing the test code.

If you are a developer, you should consider whether the knowledge you have learned is necessary for your own product and apply it if necessary!

As it is a practice part, we will introduce the contents of the code that is a combination of various technology stacks along with the test code that is close to the practice.

One of the features of the frontend is that it interacts directly with the user. If you look at this from the tests point of view, you will have to write the test code, including the scenarios that interact with the user. We will also cover the test codes and contents that we will write in Part 2, including user interaction.

All of the test code in this article were written in React and the test tools Vitest and React Testing Library.

Also, since it is not about test environment and grammar, I will not focus on test code grammar, settings for test environment and test utility, but will focus on individual test codes. Even if you are unfamiliar with Vitest, most of the grammar is compatible with Jest, so you will have no problem looking at the code.

Simple Unit Test Case

Before we look at a realistic example, let's first look at unit test codes for relatively simple functions and components. Since the test code itself is a code that verifies the behavior of a certain code, we will look at both the actual code and the test code and explain it.

Function Level Tests

const isBornIn2000OrLater = (digits: string) => {
  return ['3', '4', '7', '8'].includes(digits[0])
}
Enter fullscreen mode Exit fullscreen mode

isBornIn2000OrLater is a function that determines whether you were born after 2000 by receiving the last digits of the user's Korean social security number.

For simplicity of implementation, I will skip all validation tests. Looking at the code above, how could I write the test code?

it ('verifies whether it is the last digit of the resident registration number of a person born after 2000', () => {
  expect(isBornIn2000OrLater('1234567')).toBe(false);
  expect(isBornIn2000OrLater('2134567')).toBe(false);
  expect(isBornIn2000OrLater('3124567')).toBe(true);
  expect(isBornIn2000OrLater('4123567')).toBe(true);
  expect(isBornIn2000OrLater('5123467')).toBe(false);
  expect(isBornIn2000OrLater('6123457')).toBe(false);
  expect(isBornIn2000OrLater('7123456')).toBe(true);
  expect(isBornIn2000OrLater('8123456')).toBe(true);
});
Enter fullscreen mode Exit fullscreen mode

I've written a test code that verifies the isBornIn2000OrLater function. Put the execution result of the function called with different values in a total of 8 expects and compare it with the value of toBe to see if it matches.

If there is no problem in this test case, it means that all 8 have passed successfully. Simple and easier than you think, right? So, let's look at the component example.

Component Level Tests

import { useState } from 'react';

export default function CountComponent() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
  }

  return (
    <>
      <p>Clicked the button {count} times.</p>
      <button onClick={handleClick}>Click here!</button>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

CountComponent is a slight variation of one of the example components in react.dev. I've imported a component that has a state to show the difference from a function.

If you briefly describe the code, you can press a button, and above it there is the number of times you presses. How would you write the test code for this component?

The basic framework will be the same as a functions, but the parts that really interact with you can be solved with the help of the Testing Library. Let's write a unit test code for CountComponent with @testing-library/react.

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';

describe('CountComponent unit test', () => {
  it('displays 0 times clicked', () => {
    render(<CountComponent />);

    expect(screen.getByText('0 times clicked.')).toBeInTheDocument();
  });

  it('increments the count by one after click on the button', async () => {
    const user = userEvent.setup();

    render(<CountComponent />);

    const buttonElement = screen.getByRole('button', { name: 'click here' });

    await user.click(buttonElement);
    expect(screen.getByText('button is 0 times clicked.')).toBeInTheDocument();

    await user.click(buttonElement);
    await user.click(buttonElement);
    expect(screen.getByText('button is 3 times clicked.')).toBeInTheDocument();
  });
});
Enter fullscreen mode Exit fullscreen mode

The test code has a total of 2 test cases. One checks if the wording is exposed when rendering, and the other checks if the changed wording is exposed when the user clicks the button and interacts.

This is a relatively simple example. It would be great if our functions and components were that simple all the time.

But in reality, our code is...

The field products are not as simple as the previous example. We're going to show you some actual or similar code that you might use in the field. Let's take a look at some of the more realistic components code.

const Identification = ({ referrer, onFinish }) => {
  const [someText, setSomeText] = useState('');

  /* zustand store */
  const { needExtraAuthentication } = useMemberStore();

    /* React Query API call */
  const { data, error, isFetching } = useQuery({
    // ...
    queryFn: () =>
      fetchIdentificationInfo({
        // ...
      }),
    // ...
  });

  // ...

  const showExtraInformation = referrer === 'something' || needExtraAuthentication;

  const handleChangeInput = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.value.length > 8) {
      // ...
    } else {
      // ...
      setSomeText(e.target.value);
    }
  };

  const handleClickButton = () => {
    // ...
    onFinish(someText);
  };

    // ...  

  useEffect(() => {
    if (error) {
      // ...
      window.location.replace('https://HOST/fail');
    }
  }, [error]);

  if (isFetching) return <Loading aria-label="loading..." />;

  return (
    <div>
      <h1>starting authentication</h1>
      {/* component code ... */}
      <label id="comment-label">Comment</label>
      <input type="text" value={someText} onChange={handleChangeInput} aria-labelledby="comment-label"></input>
      {/* component code ... */}
      {showExtraInformation && <ExtraInfomation>부가 정보</ExtraInforamtion>}
      {/* component code ... */}
      <button type="button" onClick={handleClickButton}>
        confirm
      </button>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

At a glance, this seems a component that has multiple logic. What test code should we write for the this component?

When you were developing the product, you probably designed the component based on the specifications. Let's say that we first looked at the implemented code, but in reality, we designed and developed the component based on the proposal as follows.

  • Invoke the credentials API on initial entry and expose the loading component before calling
    • if API call successful, UI is exposed
    • If API call fails, go to failure page (location.replace)
  • Display an input that can be entered up to 8 characters
  • The ExtraInformation component is exposed only when the referrer prop is 'something' or if neededExtraAuthentication is true among the values in the memberStore.
  • The confirm button is exposed and when clicked, the onFinish prop is passed and called with the value of the input.
  • (Other specifications are omitted for simplicity of code)

Based on the specifications, the Identification component was developed with a number of business logic, including how API calls are processed according to success/failure, whether certain information is exposed or not exposed to conditions, and what happens when a user enters and presses a button.

*The existence of specifications with clear conditions must be tested. *

From a code point of view, if the UI is exposed or logic runs under certain conditions in JavaScript, all of that can be considered a test target. How could we write the test code for the Identification component?

Q. Why do you write the test code by looking at the code? Shouldn't you do it the other way around?

A. In this example, we assume that we are writing test codes for components that have already been developed. Due to various circumstances, we thought that many people might not be writing test codes, or writing test codes after completing the implementation of business logic. In order to attach test codes to finished products, we have read the flow of identifying specifications and writing test codes. Do you build a component or function after writing the test code first? If you are already practicing TDD or developing it in a similar way, we recommend reading the specifications first, writing the test code, and then developing the component code. 🙂

The Scenario

First, you have to think about the test scenario. In other words, you have to think about what test cases you will have and write the code!

We've already looked closely at what functions and what roles the components have. Let's write a scenario by thinking about what code to run and what to verify in the given descriptions.

# Specifications Conditions (the logic) What is being tested againt
1 Call the Auth API. If success, the page's title is displayed Make the Auth API call successful 'Attempting to login...' is displayed on the screen
2 Call the Auth API. If failed, redirect to the Error page Make the Auth API call fail location.replace to the Error page
3 The input only allows up to 8 characters Type more than 8 characters in the input The input displays only the first 8 characters
4-1 <ExtraInformation> is displayed only in certain conditions Pass the prop referrer with the value 'something' <ExtraInformation> is displayed on the screen
4-2 Set needExtraAuthentication to true <ExtraInformation> is displayed on the screen
4-3 Set needExtraAuthentication to false and pass referrer with a value other than 'somemthing' <ExtraInformation> is NOT displayed on the screen
5 User clicks the confirm button and the onFinish event gets called with the value in the input Type the content in the input and click the confirm button onFinish handler gets called with the content in the input

Hold on! Testing with Mocking

Testing doesn't just involve actions such as clicks and typings, but it also requires simulating API responses, props and stores.

On the other hand, the end result to be tested does not limit to simply displaying a phrase on the screen. It goes further to checking the result of window.alert or checking which function has been called.

The test code often use mocking to simulate certain conditions or actions by the user. Simply put, mocking is creating a fake version of an internal or external service. If you don't use mocking, you have to test it over an environment which increases testing time, becoming more complex, and requires all interfaces set up correctly.

However, not everything has to be mocked, and some code will require you to avoid mocking. If you use mocking when necessary for testing, you can proceed effectively and efficiently.

Mocking for API calls helps increase development productivity not only for testing but also for local development.

Test tools such as Vitest and Jest also support mocking. In addition to the mock function (e.g. vi.fn(), vi.spyOn(~)), there are interfaces that can mock objects that exist at the global level, and interfaces that can also be mocked by file or library. In this test case, only the mock function is required, so we will use that interface only.

For API calls, MSW can be used. Thanks to the help of service workers, it works as if you are actually calling an API at the network level. There is a guide to setting up the test environment on the official (MSW website)[https://mswjs.io/docs/integrations/node#test-runner], so you can refer to it and set up the API call mocking wherever you want.

Write the Test Code

Before writing the test code, I would like to remind you that an appropriate use of expect syntax must prevail in the test code. This is because if it doesn't fail in the individual test case, it will be treated as a success unconditionally. Only with expect does the verification of the expected behavior in the test code occur.

Also, remember that what the expect syntax discussed in Part 1 verifies should be clear. Let's look at the complete test code that includes the verification procedure based on the scenario and mocking.

const defaultIdentificationProps = {
  referrer: '',
  onFinish: () => {},
};

describe('Identification unit test', () => {
  // ...

  it('Call the Auth API. If success, the page's title is displayed.', async () => {
    /* MSW - success */
    server.use(
      http.get('Auth API URL', () => {
        return HttpResponse.json({
          // 200 success
        });
      }),
    );
    render(<Identification {...defaultIdentificationProps} />);

    await waitFor(() => {
      expect(screen.queryByLabelText('Loading the screen...')).not.toBeInTheDocument();
    });

    expect(screen.getByText('Logging in...')).toBeInTheDocument();
  });

  it('Call the Auth API. If failed, redirect to the Error page  ', async () => {
    /* MSW - fail */
    server.use(
      http.get('Auth API URL', () => {
        return HttpResponse.json({
          // Authentication error - user not found
        });
      }),
    );
    const mockReplace = vi.spyOn(window.location, 'replace');

    render(<Identification {...defaultIdentificationProps} />);
    await waitFor(() => {
      expect(screen.queryByLabelText('Loading...')).not.toBeInTheDocument();
    });

    expect(mockReplace).toBeCalledWith('https://HOST/fail');
  });

  it('The input only allows up to 8 characters  ', async () => {
    const user = userEvent.setup();
    render(<Identification {...defaultIdentificationProps} />);
    await waitFor(() => {
      expect(screen.queryByLabelText('Loading...')).not.toBeInTheDocument();
    });

    const commentInput = screen.getByLabelText('comment-label');
    await user.type(commentInput, 'helloworld');

    expect(commentInput).toHaveValue('You have typed more than 8 characters');
  });

  describe('ExtraInformation는', () => {
    it('Pass the prop referrer with the value 'something'', async () => {
      const referrer = 'something';
      render(<Identification {...defaultIdentificationProps} referrer={referrer} />);
      await waitFor(() => {
        expect(screen.queryByLabelText('Loading...')).not.toBeInTheDocument();
      });

      expect(screen.getByText('Extra Information')).toBeInTheDocument();
    });

    it('needExtraAuthentication is true', async () => {
      const { result } = renderHook(() => useMemberStore());
      act(() => {
        result.current.setMemberInfo({ needExtraAuthentication: true });
      });

      render(<Identification {...defaultIdentificationProps} />);
      await waitFor(() => {
        expect(screen.queryByLabelText('Loading...')).not.toBeInTheDocument();
      });

      expect(screen.getByText('Extra Information')).toBeInTheDocument();
    });

    it('In all other cases, do not display it', async () => {
      const referrer = 'other';
      const { result } = renderHook(() => useMemberStore());
      act(() => {
        result.current.setMemberInfo({ needExtraAuthentication: false });
      });

      render(<Identification {...defaultIdentificationProps} referrer={referrer} />);
      await waitFor(() => {
        expect(screen.queryByLabelText('Loading...')).not.toBeInTheDocument();
      });

      expect(screen.queryByText('Extra Information')).not.toBeInTheDocument();
    });
  });

  it('User clicks the confirm button and the onFinish event gets called with the value in the input', async () => {
    const onFinish = vi.fn();

    const user = userEvent.setup();
    render(<Identification {...defaultIdentificationProps} onFinish={onFinish} />);
    await waitFor(() => {
      expect(screen.queryByLabelText('Loading...')).not.toBeInTheDocument();
    });
    const commentInput = screen.getByLabelText('comment-label');
    const confirmButton = screen.getByRole('button', { name: 'confirm' });

    await user.type(commentInput, 'content typed');
    await user.click(confirmButton);

    expect(onFinish).toBeCalledWith('content typed');
  });

  // ...
});
Enter fullscreen mode Exit fullscreen mode

The test code has been written according to the designed test scenario. Regardless of the internal implementation of the component, the test code has been implemented as described in the specifications.

The component code currently utilizes isFetching for useQuery, but even if you implemented it to display the loading component with useSuspenseQuery and Suspense, the test code will pass.

What I want to say here is that internal implementation is not important for test code writing. Whatever you do, you must implement components that pass the test code. You may feel a little disappointed in the description of the test code you have written.

However, it will be difficult to advance it in the current situation because it was written only with the information given, not the actual business plans. As I told you in Part 1, it's better to include business plans, but if it was the actual code, I think I would have made it by referring to the plan!

⚠️ What if the API error handling was a custom pop-up component using the React Portal?

You'll need to verify that the screen displays a pop-up (in this case, the alertdialog role). Usually, you'll find it as screen.getBy, but you won't be able to find the components that appear outside the root component using the React Portal on the screen. In this case, you can take advantage of the baseElement that is returned by the render function in the Testing Library. You can use const {baseElement} = render(...) to find HTML elements with codes such as getQueriesForElement(baseElement).getByText(...). If it was window.alert and not a custom pop-up, then we could take advantage of mocking.

Q: What are defaultIdentificationProps?
A. The components have props, and you also need props to render the component to test. However, not all test cases require completely entered props! So, you can enter the test code more easily if you declare the props required to run the component as the default and use it. If you have a function among props, you can use the mock function, or if the interface is as simple as onFinish in the previous example, you can put it as an empty function. However, there must be necessary props for individual test cases. Only in this case, you can declare or implement it separately for individual test cases.

Extra: Unit Test with React Custom Hook With Timer

So far, we've written the test code with the components. This time, we've prepared a bonus example where you'll look at the specifications and write the test code.

If you are a react developer, you might have some experience writing custom hooks. I prepared a test code for using the timer with the hook. As expected, you have to write what you know and mix new content to study! But don't worry too much. I've prepared all the hints and model answers to catch up with a little bit.

Let's assume a situation in which a function must be executed at a specific cycle in a component, and the cycle in which the function is executed can change in real time depending on the condition.

At first, we tried to implement it within the components, but we decided to implement it separately as a custom hook called useInterval because we needed that functionality across multiple components.

If you were to design a development to implement the function, what specifications do you think of? The specifications I thought of are as follows.

  • the function should be called at certain interval
  • the interval can be changed in time

Simple, isn't it? When we look at the plan before implementing the code and think about the idea and design of the code, rather than separating all functions into specifications one by one, we need some code that perform this function! I wrote it according to that feeling.

Based on these specifications, let's write the test code while thinking about the interface of useInterval. The best answer I thought of is as below! It would be good to write the test code first and compare it. 🙂

// useInterval.test.ts 
describe('useInterval unit test', () => {
  const mockAlert = vi.fn();

  beforeAll(() => {
    window.alert = mockAlert;
  });

  beforeEach(() => {
    vi.useFakeTimers();
    mockAlert.mockClear();
  });

  afterEach(() => {
    vi.clearAllTimers();
    vi.runOnlyPendingTimers();
    vi.useRealTimers();
  });

  it('calls the hook for evey delay with the callback.', () => {
    renderHook(() =>
      useInterval(() => {
        window.alert('호출!');
      }, 500),
    );

    vi.advanceTimersByTime(200);
    expect(mockAlert).not.toBeCalled();

    vi.advanceTimersByTime(300);
    expect(mockAlert).toBeCalledWith('호출!');

    vi.advanceTimersByTime(1000);
    expect(mockAlert).toBeCalledTimes(3);
  });

  it('changes the interval if delay is changed', () => {
    let delay = 500;

    const { rerender } = renderHook(() =>
      useInterval(() => {
        window.alert('호출!');
      }, delay),
    );

    vi.advanceTimersByTime(1000);
    expect(mockAlert).toBeCalledTimes(2);

    delay = 200;
    rerender();

    vi.advanceTimersByTime(1000);
    expect(mockAlert).toBeCalledTimes(7);
  });
});
Enter fullscreen mode Exit fullscreen mode

Above is a complete test code designed according to the interface of useInterval. It also includes a timer-related code and a function to run before and after the entire test and before and after each case.

In the above example, we are using only rerender() in the renderHook, but if we had to get the method or value that the function returns, we would have used the result.current as well.

In the case of mockAlert, you can use each individual test code without putting it on the top. You can test it using SpyOn too.

Is the test code you presented similar to the one you wrote? If you've written the test code, now let's complete the useInterval code and run the test.

Below is the actual code for useInterval:

// useInterval.ts
const useInterval = (callback: () => void, delay: number) => {
  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);

  const stopInterval = () => {
    if (intervalRef.current) {
      clearInterval(intervalRef.current);
    }
  };

  const startInterval = (nextDelay: number) => {
    stopInterval();

    intervalRef.current = setInterval(() => {
      callback();
    }, nextDelay);
  };

  useEffect(() => {
    startInterval(delay);
  }, [delay]);

  useEffect(() => {
    return () => {
      stopInterval();
    };
  }, []);
};
Enter fullscreen mode Exit fullscreen mode

Have you become more familiar with the test code? So let's just think of adding another feature here. A specification has been added that the function should only be executed according to certain conditions. If you plan to implement it, you might get an option like enabled, or useInterval can return a method of starting and ending repetitions! Create a test code and code to match the interface you're thinking of. Now that you've seen the components and hook test codes, you'll be able to write this easily, right?

Test Code is Code too

So far, we've covered the test code with components and hooks. While looking at the same test code, some might think that we're testing even very detailed parts, and others might think that we need even more test cases.

The criteria for how detailed the test code should be can vary from person to person and from situation to situation. Test codes can also be divided! I would like to leave the following in response.

Test code must also be maintained and developed

Let's take the test code off and look at it from the "code" perspective. When we write normal codes, we don't just leave them as they were, but we continue to maintain them and make a lot of progress. Test codes must also be created and maintained continuously, not finished.

As more features are added, the test code may change, you may need to add a previously missed test case, or sometimes you may find and delete an unnecessary test case. When developing a product, paying attention to the test code as well as maintaining it can ensure the stability of the product while reducing the burden of work.

In addition, if you constantly write, study, and develop test codes, you will be able to cultivate your capabilities in new fields.

Don't forget the cost-effectiveness of the test code

The cost-effectiveness of the test code is also important. If the effectiveness of the test code is higher than the writing cost, of course, it should be written. The utility that should be considered here should not only be considered in terms of development convenience but also in terms of overall failure risk, product stability, and maintenance.

Even if the test code is long and complicated, it will be essential because it is the main business logic, and if it changes frequently, the utility will be more than that no matter how high the cost is. If you're thinking about whether to write a simple code or not, and if you're not sure you don't need it, I recommend just using it. The time to think is also expensive. If you're worried about using the test code for the first time, consider cost-effectiveness as well as the cost-effectiveness, so it won't be a bad idea to use it until you feel comfortable!

Top comments (0)