6 min read

Structuring Unit Test (example with Jest)

Unit testing is an essential practice in software development, ensuring that individual components of code work as expected. However, starting with unit tests—whether for an existing project, adding new features, or using a test-driven development (TDD) approach—can often feel daunting.

The key question is:

What are we aiming to accomplish with unit testing?

The purpose goes beyond simply confirming that the code functions correctly, it’s about establishing a solid foundation for sustainable development. Sustainable development ensures that the project can grow, be maintained, and undergo refactoring without the risk of disrupting existing functionality.

This Figure explain the difference in growth dynamics between projects with and without tests. A project without tests has a head start but quickly slows down to the point that it’s hard to make any progress.

Project using test vs without test

Let’s consider a common testing scenario from this function

export async function loginUser(username, password) {
    // Basic validation
    if (!username || !password || typeof username !== 'string' || typeof password !== 'string') {
        throw new Error('Validation error: Invalid credentials');
    }

    // Fetch user from database
    const user = await db.findUserByUsername(username);

    if (user === null) {
        throw new Error('User not found');
    }

    // Check user status
    if (user.status === 'inactive') {
        return { error: 'User is inactive' };
    }

    // Assume successful authentication
    const token = 'generated-jwt-token';
    return { token };
}

Lets say the test case is look like this

describe("loginUser", () => {
    test("Should success handle the login process wih correct input and user status is active", () => {
        //     
    })
})

We can build the test with AAA pattern. I won’t go into detail about the AAA pattern here, but I’ll provide a brief overview. The AAA pattern consists of three steps:

  • Arrange: Set up the necessary data, initialize objects, and establish the initial conditions.
  • Act: Perform the action that want to test.
  • Assert: Verify that the outcome of the action is as expected.

Here is the implementation using this pattern

describe("loginUser", () => {
    test("Should success handle the login process wih correct input and user status is active", () => {
        // Arrange
        const data = {
            username: 'username',
            password: 'password123'
        };
        const spyDbFindUserByUsername = jest.spyOn(db, 'findUserByUsername').mockResolvedValue({
            username: 'username',
            status: 'active',
            password: 'hashed-very-secure-password'
        });

        // Act
        const result = loginUser(data.username, data.password);

        // Assert
        expect(spyDbFindUserByUsername).shouldBeCalledWith(data.username);
        expect(result).toEqual({ token: 'generated-jwt-token' });
    })
})

See the result is structured. This approach helps to clarify the intent of the test and makes it easier to read and maintain. But that is only one case, what if we have multiple test cases?

describe("loginUser", () => {
    test("Should success handle the login process wih correct input and user status is active", () => {
        // Arrange
        // Act
        // Assert
    })

    test("Should success handle the login process wih correct input and user status is inactive", () => {
        // Arrange
        // Act
        // Assert
    })

    test("Should success handle the login process wih invalid input", () => {
        // Arrange
        // Act
        // Assert
    })
})

Notice that we have some problem: First the case name is too long, unclear about the what need to expect and unstructured. Second, the arrange and act part is mostly duplicated in each test case.

But why do we need to care about this? Remember the goal of unit testing in the first place why we write it, testing itself is still a code that need to maintain and refactor. This is what happen if we make a bad test code.

Bad test code

To solve this problem I will use the Gherkin syntax to structure the multiple test case.

Gherkin is a language used to define test cases in a structured and human-readable format. It is commonly used in Behavior-Driven Development (BDD), but it can be incredibly useful in unit testing as well. The Gherkin syntax follows a given-when-then structure, which aligns well with the AAA pattern:

  • Scenario: This represents a test case.
  • Given: This corresponds to the “Arrange” phase, to set up the necessary preconditions.
  • When: This represents the “Act” phase, where the action is performed.
  • Then: This is the “Assert” phase, where the expected outcomes are verified.

Let’s apply this to our processUser function to see how Gherkin can help structure multiple test cases more clearly.

describe('Feature: loginUser', () => {
    describe('Scenario: Able to handle result for success and failed', () => {
        describe('Given valid username and password', () => {
            const data = {
                username: 'username',
                password: 'password123'
            };

            describe('When user is status active', () => {
                let spyDbFindUserByUsername;
                let funcResult;

                beforeEach(async () => {
                    spyDbFindUserByUsername = jest.spyOn(db, 'findUserByUsername').mockResolvedValue({
                        username: 'username',
                        status: 'active',
                        password: 'hashed-very-secure-password'
                    });
                    funcResult = await loginUser(data.username, data.password)
                });

                afterEach(() => {
                    spyDbFindUserByUsername.mockRestore();
                })

                test('Then it should find user in database', () => {
                    expect(spyDbFindUserByUsername).toBeCalledWith(data.username);
                });

                test('Then it should return the token', () => {
                    expect(funcResult).toHaveProperty('token');
                });
            });

            describe('When user is status inactive', () => {
            ...

                test('Then it should find user in database', () => {
                ...
                });

                test('Then it should return error', () => {
                ...
                });
            });
        });

        describe('Given invalid username and password', () => {
            describe('When function is called with the value', () => {
            ...

                test('Then it should be thrown an error validation', () => {
                ...
                })

                test('Then it should not find user in database', () => {
                ...
                });
            });
        });
    });
});

By using the Gherkin syntax, we can clearly define the different scenarios

The result from those test is look like this

    PASS  /login-user.test.js
    Feature: loginUser
        Scenario: Able to handle result for success and failed
            Given valid username and password
                When user is status active
                    âś“ Then it should find user in database
                    âś“ Then it should return the token
                When user is status inactive
                    âś“ Then it should find user in database
                    âś“ Then it should return error
            Given invalid username and password
                When function is called with the value
                    âś“ Then it should be thrown an error validation
                    âś“ Then it should not find user in database

This structure makes it easier to understand the test cases and ensures that the tests are well-organized and maintainable.


Images source: Khorikov, Vladimir. Unit Testing Principles, Practices, and Patterns. Manning Publications, 2020.