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:
Let’s get started!
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.
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.
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(); });
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:
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 () => { // ... }); }); });
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.
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); });
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"); });
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; } }
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());
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.
This last section describes best practices for test preparation.
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!
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.
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.
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 see exactly what the user did that led to an error.
LogRocket records console logs, page load times, stack traces, slow network requests/responses with headers + bodies, browser metadata, and custom logs. Understanding the impact of your JavaScript code will never be easier!
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.
Handle frontend data discrepancies with eventual consistency using WebSockets, Docker Compose, and practical code examples.
Efficient initializing is crucial to smooth-running websites. One way to optimize that process is through lazy initialization in Rust 1.80.
One Reply to "JavaScript testing: 9 best practices to learn"
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.