Testing JavaScript Applications: Ch2. What to test and when?

I’m a Frontend Software Engineer with 3+ years of experience building fast, scalable, and user-friendly web applications. I specialize in performance optimization, refactoring legacy code, and enhancing UI/UX, particularly in complex dashboard applications. I focus on improving load times and delivering smooth user experiences by collaborating closely with cross-functional teams.
In the previous article, I introduced a book called Testing Javascript Applications by Lucas da Costa and we discussed chapter 1 of the book which gives an Introduction to automated testing.
In this article, we will discuss chapter 2 of the book which discusses different types of automated tests and their advantages and disadvantages, providing guidance for selecting the appropriate tests to write.
[1] Testing pyramid
Developers use different types of tests for different purposes, frequencies, and durations. Tests can guide developers during development, test completed features, and interact with code or graphical interface (as an end-user would). The choice of selecting the right test type depends on the developer's judgment and the software's specific needs to build more reliable software. Understanding the types and purposes of tests is reflected in a fundamental concept in software testing: the testing pyramid.
The testing pyramid is a useful approach to ensure high-quality software development by showing the role, value, and frequency of tests. it involves 3 steps:

The first step is to test individual units of code, just like testing individual functions, to ensure they work correctly. (Unit tests)
The second step involves testing the integration of these units into larger components, like combining code modules to create a software feature. This validates that the different pieces of your software work together. (Integration tests)
Finally, the last step is to test the final product, by verifying your work from a user’s perspective by interacting with your software through the user interface it provides. (E2E tests)
The size of each layer indicates the number of tests that should be written of that type, and the higher up a test is in the pyramid, the less frequently they run and the more value they provide.
By following this testing pyramid approach, we can ensure that our software meets high-quality standards and is useful to our users.
[2] Testing setup and frameworks
To write a test any functionality, we can use manual tests for each condition (using if..else), or we can use a testing library because:
It gets rid of the complicated logic to determine whether objects are equal to each other (using
if..else), instead, it has built-in methods.It generates meaningful testing output, so you don’t have to manipulate strings yourself.
It solves the problem of organizing and running multiple tests sequentially and in individual scopes, providing easily readable results, by using Test Runners.
Currently, the most popular testing tool in the JavaScript ecosystem is Jest.
Jest is a testing framework created at Facebook. It focuses on simplicity and, therefore, ships with everything, So you can start writing tests straightaway.
To prepare tests for Jest, wrap them in the test() function that Jest adds to the global scope. This function helps organize multiple tests within a file and specifies which tests should run.
The
test()creates a block called "Test spec" and it's a function that takes the test's name as its first argument and a callback function that contains the actual test as the second argument.test('sample test', () => { // Testing logic });The
expect()function helps Jest provide feedback that’s even more helpful.test('sample test', () => { expect(1 + 2).toBe(3); // check if 1 + 2 = 3 using toBe() matcher utility });It takes the actual subject of the assertion as an argument and returns an object that provides different matcher functions.
These matcher functions verify whether the actual value matches your expectations.
they compare the value of the assertion’s target (the argument provided to
expect()) to the value of the argument passed to them (toBe())
Note for installing a testing library/framework:
Using a global installation of a testing library/framework can result in incorrect test results, indicating that the application is functioning correctly when it may not be.
You want tests to fail only when there’s something wrong with your application, not when people are running different versions of a test framework.
To avoid this, Install them as devDependencies using
"npm install [library] --save-dev". This ensures consistency for developers without shipping the library with the application.
[3] Unit tests
Unit tests help you ensure that the smallest units of your software (your functions) behave as you expect them to.
Unit tests’ scope is limited to a function, So their feedback is narrow and precise. They can immediately tell which function is failing. Strict feedback like this makes it faster to write and fix your code.
These tests are inexpensive and quick to write, but they cover only a small part of your application, and the guarantees they provide are weaker.
Just because functions work in isolation for a few cases doesn’t mean your whole software application works, too.
- So, to get the most out of these narrow and inexpensive tests, you should write many of them.
Considering that unit tests are numerous and inexpensive, and run quickly and frequently, we place these tests at the bottom of the testing pyramid. They’re the foundation other tests will build upon

[3.1] Writing unit tests
When writing a test, we follow this sequence known as "The three As": (Arrange, Act, Assert).
First, you set up a scenario -> Arrange
Then you call the function you want to test -> Act
And, finally, you check whether the output matches what you expected -> Assert
Let's say that we want to test the behavior of a Shopping Cart and we want to test its functionalities in our app:

we have a class Cart that has these functions:
addItemToCartfunctionality ->addToCart()methodlistCartItems()functionality ->itemsproperty
// Cart.js
class Cart {
constructor() {
this.items = [];
}
addToCart(item) {
this.items.push(item);
}
}
module.exports = Cart;
To test it, we create a Cart.test.js file:
// Cart.test.js
const Cart = require("./Cart.js");
test("The addToCart function can add an item to the cart", () => {
const cart = new Cart(); // 1. Arrange the test data
cart.addToCart("cheesecake"); // 2. Act on the data
expect(cart.items).toEqual(["cheesecake"]); // 3. Assert the result
});
As we can see, in unit tests, we only focus on testing small and isolated pieces of code.
Isolating your code in unit tests can be great for writing quick and simple tests, but unit tests can’t guarantee that you are using other pieces of software as you’re supposed to. That's where Integration tests come into place.
[4] Integration tests
Integration tests help you ensure that different components or modules of your software work together as expected. They verify that the interactions between these components are correct, data is transferred between modules properly, and the integrated system functions as intended.
Integration testing is a critical step in the software development process as it helps to identify any defects or issues that arise when multiple components are combined and tested together.
By conducting integration tests, you can ensure that the different parts of your software work seamlessly together and deliver the desired functionality to end-users.
The scope of these tests is broader than the scope of unit tests but smaller than the scope of end-to-end tests. They assert the quality of the intermediary steps of the process.

[4.1] Testing 3rd party libraries
If you are using a library like React, for example, your software must integrate appropriately with it. The way React behaves is essential to how your application does, so you must test your code in integration with React.
For example, you should not write tests for React's core built-in methods like
useState()oruseEffect(). Instead, you should write integration tests to ensure that your code correctly integrates with and utilizes these React methods in the intended way. By doing so, you can verify that your application functions as expected and takes full advantage of the features provided by React.Testing the library methods is the author’s responsibility, not yours. And, if the authors didn’t write tests, it’s probably better to reconsider its adoption.
The same is valid for interacting with a database or with a computer’s filesystem. You rely on how those external pieces of software work, and, therefore, it’s wise to check if you’re using them correctly.
It’s important to highlight that the goal of an integration test is not to test any third-party pieces of software themselves. The purpose of an integration test is to check whether you are interacting with them correctly.
When looking at the application’s infrastructure diagram, you will see that the scope of integration tests is broader than the scope of unit tests. They check how your functions interact and how your software integrates with third parties.

[4.2] Example: a test that talks to a database
For example, we have a database that we're connected to, and will use it in our cart functionality:
// cart.js
const { db } = require("./dbConnection");
const createCart = username => {
return db("carts").insert({ username });
};
module.exports = {
createCart
};
Writing tests for it will help ensure that your code works with the database correctly and that the APIs you’re using behave as you expect. So, If you had any incorrect queries, these tests would catch them.
To test the functions in the cart.js module, We can follow a pattern similar to the one we used in unit testing: (Arrange, Act, Assert)
Here's a test for createCart(). It should ensure that the database is clean, create a cart, and then check if the database contains the cart you’ve just created:
// cart.test.js
const { db, closeConnection } = require("./dbConnection");
const { createCart } = require("./cart");
test("createCart creates a cart for a username", async () => {
// 1. Arrange the test data
await db("carts").truncate(); // Delete every row in the carts table
// 2. Act on the data
await createCart("Lucas da Costa");
// Selects value in the username column for all the items in the carts table
const result = await db.select("username").from("carts");
// 3. Assert the result
expect(result).toEqual([{ username: "Lucas da Costa" }]);
await closeConnection(); // Tears down the connection pool
});
Usually in integration tests, we notice that we have prerequisites and postrequisites for tests that have dependencies like "connecting to a database", they can be handled in what we call "Testing Hooks":
afterAll&afterEachhooks to close the connection pool only after all tests have run and remove the invocation ofcloseConnection()from within the test.beforeAll&beforeEachhooks to clean the database before they run. If you do this, there’s no need to repeat thetruncate()statements on every test.
So testing the interaction with the database using hooks will look like this:
// cart.test.js
const { db, closeConnection } = require('./dbConnection');
const { createCart, addItem } = require('./cart');
// Prerequisites: clear the carts and carts_items tables before each test
beforeEach(async () => {
await db('carts_items').truncate();
await db('carts').truncate();
});
// Postrequisites: tear down the connection pool once all tests have finished,
afterAll(async () => await closeConnection());
test('createCart creates a cart for a username', async () => {
await createCart('Lucas da Costa');
const result = await db.select('username').from('carts');
expect(result).toEqual([{ username: 'Lucas da Costa' }]);
});
[5] End-to-end tests
End-to-end tests are the most comprehensive tests, they validate your application by interacting with it as a real user would.
E2E tests don’t use your software’s code directly as unit tests do. Instead, they interact with it from an external perspective. So they end up covering a large surface of the application, as shown in the illustration. They rely on the client side working as well as all the pieces of software in the backend (the entire application).

For example, In an E2E test to validate whether it’s possible to add an item to a cart:
It wouldn’t directly call the
addToCart()function.Instead, it would open your web application, click the buttons with “Add to Cart” written on them, and then check the cart’s content by accessing the page that lists its items.
E2E tests also tend to take more time to run and, therefore, run less frequently. Unlike in unit tests, it’s not feasible to run end-to-end tests whenever you save a file.
E2E tests are very valuable and expensive, take relatively more time to run, and need a smaller quantity. So these tests are more suited for a later stage of the development process. Thus they are at the very top of the testing pyramid.

E2E tests can be divided into 2 parts:
Testing HTTP APIs
Testing GUIs
[5.1] Testing HTTP APIs
Tests for HTTP APIs are excellent for ensuring that services follow the established “contracts”. When multiple teams have to develop different services, these services must have well-defined communication standards, which you can enforce through tests. Tests will help prevent services from not being able to talk to each other

HTTP API tests have a narrower scope than GUI tests as they only focus on probing the backend of the application. Hence, the testing pyramid divides the area for end-to-end tests and places HTTP API tests below GUI tests.

For example, let's say we have an endpoint to get items in the cart:
To retrieve the cart’s content, the client must send a request to
GET/carts/:username/items.To write a test that uses HTTP requests to add items to a cart and check the cart’s contents. Even though you are making HTTP requests instead of calling functions, the general formula for tests should be the same: (Arrange, Act, Assert).
Because tests for RESTful APIs require only a client capable of performing HTTP requests and inspecting responses, we can write them within Jest using fetching libraries like Axios to perform HTTP requests within our tests.
// server.test.js
const axios = require("axios");
const apiRoot = "http://localhost:3000";
const app = require("./server");
// HELPER FUNCTIONS
const getItems = async username => {
const response = await axios.get(`${apiRoot}/carts/${username}/items`);
return response;
};
test("getting cart items for a user", async () => {
// 1. Lists the items in a user’s cart
const itemsResponse = await getItems("lucas");
// 2. Checks whether the response’s status is 200
expect(initialItemsResponse.status).toBe(200);
// 3. Checks whether the response data include the correct items
expect(await finalItemsResponse.data.json()).toEqual(["cake"]);
});
// Stops the server after finishing all tests
afterAll(() => app.close());
[5.2] Testing GUIs
GUI (Graphical User Interface) tests cover your entire application. They will use its client-side to interact with your backend-side, therefore, touching every single piece of your stack. that's why they're usually called "UI tests".

UI tests require specialized tools capable of interacting with web page elements, such as buttons and forms, and controlling a real browser. Popular tools for UI testing, including Cypress, TestCafe, and Selenium, allow browser interaction through JavaScript control.
UI tests follow a similar structure to other types of tests, including setting up a scenario, performing actions, and making assertions. However, the main difference is that UI tests involve browser interactions, where actions happen through the browser, and assertions depend on a web page's content.
Because these tests cover all parts of your application, they have the highest place in the testing pyramid, as shown below. They take the longest to run, but they also provide the strongest possible guarantees.

Summary
The testing pyramid is a metaphor for categorizing tests based on their frequency, quantity, scope, and quality guarantees. As we move up the pyramid, tests become less frequent but more valuable, with broader scope and stronger quality guarantees.
Test runners organize tests, generate output, and include assertion libraries to compare the output. Jest is a popular example.
Tests follow a three-step formula: arrange, act, and assert, which involves setting up a scenario, triggering an action, and checking the results.
Unit tests ensure software quality by testing functions at a granular level, providing precise feedback.
Integration tests ensure that different parts of an application work together, and may require access to external third-party libraries or dependencies like databases.
End-to-end tests interact with an application like a user, providing strong quality guarantees by simulating real-world use-case scenarios.


