DEV Community

Cover image for Unit Tests for Frontend Developers (with code examples) [Part 1]
Sol Lee
Sol Lee

Posted on

Unit Tests for Frontend Developers (with code examples) [Part 1]

Summarized:

  • Unit tests should describe "what it should do", rather than "how it is implemented"
  • By separating into smaller components we can have easier test code
  • Testing Library allows us to achieve web starndards and accessibility

Although laborious, writing test code is very important. Popular programming books such as "Clean Code" and "Refactoring" mention its importance too. In fact, we all know the term TDD (Test Driven Development), which tells us to write the test code first, and then the main corresponding code.

In recent years, as frontend has expanded, the complexity of code has increased, and more business logic has been dealt with on the frontend than before. Along with this, it seems that the interest and importance of the testing in frontend is also increasing.

In this article, we will focus on unit tests among test codes and look at them along with the frontend code.

Frontend and Unit Tests

Frontend is still barren in many aspects, and testing is one of them. However, thanks to the efforts of many developers, we have useful libraries at our disposal.

There are also various types of tests, such as snapshot tests and e2e tests, in addition to the unit tests, which is this article's all about. Tests are basically made to verify and confirm something, so the types of tests that are applied are different depending on which you want to check.

A unit test is a test that verifies that a particular module or unit is functioning as intended, and it is the most basic and underlying test among tests. Unit tests are limited in scope, as the word specific module or unit implies, so the amount of test code for each unit is small and efficient. However, if the specification changes, it is also the most affected test, so its design must be careful.

Image description

How to efficiently write test codes

A test code is literally a code that tests whether the product and code are working and functioning correctly. In other words, it is another code that verifies that the code is written correctly.

The test code is a code that contributes to improving the stability and maintenance of the product, and you need to make an effort to write it well.

Test codes must also be made once and continuously maintained and developed like the code that make up the product. Before writing test code, we will now introduce what you need to know or what can be helpful in writing good test codes.

F.I.R.S.T principles

The so-called F.I.R.S.T. principle talks about the characteristics and principles that a unit test should have. There are various methodologies and rules in programming for better products, and this principle explains the principles that can make a better code for a unit test code.

  • Fast: The unit test should be fast.
  • Isolated: Unit tests shall be run independently and not dependent on external factors.
  • Repeatable: A unit test should produce the same result each time it runs.
  • Self-validating: A unit test should be able to determine whether it has passed the test by itself or not.
  • Timely/Though – 2 interpretations exist:
    • Timely: unit tests must be implemented before the production code is successful in testing. Interpretation appropriate for TDD
    • Thought: Unit tests should respond not only to successful flows, but also to all possible errors or abnormal flows.

Test code should be DAMP

Another principle or advice for writing test code is that they should be DAMP (Descriptive And Meaningful
Phrases). In other words, we want to make a test code that is easier to read and understand. This will make maintenance even easier.

When writing the code in a DAMP manner, it can sometimes conflict with the DRY (Don't Repeat Yourself) principle. If it's normal code, we'll try to reduce redundancy according to the DRY principle. However, it's better to write the test code with more weight so that it can be understood intuitively and clearly even if there are some redundancies. However, too much redundancy is less legible, so you should weigh the two carefully.

The first and second test codes below are used to verify the same functionality. The first test code has no problem verifying its behavior, but it can be a little difficult to identify. But the second test code makes sense, right? Consider writing the test code in a DAMP way and compare it!

// Test code #1
it('Do you understand this test code right away?', async () => {
    const user = userEvent.setup();
  render(<Component {...preDefinedProps} />);

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

  expect(doSomething).toBeCalledWith(1234);
});

// Test code #2
it('You do understand this test code with no more explanations', async () => {
    const something = 1234;
    const doSomething = vi.fn();
    const user = userEvent.setup();
  render(<Component {...preDefinedProps} something={something} doSomething={doSomething} />);

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

  expect(doSomething).toBeCalledWith(1234);
});
Enter fullscreen mode Exit fullscreen mode

Given-When-Then

The purpose of the test code is to validate the results for a given situation. To achieve this goal, a Given-When-Then pattern can help. If you've learned about B*ehavior Driven Development (BDD), you may have already heard of it. The Given-When-Then structure helps define a test scenario **based on user behavior* that is central to BDD.

While the test code is written based on business decisions, the test code itself should be easy for developers to understand. If you configure the test with a Given-When-Then structure, developers can easily identify and understand the code on top of a clear scenario.

  • Given: given environment to set up for testing
  • When: the conditions in which the tests are conducted.
  • Then: Show the expected results and can verify that it is working as intended
it('should expose the phrase "click once" after button is clicked', async () => {
    // Given: the user and the screen are ready, and the screen contains a button
    const user = userEvent.setup();
  render(<Component />);

    // When: the user clicks the button
  const buttonElement = screen.getByRole('button', { name: 'click here' });
  await user.click(buttonElement);

  // Then: verify if the phrase shows up
  expect(screen.getByText('button has been clicked.')).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

The purpose of the individual test case should be clear

When writing the corresponding test code for each test case, you need to think clearly about the purpose of the test case you are writing and write the test code. Let's take a look at the component code below.

// store.ts
interface StateAndAction {
  word: string;
  updateWord: (newWord: string) => void;
}

const useStore = create<StateAndAction>((set) => ({
  word: 'apple',
  updateWord: (newWord) => set({ word: newWord }),
}));

// WordWithButton.tsx
const WordWithButton = () => {
  const word = useStore((state) => state.word);
  const updateWord = useStore((state) => state.updateWord);

  return (
    <main>
      <h1>
        I love {word}
      </h1>
      <button
        type="button"
        onClick={() => {
          updateWord('banana');
        }}
      >
        Change the preferred fruit
      </button>
    </main>
  );
};
Enter fullscreen mode Exit fullscreen mode

<WordWithButton> is a component that contains global state, buttons, and heading. Since it's a simple component, you might be able to predict how changes will occur and how they will perform the action.

If you write a test code for that component, it will likely verify that the phrase that turns your favorite fruit into a banana is exposed when you press the button. Which test code can achieve this goal?

// First test code
it ('word changes to banana when update Word is called with banana string', () => {
  const { result } = renderHook(() => useStore());

  act(() => {
    result.current.updateWord('banana');
  });

  expect(result.current.word).toBe('banana');
});
Enter fullscreen mode Exit fullscreen mode

The first test code is testing if the status of internally possessed words changes correctly. Perhaps you think this code is necessary, but this test code is not fit for the purpose we are testing. What we want to test is to change the wording when we click the button. However, what the test code is currently verifying is internal behavior. In other words, we are not testing the component, we are checking if the zustand library we use internally is working correctly.

We have to test outside using the interface that is revealed outside the component, but now the internal implementation is revealed in the test code. If we refactored a component, the test code would have to be changed again depending on the technology used inside the component, which is something weird, right? The test code we have to write is not one that verifies that the library we are using is working correctly.

// Second test code
it('Your favorite fruit turns into a banana when you click the button', async() => {
  const user = userEvent.setup();
  render(<WordWithButton />);

  await user.click(screen.getByRole ('button', {name: 'change favorite fruit'));

  expect(screen.getByText(/바나나/i)).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

Above is the second test code. Is this the correct test code? There are actions that the user clicks on, and there is also the code that verifies the 'banana' word. It seems like they are verifying as desired. But no, it is not.

If you look within expect syntax, we are not verifying that it has changed to the correct phrase, but we are checking if there is the word 'banana' on the screen. It is a code that achieves our intended test purpose to some extent, but if 'banana' was exposed elsewhere on the screen or if the button name was like 'change to banana', that test would have failed. You have to write the test code while thinking about whether you are properly verifying the behavior of the code you wrote for the purpose of the test.

// Third test code
It('When you click the button, the phrase in the heading area changes to the content that you like bananas', async() => {
  const user = userEvent.setup();
  render(<WordWithButton />);

  await user.click(screen.getByRole ('button', {name: 'change favorite fruit'));

  expect(screen.getByRole ('heading', {name: 'I like bananas!' }).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

This is the third test code. The test code verifies that when the button is pressed, the phrase in the heading area changes to the content that you like bananas. Finally, the code that matches the purpose we want to test appeared. What we want to test is not the internal actions, but whether the phrase in a specific area changes exactly when the user performs a specific action.

In addition to the test code's content, the content that is verified by the expect syntax should be clear. In other words, you have to specify the scope of what you want to verify, such as finding a specific word on the screen, verifying that the phrase appears in a specific format, or comparing what it contains or is the correct phrase.

You have to decide on what you want to verify with, even if it's not a phrase, which function is called, how many times, or called with specific parameters. Let's write the code and expect, thinking about the purpose of the test case, to see exactly what the test case I'm writing will verify.

The description is also important

It is often overlooked, but the description of the test code is definitely important. Sometimes we roughly write the description of the test code or omit the necessary information. If the description is well written, it will be helpful to modify or extend the feature or debug the code.

In contrast, if the description does not make sense, it will take a relatively long time to tear through the code line by line. Usually, you may not look closely for the description of the test code, but when a problem occurs and requires an understanding or when you try to modify the code, the description will be the first to provide hints about the code.

When writing a description, you should think that you are looking at the function from the outside rather than focusing on the implementation. If you focus on the implementation, it is more likely that you will find a description that interprets the code rather than the specification. Rather than implementing the code, look at the role the function plays and write the test code. One way is to create the test code and write this code after.

Please write a concise and clear description. You should avoid abstracts such as '~~ it works normally'. Don't forget that in the description as well as the code, we wrote the test code to verify the results, not internal implementation!

// Hmm.. - Test code focused on internal implementation and unclear.
It ('Return true only when the first digit of the entered numeric string is 3,4,7,8, otherwise false', => {
  expect(doSomething('1234567')).toBe(false);
  expect(doSomething('2134567')).toBe(false);
  expect(doSomething('3124567')).toBe(true);
  expect(doSomething('4123567')).toBe(true);
  expect(doSomething('5123467')).toBe(false);
  expect(doSomething('6123457')).toBe(false);
  expect(doSomething('7123456')).toBe(true);
  expect(doSomething('8123456')).toBe(true);
});

// Good! - Let's express the description with the specifications and policies of the function concisely and clearly.
it ('Verify whether it is the last digit of the resident number of a person born after 2000', () => {
  expect(doSomething('1234567')).toBe(false);
  expect(doSomething('2134567')).toBe(false);
  expect(doSomething('3124567')).toBe(true);
  expect(doSomething('4123567')).toBe(true);
  expect(doSomething('5123467')).toBe(false);
  expect(doSomething('6123457')).toBe(false);
  expect(doSomething('7123456')).toBe(true);
  expect(doSomething('8123456')).toBe(true);
});
Enter fullscreen mode Exit fullscreen mode

Test code and the "Good" code

So far, we've introduced some tips that would be helpful if you knew about them before writing them. But even if you're interested in them, some of you may be wondering why you need them. So I'd like to ask you a question here!

What do you think are good code?

I'm sure each one has their own answers, but the good code I think is one that has a design that is easy to change and expand even if someone other than me works on it. Those of you who work as developers may have experienced adding some features to existing code or refactoring them. Has the process always been smooth?

From now on, I will introduce you to how we can help you create a good code for the test code.

A well-made test code itself serves as a specification

Imagine we would like to design the components to implement the following features:

  • At first, a word list is exposed.
  • The search window is exposed on the screen, and if the search button is pressed after entering the search word, only words containing the corresponding string are exposed, and the value entered in the search box remains unchanged.
  • When you click the search button, you save the search word as a list of recent search words and it is exposed on the screen.
  • When a recent search word is clicked, the same operation as when the search button is pressed based on the recently clicked search word is performed, but it is not reflected in the search window input and maintains the existing input.

Let's write the test code based on the specifications above. I will skip the internal implementation of the test code. If you are studying the test code, I recommend that you write it after reading upcoming Part 2. 🙂

describe ('Some Component Unit Test', () = > {
  it ('If there is no current search word, all word lists will be exposed', () => {
    // ...
  });

  it ('When you enter a search word and click the search button, the word list is exposed only to the list containing the entered search word string', () => {
    // ...
  });

  It ('If you enter a search word and click the search button, it will be saved as a recent search word and exposed to the screen', () => {
    // ...
  });

  it ('When you click on a recent search word, the word list only exposes the list containing the search word string you clicked on', () => {
    // ...
  });

  It ('If you click the search button after entering a search word, the entered search word is not initialized and is maintained', () => {
    // ...
  });

  It ('If you enter a search word and click on a recent search word, the entered search word is not initialized and remains', () => {
    // ...
  });
});
Enter fullscreen mode Exit fullscreen mode

Just by looking at the description of the test we've written, we can see what role we're expecting from the component. If you've written well inside it as well as the description, you'll be able to get a clearer picture of the specifications.

I might not normally look at the test code and description in detail, but if there's a breakdown or if there's a specification of the component when adding or repecting features, it'll be very helpful to identify the code. If we write the code in our company, we're not the only ones who see it, right? As others see it, the test code will help you quickly identify the component. I'll try to write the component code based on the test code. Again, I'll skip the detailed internal implementation.

const SomeComponent = ({ wordList }: SomeComponentProps) => {
  const [keyword, setKeyword] = useState('');
  const [keywordHistory, setKeywordHistory] = useState<string[]>([]);

  const search = (value: string) => {
    setKeyword(value);

    setKeywordHistory([value, ...keywordHistory]);
  };

  const handleChangeInput = (...args: unknown[]) => {
    // Handlers changing search terms in search bar
  };

  const handleClickButton = (...args: unknown[]) => {
    // Click Search button handler
  };

  const handleClickPreviousKeyword = (...args: unknown[]) => {
    // Click Recent Search Word Handler
  };

  const filteredWordList = wordList.filter((word) => word.includes(keyword));

  return <>{/* Where the components are combined */}</>;
};
Enter fullscreen mode Exit fullscreen mode

We have implemented <SomeComponent> that passes the test code. We have pre-written the test code and implemented the components that pass the test, so we can ensure that the written code works as intended. If there is a test code, even if there is something I made a mistake in development, I will be able to run the test and discover it.

Also, when conducting code reviews, it will also serve as a good document to understand the behavior of the component and refer to it for more detailed reviews. Even if I or someone else misunderstands the intentions and behavior and corrects the code incorrectly, the test code will easily reveal the wrong part.

Increase the cohesion between test code and the actual code

Cohesion refers to how much is associated with the internal elements of a module, and in other words, it can be expressed as the degree to which they change together. If the code changes frequently, it is recommended in many ways to implement the code to have high cohesion. Let's take a closer look at the test code of <SomeComponent> that we just wrote. Well-made test codes are both specific and documented! The test code shows that the component performs three main roles.

In the word list, only strings containing current search terms are exposed.
The current search word is changed when the button is clicked after entering the search box or when the most recent search word is clicked.
When a button is clicked after entering a search box or when a recent search word is clicked, it is stored in the most recent search word list.
According to the single responsibility principle and the separation of concerns that we are familiar with, it would be a good idea to try separating components. We already have test codes that we have written, so we can do the refactoring without much burden. Let's separate some components by role and divide the functions.

// Component 1: The component that the current search term changes when you click the button after entering the search box or when you click the most recent search term
const DividedComponentOne = ({ keywordHistory, changeKeyword }: DividedComponenOneProps) => {
  const handleChangeInput = (...args: unknown[]) => {
    // Handlers changing search terms in search bar
  };

  const handleClickButton = (...args: unknown[]) => {
    // ...
    changeKeyword(...)
    // ...
  };

  const handleClickPreviousKeyword = (...args: unknown[]) => {
    // ...
    changeKeyword(...)
    // ...
  };

  return <>{/* Where the components are combined */}</>;
};

// Component 2: Components that expose only strings containing current search terms
const DividedComponentTwo = ({ wordList, keyword }: DividedComponentTwoProps) => {
  const filteredWordList = wordList.filter((word) => word.includes(keyword));

  return <>{/* Where the components are combined */}</>;
};

// Component 3: The component that controls data at the top and stores it in the most recent search term list when changing a search term
const SomeComponent = ({ wordList }: SomeComponentProps) => {
  const [keyword, setKeyword] = useState('');
  const [keywordHistory, setKeywordHistory] = useState<string[]>([]);

  const changeKeyword = (value: string) => {
    setKeyword(value);
  }

  useEffect(() => {
    setKeywordHistory([keyword, ...keywordHistory]);
  },[keyword])

  return (
    <>
      <DividedComponentOne changeKeyword={changeKeyword} keywordHistory={keywordHistory} />
      {/* Where the components are combined */}
      <DividedComponentTwo wordList={wordList} keyword={keyword} />
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

The responsibilities of each component have been clarified and readability has been improved.

The degree of cohesion of the code has also increased. Let's take an example. If you change the function so that only the list of words that do not have the string is exposed when entering a search term, only <DividedComponentTwo> will be changed in the above design, and no other components will be changed.

Let's change the code so that the input window will be initialized when you click on a recent search term. We just need to change the <DividedComponentOne> component.

Before separating into two components, in both cases, the code would have been changed in <SomeComponent>. How does the test code change after separating the component? If you use it as it is, it can be written as an integrated test at the <SomeComponent> level, but we are dealing with the unit test today, so let's change it to the unit test level.

Test code:

describe ('Divided Component One Unit Test', () => {
  it ('If you enter a search word and click the search button, change the current search word to the entered string', () => {
    // ...
  });

  it ('If you click on a recent search word, change the current search word to the clicked search word', () => {
    // ...
  });

  It ('If you click the search button after entering a search word, the entered search word is not initialized and is maintained', () => {
    // ...
  });

  It ('If you enter a search word and click on a recent search word, the entered search word is not initialized and remains', () => {
    // ...
  });
});

describe ('Divided Component Two unit test', () => {
  it ('If there is no current search word, the entire word lists will be exposed', () => {
    // ...
  });

  it ('Only words containing the searched word string are exposed if the current search word exists', () => {
    // ...
  });
});

describe ('Some Component Unit Test', () = > {
  It ('If you enter a search word and click the search button, it will be saved as a recent search word and exposed to the screen', () => {
    // ...
  });

  it ('If you click on the most recent search term, it will be saved as the most recent search term and will be exposed on the screen', () => {
    // ...
  });
});
Enter fullscreen mode Exit fullscreen mode

Not just the component code, but the number of test cases that each component performs is reduced.

This is because the separation of components reduces responsibility to each component, so each one has less feature than one whole component.

If a component is difficult to test, you should suspect low coherence or if the interface is a misdesigned code.

Doesn't the example above make it seem like a well-written code that's good to test? The improved code is much easier to understand and correct.

You may not feel comfortable because you've removed a relatively simple component this time. A function can have a lot of test codes, but more test codes usually means more roles and responsibilities for modules.

If a component gets more features and, therefore, gets more test code, it's a sign that it needs to be separated. If you refer to test codes for different criteria for separating codes, you can operate components more cohesively.

⚠️ Caution: This doesn't mean that you always have to separate the code for the testing.

There's a saying that duplication is better than clumsy abstraction. Keep in mind that good code itself is not abstracted or responsible, but concise, readable, easy to maintain, and consistent code.

Code separation and abstraction are also considered in the same context. Let's recall that developers are not just people who write the code, but people who solve problems and create products through development.

Testing Library helps you pay more attention to web standards and accessibility

Web standards and accessibility are areas that web frontend developers should pay attention to.

However, we usually develop in a per-component basis and use various libraries, resulting in a difficulties to check for missing properties or strange tag structures.

In this context, Testing Library is a simple, but effective solution to take care of web standards and a11y! Let's take a look at the code below.

const ComponentOne = () => {
  const contents = [
    { id: 1, title: 'Title 1', content: 'Content 1' },
    { id: 2, title: 'Title 2', content: 'Content 2' },
  ];

  return (
    <div>
      <p>Hello! We are Baedal Minjok.</p>
      <p>
        {contents.map(({ id, title, content }) => (
          <ComponentTwo key={id} title={title} content={content} />
        ))}
      </p>
    </div>
  );
};

const ComponentTwo = ({ title, content }) => {
  return (
    <>
      <h4>{title}</h4>
      <p><p>{content}</p>
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

Have you noticed anything strange?

We are calling <ComponentTwo/> within the <p> tag, and there are <h4> and <p> tags inside <ComponentTwo>. This is against the web standards. Even if you are aware of this, you may have missed it due to the nature of React; as React components can be (and they usually are) defined in multiple files, rather than in the same file.

If your project uses styled-components or in-house design system components instead of just typical
HTML tags, can you be confident that you wrote them everywhere in compliance with web standards correctly? As the previous React example tells you, it may not be noticeable. If you automate testing with Testing Library, you can develop the following errors by checking them once more.

testing-library

In addition to helping to comply with web standards, Testing Library also allows you to pay attention to accessibility.

When using Testing Library, do you use testId by any chance? According to the Testing Library's philosophy, testId is actually an interface that you should consider as a "last resort". Instead, you will mostly be using Role, Text, or Label.

Role can find HTML elements in a number of ways related to accessibility, including aria-role, aria-label, so if it's not something you can't see from the user's point of view, write the test code in a different way and check the accessibility at the same time!

Thanks for reading part 1. Upcoming part 2 will be even more practical, so please wait until it comes out.

Top comments (1)

Collapse
 
jonrandy profile image
Jon Randy 🎖️ • Edited

Although laborious, writing test code is very important.

I've heard this all the time over 30 years as a professional developer - but in my experience I have very rarely written test code or used any form of automated testing, and I still don't. To me, this seems totally normal, and it's never been a problem. I've worked in many industries (media libraries, property websites, online payments, e-commerce, local government, warehouse/logistics management, online hotel booking, video game development), for many companies, on both backend and front-end. In pretty much every one, the majority of the code being worked on has not had automated tests.

Maybe my experience has just been unusual, or maybe it is totally normal. Maybe the 'testing' thing is largely just not done - even though it is promoted as super-important.

🤷‍♂️