Testing frontend logic

with JEST

How to make our application safer and, above all, more pleasant to maintain.

2023-05-10

#QA

Introduction

In Computer Science, there are a few different types of tests. Main classification differentiates 3 main ones: unit testing, integration testing and end to end testing. As a strong testing follower, I appreciate all these types.

In this particular article I want to convince you to start using the first one: unit testing. This article is not for total beginners: it assumes you understand the basics of the frontend and you want to know ‘how can I improve my app’. As a pragmatic person, I’ll present it in a real application example.

Setup

I am writing this article with React (and React Native) in mind, yet it applies for other frontend applications (frameworks) as well. In the basic create-react-app (or respectively react-native init) project, you should have a JEST set-up by default. If JEST is not configured in your app, see details on https://jestjs.io/docs/configuration.

For this article, I am using JavaScript instead of TypeScript to avoid confusing people who are not familiar with it yet. Nevertheless, if you are one of them, I really encourage you to learn and start using it ASAP! It’s becoming a standard, also for frontend applications.

Example

Problem: We need a function, which converts numbers from regular to ordinal form (1 -> 1th, 2 -> 2nd etc.). Let’s assume, we don’t want to increase bundle size by installing any packages.

Solution: We will create helpers (utils). Let’s name them DateFormatHelpers and define getDayOrdinalNumber function inside. We need to add st, nd and rd for all numbers ending with 1 / 2 / 3 and th for others - eg. 45th, 77th etc. Let’s assume that the function returns an empty string ('') if invalid value is passed to the function. We’re creating a file named DateFormat.helpers.test.js in the same folder where we have got our helpers. It’s good practice to keep tests close to elements we test.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
describe('DateFormatHelpers test', () => {
  describe('getDayOrdinalNumber', () => {
    it('returns correct ordinal number text for numbers ended with 1', () => {
      expect(DateFormatHelpers.getDayOrdinalNumber(1)).toEqual('1st');
      expect(DateFormatHelpers.getDayOrdinalNumber(91)).toEqual('91st');
    });

    it('returns correct ordinal number text for numbers ended with 2', () => {
      expect(DateFormatHelpers.getDayOrdinalNumber(2)).toEqual('2nd');
      expect(DateFormatHelpers.getDayOrdinalNumber(32)).toEqual('32nd');
    });
       
    it('returns correct ordinal number text for numbers ended with 3', () => {
      expect(DateFormatHelpers.getDayOrdinalNumber(3)).toEqual('3rd');
      expect(DateFormatHelpers.getDayOrdinalNumber(53)).toEqual('53rd');
    });
       
    it('returns empty string if passed value is incorrect', () => {
      expect(DateFormatHelpers.getDayOrdinalNumber()).toEqual('');
      expect(DateFormatHelpers.getDayOrdinalNumber({})).toEqual('');
      expect(DateFormatHelpers.getDayOrdinalNumber(null)).toEqual('');
    });
   });
});

Some explanation{id}

describe - a keyword for scoping tests. In our case we have 2 scopes: object and function. To make it simple, I just used to copy names of objects and functions to describe value.

it - describes expected behavior of the function. It is good practice to create one it for each case. The rule of thumb is to make this text as descriptive as possible, but concise. Balance is the key (same rule applies to variable naming)!.

Remember: it is a part of sentence so make sure to compose the requirement properly: it < does something > : it returns < something > or it should return < something >. This sentence should be also slightly resistant to changes in tests.

Example: we don’t want to use the value th in the first it description as we may want to change the app language in the future - let's say Swedish. Then the value should change from 1st to 1:a and with the description used above, we don’t need to change it! Of course sometimes with changing requirements it will not be possible to avoid text changes, but generally try spending several seconds to make this description descriptive enough, but not overcomplicated

expect / toEqual- a syntax of our tests (as we call it from now, assertions): on the left side we call a function with case arguments and on the right side we type a value we expect to get.

Now let’s assume our implementation of this function is:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const getDayOrdinalNumber = (dayNumber) => {
  if (typeof dayNumber === 'number') {
    const nthDayOrdinal = dayNumber % 10;
    const dayOrdinalNumberLookup = {
      1: `${dayNumber}st`,
      2: `${dayNumber}nd`,
      3: `${dayNumber}rd`,
    };
       
    return dayOrdinalNumberLookup[nthDayOrdinal] ?? `${dayNumber}th`;
   } else return '';
};
 
export const DateFormatHelpers = {
  getDayOrdinalNumber,
};

And when we type yarn jest, we should see our tests passing:

1
2
3
4
5
6
7
8
9
10
11
12
PASS  src/hooks/useDateFormats/DateFormat.helpers.test.ts
  DateFormatHelpers test
    getDayOrdinalNumber
      ✓ returns correct ordinal number text for numbers ended with 1 (1 ms)
      ✓ returns correct ordinal number text for numbers ended with 2
      ✓ returns correct ordinal number text for numbers ended with 3
      ✓ returns empty string if passed value is incorrect

Test Suites: 1 passed, 1 total
Tests:       4 passed, 4 total
Snapshots:   0 total
Time:        0.569 s, estimated 1 s

Great! Now we know our function is working correctly (at least for the assumptions we’ve made) and the UI is displaying proper value :) Unfortunately - as more observant coders may have noticed - we have forgotten about specific cases of our function! Numbers 11, 12 and 13 ordinals expect ‘th’ instead of ‘st’, ‘nd’ and ‘rd’. We should add additional assertion to our tests then:

1
2
3
4
5
it('returns default suffix for 11, 12 and 13', () => {
  expect(DateFormatHelpers.getDayOrdinalNumber(11)).toEqual('11th');
  expect(DateFormatHelpers.getDayOrdinalNumber(12)).toEqual('12th');
  expect(DateFormatHelpers.getDayOrdinalNumber(13)).toEqual('13th');
});

And after running tests indeed we see test fails:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
DateFormatHelpers test
getDayOrdinalNumber
✓ returns correct ordinal number text for numbers ended with 1 (1 ms)
✓ returns correct ordinal number text for numbers ended with 2 (1 ms)
✓ returns correct ordinal number text for numbers ended with 3
✓ returns empty string if passed value is incorrect
✕ returns default suffix for 11, 12 and 13 (2 ms)

● DateFormatHelpers test › getDayOrdinalNumber › returns default suffix for 11, 12 and 13

expect(received).toEqual(expected) // deep equality

Expected: "11th"
Received: "11st"

25 |
26 | it('returns default suffix for 11, 12 and 13', () => {
> 27 | expect(DateFormatHelpers.getDayOrdinalNumber(11)).toEqual('11th');
| ^
28 | expect(DateFormatHelpers.getDayOrdinalNumber(12)).toEqual('12th');
29 | expect(DateFormatHelpers.getDayOrdinalNumber(13)).toEqual('13th');
30 | });

at Object.<anonymous> (src/hooks/useDateFormats/DateFormat.helpers.test.ts:27:76)

Test Suites: 1 failed, 1 total
Tests: 1 failed, 4 passed, 5 total

We should cover this case in our implementation by adding numeric exceptions:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const getDayOrdinalNumber = (dayNumber?: unknown) => {

if (typeof dayNumber === 'number') {
  const nthDayOrdinal = dayNumber % 10;
  const dayOrdinalNumberLookup: Record<number, string> = {
    1: `${dayNumber}st`,
    2: `${dayNumber}nd`,
    3: `${dayNumber}rd`,
  };

  const isNumericException = dayNumber === 11 || dayNumber === 12 || dayNumber === 13;

  return (
    dayOrdinalNumberLookup[isNumericException ? dayNumber : nthDayOrdinal] ?? `${dayNumber}th`);
  } else return '';
};

export const DateFormatHelpers = {
  getDayOrdinalNumber,
};

Et voilà!

1
2
Test Suites: 1 passed, 1 total
Tests: 5 passed, 5 total

Conclusion

Benefits from unit testing:

  • Unit testing with JEST is simple and developed fast. It doesn’t require complicated setup to begin. Sometimes you need to mock some modules, but it’s way less frequent than in integration or e2e testing.
  • Unit tests are running really fast.
  • In many cases you don’t really need to render an application to make sure your change works. Of course you should check it when you finish your feature, but you don’t need to re-render it after each change: test passes - your function is working!
  • Better application safety. Better application = better sleep
  • Easier debugging. As shown in our example, if we skip some cases, we can add them later. If some changes will be required in our function, we are 100% sure that we won’t cause any regression as we have listed all cases. Running tests prevent us from skipping any of them.
  • Code is well documented. Want to understand how function works? Check tests.
  • Forces good patterns. To make an application testable, it has to be made out of pure functions (functions, which returns the same value for the same arguments passed each time). And trust me: you don’t want to maintain an application in which after the same steps you get different behavior. More pure functions = smaller chance for that.

Tip: It is good practice to run tests before pushing anything to the remote branch. To configure commit steps, I recommend a tool named Husky. For more details, see documentation. Wider description is a topic for another article :) I hope I have convinced you to add some tests in your apps. Trust me, they make life easier.

Check the competences of our software house specializing in web development, building mobile applications, pwa development, creating web applications or technologies based on Java Script such as node.js or vue development.

Share this post

Want to light up your ideas with us?

Józefitów 8, 30-039 Cracow, Poland

hidevanddeliver.com

(+48) 789 188 353

NIP: 9452214307

REGON: 368739409