MuTasim

Let's Build a Testing Library

•⌛ 6 min read•

Testing? Absolutely. Writing test cases should be considered one of the 'must-have' skills for developers. Unluckily, not all developers can write tests, or they may not be aware of the existence of testing or testing libraries. There are various types of testing, including unit testing, integration testing, functional testing, and more(we won't delve into them in detail in this blog, you can search about them).

So, what if I say we can build our own testing library? Let's see.

Unit Testing in JS

Before we start building, let's look at an example of unit tests and how they work. I'm going to use Vitest as an example. Vitest is one of my favorite testing libraries.🥰

Let's create a simple function for adding two numbers:

./sum.ts
export function sum(a: number, b: number) {
  return a + b;
}

Now, let's write a unit test case for that function:

./sum.test.ts
import { describe, expect, it } from 'vitest';
import { sum } from './sum';

describe('Basic addition', () => {
    it('adds 1 + 2 to equal 3', () => {
        expect(sum(1, 2)).toBe(3);
    });

    it('adds 2 + 5 to equal 7', () => {
        expect(sum(2, 5)).toBe(7);
    });
});

Now, let’s run it using this command: "pnpm test". The result should be:

terminal
    ✓ Basic addition (2)
        ✓ adds 1 + 2 to equal 3
        ✓ adds 2 + 5 to equal 7

    Tests 2 passed (2)

Perfect, isn't it? You might think writing test cases is boring, but trust me, when you have a big app and make some changes, and your app stops working, or when you deploy it and it starts working unstably, that's when you will understand how important writing tests is. So, before that happens, it's better to cover your projects with tests.

Let's Build

Let's build our own testing library. First, let’s start from the most inner function that we are calling:

./sum.test.ts
expect(sum(1, 2)).toBe(3)

All I can see here is an expect function that takes a parameter and returns an object that has a toBe function, which also takes a parameter. Additionally, it checks those two parameters (let's name them 'actual' and 'expected' for better understanding). Let's try to code it:

./testing-library.ts
type Expectation<T> = { toBe: (expected: T) => void };

export function expect<T>(actual: T): Expectation<T> {
    return {
        toBe: (expected: unknown) => {
            if (actual === expected) {
                console.log('✓ Succeeded!');
            } else {
                throw new Error(`Fail - Expected ${actual} to be ${expected}`);
            }
        }
    }
}

expect(true).toBe(true); // Succeeded!
expect(3).toBe(2); // Fail - Expected 3 to be 2

Amazing, isn't it? Now, before going to outer functions, let’s add some more functions that 'expect' usually has. Indeed, 'expect' has many different functions for checking. Let’s add a few more, but I think it's better to move to classes because the function is growing and looks overwhelming. Here it is:

./testing-library.ts
class Expectation<T> {
    private actual: T;

    constructor(actual: T) {
        this.actual = actual;
    }

    toBe(expected: unknown): void {
        if (this.actual === expected) {
            console.log('✓ Succeeded!');
        } else {
            throw new Error(`Fail - Expected ${this.actual} to be ${expected}`);
        }
    }

    toBeFalsy(): void {
        if (!this.actual) {
            console.log('✓ Succeeded!');
        } else {
            throw new Error(`Fail - Expected ${this.actual} to be falsy`);
        }
    }

    toBeTruthy(): void {
        if (this.actual) {
            console.log('✓ Succeeded!');
        } else {
            throw new Error(`Fail - Expected ${this.actual} to be truthy`);
        }
    }
}

export function expect<T>(actual: T): Expectation<T> {
    return new Expectation(actual);
}

// Examples:
expect(true).toBe(true); // Succeeded!
expect(0).toBeFalsy(); // Succeeded!
expect(NaN).toBeTruthy(); // Fail - Expected NaN to be truthy

Here, I added two more functions: 'toBeFalsy' and 'toBeTruthy'. Now, let’s move to the outer function of 'expect'. It was the 'it' function that wraps 'expect' or other matching functions. Here's an example:

./sum.test.ts
it('adds 1 + 2 to equal 3', () => {
    expect(sum(1, 2)).toBe(3);
})

What can you see here? It's a simple function with two parameters. The first one is a description of the test case(string), and the second one is a function that we need to call inside the higher-order function. In our case, 'it' is the higher-order function, and the function that will be called inside it is the callback function. Here are my implementations:

./testing-library.ts
export function it(description: string, test: () => void): void {
    console.log(`Test: ${description}`);
    try {
        test();
    } catch (error) {
        throw new Error('Test Run Failed');
    }
}

Wow 🤩, isn't it cool? Now, let's go up another level; we can see the 'describe' function:

./sum.test.ts
describe('Basic addition', () => {
    it('adds 1 + 2 to equal 3', () => {
        expect(sum(1, 2)).toBe(3);
    });
});

Any ideas on how to do that? It's exactly the same as the 'it' function:

./testing-library.ts
export function describe(suiteDescription: string, suiteFunction: () => void): void {
    console.log(`\nTest Suite: ${suiteDescription}`);
    try {
        suiteFunction();
    } catch (error) {
        throw new Error(`\nTest Suite "${suiteDescription}" failed`);
    }
}

Now, our mini testing library is done. Let’s test it. (Testing a testing library. Haha, sounds funny ;], right?)

First, let’s make some custom functions and test them:

./example.ts
// Function that checks if a given number is even
export const isEven = (num: number): boolean => num % 2 === 0;

// Function that concatenates two strings
export function concatenateStrings(str1: string, str2: string): string {
    return str1 + str2;
}

Now, let's use them with our library:

./example.test.ts
import { describe, expect, it } from './testing-library';
import { isEven, concatenateStrings } from './example';

describe('isEven function', () => {
    it('should be truthy for even numbers', () => {
        expect(isEven(6)).toBeTruthy();
        expect(isEven(8)).toBeTruthy();
    });

    it('should be falsy for odd numbers', () => {
        expect(isEven(1)).toBeFalsy();
    });
});

describe('Concatenate two strings', () => {
    it('will concatenate two strings', () => {
        const result = concatenateStrings('Hello', 'World');
        expect(result).toBe('HelloWorld');
    });

    it('should fail', () => {
        const result = concatenateStrings('2', '2');
        expect(result).toBe(4); //! '2' + '2' is '22' and 22 is not equal 4
    });
});

When we run the code, the terminal should show something like this:

terminal
    Test Suite: isEven function
    Test: should be truthy for even numbers
    ✓ Succeeded!
    ✓ Succeeded!
    Test: should be falsy for odd numbers
    ✓ Succeeded!

    Test Suite: Concatenate two strings
    Test: will concatenate two strings
    ✓ Succeeded!
    Test: should fail
    x Fail - Expected 22 to be 4

Perfect! We can make it prettier by giving some colors to the terminal, like green for succeeded and red for failed, but I am not going to design it now.

Conclusion

We built this tiny unit testing library for JS. Not only did we build it, but we also delved deep and found out how such libraries work. Nothing is difficult—just a bit of reverse thinking, and you're done. To be honest, this way of thinking and coming up with your solution is one of the best ways to understand and master things like libraries and frameworks(and not only them). Our main goal is not to dismiss all existing libraries and solely use ours. The primary goal here is to go under the hood and find out how they work in order to understand and master them.

For me, it's part of my daily coding—being curious and trying to find out. For this, I created a new repo named "Let's Build X" where I will build my own X and share. Also, the code for this testing library can be found there.

Happy coding, and may all your test cases pass! 🥰