Michiel Mulders Michiel loves the Node.js and Go programming languages. A backend/core blockchain developer and avid writer, he's very passionate about blockchain technology.

JavaScript testing: 9 best practices to learn

6 min read 1940

JavaScript Testing: 9 Best Practices to Learn

Many developers don’t like testing, but it’s an important aspect of software engineering that directly affects code quality. Flaky tests won’t help you to catch bugs when writing code, which defeats the whole purpose of testing.

On top of that, tests act as a piece of documentation for other developers. By reading the tests you’ve created, they should get a good understanding of the purpose of the code you’ve developed.

This article zooms in on nine best practices for JavaScript testing that can help you write better tests and help your team to better understand the tests you’ve created. We’ll focus on three specific elements:

  1. Test anatomy and test descriptions
  2. Unit testing anti-patterns
  3. Test preparation

Let’s get started!

1. Test anatomy and test descriptions

This section explores how you can improve your test anatomy and test descriptions. The goal is to improve your testing files’ readability so developers can quickly scan them to find the info they want.

For instance, they’ve updated a function and want to understand which tests require changes. You can really help them by applying structure to your tests and writing mindful test descriptions.

1.1 – Structure tests with the AAA pattern

At first, the AAA pattern might tell you nothing — so let’s clarify! The AAA pattern stands for Arrange, Act, and Assert. You want to break up the logic inside tests into three parts to make them easier to understand.

The “arrange” part includes all the setup code and test data you need to simulate a test scenario. Secondly, as its name implies, the “act” part executes the unit test. Usually, test execution only consists of one or two lines of code. And lastly, the “assert” part groups all assertions where you compare the received output with the expected output.

Here’s an example that demonstrates this:

it('should resolve with "true" when block is forged by correct delegate', async () => {
    // Arrange
    const block = {
        height: 302,
        timestamp: 23450,
        generatorPublicKey: '6fb2e0882cd9d895e1e441b9f9be7f98e877aa0a16ae230ee5caceb7a1b896ae',
    };

    // Act
    const result = await dpos.verifyBlockForger(block);

    // Assert
    expect(result).toBeTrue();
});

If you compare the above test structure with the example below, it’s clear which is more readable. You’ll have to spend more time reading the test below to figure out what it does, while the above approach makes it visually clear how the test is structured.

We made a custom demo for .
No really. Click here to check it out.

it('should resolve with "true" when block is forged by correct delegate', async () => {
    const block = {
        height: 302,
        timestamp: 23450,
        generatorPublicKey: '6fb2e0882cd9d895e1e441b9f9be7f98e877aa0a16ae230ee5caceb7a1b896ae',
    };
    const result = await dpos.verifyBlockForger(block);
    expect(result).toBeTrue();
});

1.2 – Write detailed test descriptions using the 3-layer system

It might sound easy to write detailed test descriptions, yet there’s a system you can apply to make test descriptions even simpler to understand. I suggest structuring tests using a three-layer system:

  • Layer 1: Unit that you want to test, or test requirement
  • Layer 2: Specific action or scenario you want to test
  • Layer 3: Describe the expected result

Here’s an example of this three-layer system for writing test descriptions. In this example, we’ll test a service that handles orders.

Here, we want to verify whether the functionality for adding new items to the shopping basket works as expected. Therefore, we write down two “Layer 3” test cases where we describe the desired outcome. It’s an easy system that improves the scannability of your tests.

describe('OrderServcie', () => {
    describe('Add a new item', () => {
        it('When item is already in shopping basket, expect item count to increase', async () => {
            // ...
        });

        it('When item does not exist in shopping basket, expect item count to equal one', async () => {
            // ...
        });
    });
});

2. Unit testing anti-patterns

Unit tests are crucial for validating your business logic — they are intended to catch logical errors in your code. It’s the most rudimentary form of testing because you want your logic to be correct before you start testing components or applications via E2E testing.

2.1 – Avoid testing private methods

I’ve seen many developers who test the implementation details of private methods. Why would you test them if you can cover them by testing only public methods? You’ll experience false positives if implementation details that actually don’t matter for your exposed method change, and you’ll have to spend more time maintaining tests for private methods.

Here’s an example that illustrates this. A private or internal function returns an object, and you also verify the format of this object. If you now change the returned object for the private function, your test will fail even though the implementation is correct. There’s no requirement to allow users to calculate the VAT, only show the final price. Nevertheless, we falsely insist here to test the class internals.

class ProductService {
  // Internal method - change the key name of the object and the test below will fail
  calculateVATAdd(priceWithoutVAT) {
    return { finalPrice: priceWithoutVAT * 1.2 };
  }

  //public method
  getPrice(productId) {
    const desiredProduct = DB.getProduct(productId);
    finalPrice = this.calculateVATAdd(desiredProduct.price).finalPrice;
    return finalPrice;
  }
}

it('When the internal methods get 0 vat, it return 0 response', async () => {
  expect(new ProductService().calculateVATAdd(0).finalPrice).to.equal(0);
});

2.2 – Avoid catching errors in tests

I often see developers who use a try...catch statement to catch errors in tests to use them in assertions. This isn’t a good approach because it leaves the door open for false positives.

If you make a mistake in the logic of the function you’re trying to test, it’s possible that the function doesn’t throw an error when you expect it to throw one. Therefore, the test skips the catch block, and the test passes — even though the business logic is incorrect.

Here’s such an example that expects the addNewProduct function to throw an error when you create a new product without providing a product name. If the addNewProduct function doesn’t throw an error, your test will pass because there’s only one assertion outside the try...catch block that verifies the number of times the function got called.

it('When no product price, it throws error', async () => {
    let expectedError = null;
    try {
        const result = await addNewProduct({ name: 'rollerblades' });
    } catch (error) {
        expect(error.msg).to.equal("No product name");
        errorWeExceptFor = error;
    }
    expect(errorWeExceptFor).toHaveBeenCalledTimes(1)
});

So how can you rewrite this test? Jest, for instance, offers developers a toThrow function where you expect the function invocation to throw an error. If the function doesn’t throw an error, the assertion fails.

it('When no product price, it throws error', async () => {
    await expect(addNewProduct({ name: 'rollerblades' }))
        .toThrow(AppError)
        .with.property("msg", "No product name");
});

2.3 – Don’t mock everything

Some developers mock all function calls in unit tests, so they end up testing if...else statements. Such tests are worthless because you can trust a programming language to implement an if...else statement correctly.

You should only mock the underlying or the lowest-level dependencies and I/O operations, such as database calls, API calls, or calls to other services. This way, you can test the implementation details of private methods.

For instance, the below example illustrates a getPrice function that calls an internal method calculateVATAdd, which by itself calls an API with getVATPercentage. Don’t mock the calculateVATAdd function; we want to verify the implementation details of this function.

As such, we should only mock the external API call getVATPercentage because we don’t have any control over the results returned by this API.

class ProductService {
    // Internal method
    calculateVATAdd(priceWithoutVAT) {
        const vatPercentage = getVATPercentage(); // external API call -> Mock
        const finalprice = priceWithoutVAT * vatPercentage;
        return finalprice;
    }

    //public method
    getPrice(productId) {
        const desiredProduct = DB.getProduct(productId);
        finalPrice = this.calculateVATAdd(desiredProduct.price); // Don't mock this method, we want to verify implementation details
        return finalPrice;
    }
}

2.4 – Use realistic data

Not every developer likes creating test data. But test data should be as realistic as possible to cover as many application paths as possible to detect defects. Thus, many data generation strategies exist to transform and mask production data to use it in your tests. Another strategy is to develop functions that generate randomized input.

In short, don’t use the typical foo input string to test your code.

// Faker class to generate product-specific random data
const name = faker.commerce.productName();
const product = faker.commerce.product();
const number = faker.random.number());

2.5 – Avoid too many assertions per test case

Don’t be afraid to split up scenarios or write down more specific test descriptions. A test case that contains more than five assertions is a potential red flag; it indicates that you are trying to verify too many things at once.

In other words, your test description isn’t specific enough. On top of that, by writing more specific test cases, it becomes easier for developers to identify tests that require changes when making code updates.

Tip: Use a library like faker.js to help you generate realistic testing data.

3. Test Preparation

This last section describes best practices for test preparation.

3.1 – Avoid too many helper libraries

Often, it’s a good thing to abstract a lot of complex setup requirements using helper libraries. Too much abstraction can become very confusing, however, especially for developers who are new to your test suite.

You may have an edge case where you require a different setup to complete a test scenario. Now it becomes very hard and messy to create your edge case setup. On top of that, abstracting too many details might confuse developers because they don’t know what’s happening under the hood.

As a rule of thumb, you want testing to be easy and fun. Suppose you have to spend more than 15 minutes to figure out what’s happening under the hood during the setup in a beforeEach or beforeAll hook. In that case, you’re overcomplicating your testing setup. It might indicate that you’re stubbing too many dependencies. Or the opposite: stubbing nothing, creating a very complex test setup. Be mindful of this!

Tip: You can measure this by having a new developer figure out your testing suite. If it takes more than 15 minutes, it indicates that your testing setup might be too complex. Remember, testing should be easy!

3.2 – Don’t overuse test preparation hooks

Introducing too many test preparation hooks — beforeAll, beforeEach, afterAll, afterEach, etc. — while nesting them in describe blocks becomes an actual mess to understand and debug. Here’s an example from the Jest documentation that illustrates the complexity:

beforeAll(() => console.log('1 - beforeAll'));
afterAll(() => console.log('1 - afterAll'));
beforeEach(() => console.log('1 - beforeEach'));
afterEach(() => console.log('1 - afterEach'));

test('', () => console.log('1 - test'));

describe('Scoped / Nested block', () => {
  beforeAll(() => console.log('2 - beforeAll'));
  afterAll(() => console.log('2 - afterAll'));
  beforeEach(() => console.log('2 - beforeEach'));
  afterEach(() => console.log('2 - afterEach'));
  test('', () => console.log('2 - test'));
});

// 1 - beforeAll
// 1 - beforeEach
// 1 - test
// 1 - afterEach
// 2 - beforeAll
// 1 - beforeEach
// 2 - beforeEach
// 2 - test
// 2 - afterEach
// 1 - afterEach
// 2 - afterAll
// 1 - afterAll

Be mindful about using test preparation hooks. Only use hooks when you want to introduce behavior for all of your test cases. Most commonly, hooks are used to spin up or tear down processes to run test scenarios.

Conclusion

Testing might look simple at first, but there are many things you can improve to make testing more fun for you and your colleagues. Your goal is to keep your tests easy to read, easy to scan, and easy to maintain. Avoid complex setups or too many abstraction layers, which increases testing complexity.

You can significantly impact your tests’ quality and readability by introducing the three-layer system and AAA pattern. It’s a small effort that returns a lot of value for your team. Don’t forget to consider the other best practices described in this blog post.

: Debug JavaScript errors easier by understanding the context

Debugging code is always a tedious task. But the more you understand your errors the easier it is to fix them.

LogRocket allows you to understand these errors in new and unique ways. Our frontend monitoring solution tracks user engagement with your JavaScript frontends to give you the ability to find out exactly what the user did that led to an error.

LogRocket records console logs, page load times, stacktraces, slow network requests/responses with headers + bodies, browser metadata, and custom logs. Understanding the impact of your JavaScript code will never be easier!

.
Michiel Mulders Michiel loves the Node.js and Go programming languages. A backend/core blockchain developer and avid writer, he's very passionate about blockchain technology.

One Reply to “JavaScript testing: 9 best practices to learn”

  1. Using Faker or random test data is a bad idea. Your tests should be repeatable and deterministic.

    If a test fails because of the data (perhaps you forgot to escape apostrophes and Faker gave you an Irish surname like O’Neill), when you run the test again to debug the code, you won’t have the same data and the test may pass.

Leave a Reply