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

In addition, tests can be used as documentation for other developers. By reading the tests you create, they should have a good understanding of the purpose of the code you are developing.

This article has highlighted nine best practices for JavaScript testing that can help you write better tests and help your team better understand the tests you create. We will focus on three specific elements.

  1. Test anatomy and test description
  2. Anti-patterns for unit testing
  3. Test preparation

Let’s get started!

1. Test analysis and test description

This section explores how to improve your test anatomy and test description. The goal is to improve the readability of your test files so that developers can quickly scan them to find the information they want.

For example, they have updated a feature and want to know which tests need to be changed. You can really help them by applying structure to your tests and writing thoughtful test descriptions.

1.1 – Construct tests with AAA pattern

At first, AAA might tell you what — so let’s clarify what AAA stands for: scheduling, action, and assertion. You want to break the logic in the test into three parts to make it easier to understand.

The “setup” section contains all the setup code and test data you need to simulate a test scenario. Second, as the name implies, the “action” section performs unit testing. Typically, test execution consists of only one or two lines of code. Finally, the “Assertions” section groups all the assertions and compares the received output with the expected output.

Here’s an example to prove it.

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();
});
Copy the code

If you compare the above test structure with the example below, it is obvious 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 gives you a visual look at the structure of the test.

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();
});
Copy the code

1.2 – Write detailed test descriptions using a 3-tier system

Writing detailed test descriptions sounds easy, but there is a system you can apply to make test descriptions easier to understand. I recommend using a three-tier system to structure tests.

  • Level 1: The unit you are testing, or testing requirements
  • Level two: the specific action or scenario you want to test
  • Layer 3: Describe the expected results

Here is an example of a three-tier system that writes test descriptions. In this example, we will test a service that processes orders.

Here, we verify that the ability to add new items to the basket works as expected. Therefore, we write down two “layer 3” test cases, where we describe the expected results. This is a simple system that can improve 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 () => {
            // ...
        });
    });
});
Copy the code

2. Anti-patterns for unit testing

Unit tests are critical to validating your business logic — they’re designed to catch logical errors in your code. This is the most basic form of testing, because you want to get your logic right before you start testing components or applications through E2E tests.

2.1 – Avoid testing private methods

I’ve seen many developers test the implementation details of private methods. If you can override them by testing public methods, why would you test them? If implementation details change that are not really important to your exposure method, you will encounter false positives, and you will have to spend more time maintaining tests for private methods.

Here’s an example to illustrate the point. A private or internal function returns an object, and you also verify the format of the object. If you now change the return object of a private function, your test will fail, even if the implementation is correct. There is no requirement for the user to calculate VAT, only for the final price to be displayed. Nevertheless, we mistakenly insist on testing the internal structure of the class here.

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); });Copy the code

2.2 – Avoid catching errors during tests

I often see people who use try in tests… Catch statements to catch errors are used in assertions by developers. This is not a good method because it leaves the door open for false positives.

If you make an error in the logic of the function you are trying to test, it is possible that the function did not throw an error when you expected it to. So the test skipped the catch block and passed — even though the business logic was wrong.

Here is an example of how the addNewProduct function is expected to throw an error when you create a new product without providing a product name. If the addNewProduct function does not throw an error, your test will pass, because in the try… There is only one assertion outside the catch block that verifies how many times the function has been 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)
});
Copy the code

So, how do you rewrite this test? For example, Jest provides a toThrow function for developers, and you expect the call toThrow an error. If the function does not 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");
});
Copy the code

2.3 – Don’t simulate everything

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

You should only simulate low-level or low-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 example, the following example illustrates a getPrice function that calls the internal method calculateVATAdd, which itself calls an API with getVATPercentage. Do not emulate the calculateVATAdd function; We need to verify the implementation details of this function.

Therefore, we should only simulate the external API call getVATPercentage, since we have no 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; }}Copy the code

2.4 – Use real data

Not every developer enjoys creating test data. But test data should be as realistic as possible to cover as many application paths as possible to detect defects. Therefore, there are many data generation strategies to transform and mask production data and use it in your tests. Another strategy is to develop functions that generate random 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());
Copy the code

2.5 – Avoid too many assertions per test case

Don’t be afraid to break down scenarios or write down more specific test descriptions. A test case containing more than five assertions is a potential red flag; It shows that you’re trying to verify too many things at once.

In other words, your test description is not specific enough. In addition, by writing more specific test cases, developers can more easily identify tests that need to be changed when they make code updates.

Tip: Use a library like faker.js to help you generate real test data.

3. Test preparation

This final section describes best practices for test preparation.

3.1 – Avoid too many helper libraries

In general, it’s a good thing to use helper libraries to abstract out a lot of complex setup requirements. However, too much abstraction can become very confusing, especially for developers new to the test suite.

You may have an edge case where you need a different setup to complete a test scenario. Now, creating your edge case Settings becomes very difficult and confusing. Beyond that, abstracting out too many details can be confusing for developers who don’t know what’s going on under the hood.

As a rule of thumb, you want tests to be simple and fun. Let’s say you have to spend more than 15 minutes figuring out what’s going on under the hood during the beforeEach or beforeAll hook setup. In this case, your test setup is too complex. This may indicate that you stubbed too many dependencies. Or the opposite: stub nothing and create a very complex test setup. Pay attention to this!

Tip: You can measure this by letting a new developer figure out your test suite. If it takes more than 15 minutes, your test setup may be too complex. Remember, testing should be easy

3.2 – Do not overuse test preparation hooks

Introduce too many tests to hook – beforeAll, beforeEach, afterAll, afterEach, and so on, at the same time they are nested in the describe block, will become a real confusion, difficult to understand and debug. Here is 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
Copy the code

Be careful when preparing hooks for tests. Use hooks only if you want to introduce behavior for all of your test cases. Most commonly, hooks are used to start or close processes to run test scenarios.

conclusion

The test may seem simple at first, but there are many things you can improve to make it 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 layers of abstraction, which can add complexity to testing.

By introducing a three-tier system and AAA mode, you can greatly affect the quality and readability of your tests. It’s a small effort that returns a lot of value to your team. Don’t forget to consider the other best practices described in this blog post.

The postJavaScript Testing: 9 Best Practices to Learn first appeared on The LogRocket blog.