Photo by Ferenc Almasi on Unsplash
Unit Tests That Speak to Developers
With Practical Examples in Javascript and Jest
Introduction
At the beginning of my career, I remember my first software company introducing me to "unit tests." Back then, it was literally a checklist of tasks that I had to manually verify before checking in my code to version control. Over time, these tests have evolved. Now they cover real use cases, edge cases, and more complex scenarios. There’s something fascinating about seeing a test fail because of a code change; it means the test is doing its job. It provides a sense of security and confidence that the codebase is "protected".
As I learned more about automated tests and integrated them into code repositories, I noticed something was off: some tests seemed to be written just for the sake of it. It was easy to create a lot of passing tests, but not all of them were necessarily useful. Over time, I realized that the best tests are the ones that focus on the business logic - the real purpose of software systems. These tests ensure the core functionality works as expected and reflect the real-world scenarios that the software will encounter.
To me, the most valuable tests also act as documentation for the project. They describe the logic and rules the software is following. Rather than aiming for "100% code coverage," it's often more valuable to focus on tests that cover real use cases and different scenarios within the product and business logic.
Earlier this year, I stumbled upon Gerard Meszaros' article, Write Tests for People. In this article, Meszaros asks a fundamental question: "Who am I writing the test for?" The answer is that tests should be written for the person trying to understand your code. Good tests can act as clear documentation that helps developers understand how the system behaves.
This mindset resonated with me. It’s something we as developers can sometimes overlook, myself included. In this blog post, I want to share some examples of unit tests that I believe are truly useful, not because they contribute to some arbitrary percentage of code coverage, but because they provide clarity and real value to the project.
Practical Examples
Example 1: Calculate Discount
Imagine we have a function that calculates a discount given a price and a discount percentage. The function also needs to validate some of its input, as shown below:
function calculateDiscount (price, discountPercentage) {
if (price <= 0 || discountPercentage < 0 || discountPercentage > 100) {
throw new Error("Invalid input");
}
const discount = (price * discountPercentage) / 100;
return price - discount;
}
Bad Test Example ❌
test('test price calculation', () => {
expect(calculatePrice(100, 20)).toBe(80);
});
What’s wrong with this test?
This test is lacking context. The test name is vague and doesn't tell us what specific scenario is being tested.
It doesn't tell us anything about the logic. There’s no indication of why this particular discount and price are being used or what the business rule behind them is.
It's not readable and doesn't give future developers any insight into the functionality, making it hard to maintain or change in the future
Improved Version ✅
describe("calculateDiscount", () => {
test('should throw an error for negative price', () => {
const price = -100;
const discount = 20;
expect(() => calculateDiscount(price, discount)).toThrow('Invalid input');
});
test('should throw an error for discount greater than 100%', () => {
const price = 100;
const discount = 120;
expect(() => calculateDiscount(price, discount)).toThrow('Invalid input');
});
test('should apply a 20% discount on a $100 product', () => {
const price = 100;
const discount = 20;
const result = calculateDiscount(price, discount);
expect(result).toBe(80);
});
test('should return the full price when discount is 0%', () => {
const price = 100;
const discount = 0;
const result = calculateDiscount(price, discount);
expect(result).toBe(100);
});
});
Why is this a better test?
Names are clear and descriptive. They explain the scenarios being tested
Each test describes a real-world scenario, which adds clarity in the use cases that the function covers (valid and invalid inputs)
The tests follows a structured pattern (like the AAA Pattern). This makes it easy to understand and correlate the different scenarios together
The tests cover different scenarios like the error handling, which is an important part of the business logic
Example 2: Discount eligibility
For the second example, we have a function that verifies the eligibility for a discount based on an age. An example implementation is described below:
function isEligibleForDiscount (age) {
if (age < 0 || age > 120) {
throw new Error("Invalid age");
}
return age >= 65;
}
Bad Test Example ❌
test('check discount eligibility', () => {
expect(isEligibleForDiscount(70)).toBe(true);
});
What’s wrong with this test?
The name is vague and generic. It doesn't give info on the scenario
Edge cases are not covered as only one value is used
the test doesn't give any context on the logic of the function and doesn't explain why is it using the provided input and output
Improved Version ✅
describe('isEligibleForDiscount', () => {
test('should return true for age 70 (eligible for senior discount)', () => {
const age = 70;
const result = isEligibleForDiscount(age);
expect(result).toBe(true);
});
test('should return false for age 50 (not eligible for senior discount)', () => {
const age = 50;
const result = isEligibleForDiscount(age);
expect(result).toBe(false);
});
test('should return true for age 65 (boundary value)', () => {
const age = 65;
const result = isEligibleForDiscount(age);
expect(result).toBe(true);
});
test('should throw an error for negative age', () => {
const age = -5;
expect(() => isEligibleForDiscount(age)).toThrow('Invalid age');
});
test('should throw an error for age over 120', () => {
const age = 130;
expect(() => isEligibleForDiscount(age)).toThrow('Invalid age');
});
});
Why is this a better test?
The tests uses descriptive names that tell the story behind the scenario
The tests covers different values for input and expected outputs, including the edge cases
The tests give business context about the underlying logic in the test
The tests follow a structured pattern, which helps in relating the different cases and understanding them
Conclusion
Unit tests can be a powerful tool to not only safeguard our code from bugs and issues, but also to provide clarity and documentation. The key is focusing on real-world use cases and covering the business logic that our software product uses.
The goal isn't to "write tests until we hit 100% test coverage" - rather the objective should be creating tests that offer insights and help in understanding the underlying business logic.
Let's focus on writing tests that matter!