Expert · Testing and API documentation · topic 44 of 54 · 25 min
Unit testing: checking one piece at a time
Explanation
Why write tests at all?
When you change code, you can break something that used to work. Checking every feature by hand after every change is slow, and people forget steps. A test is code that runs your code with a known input and checks that the output is what you expect. A computer can run hundreds of tests in a few seconds, every time you save.
Tests give you three things. First, they catch bugs before users see them. Second, they let you change and clean up code without fear, because the tests tell you if you broke a rule. Third, they explain how the code should behave: a test named "free delivery above 499 rupees" is documentation that never goes out of date.
What is a unit test?
A unit is the smallest piece of code that does one job, usually one function. A unit test calls that function directly with some input and checks the result. It does not start a server, call the internet or touch a real database. Because of that it is fast (milliseconds) and it always gives the same answer.
Arrange, Act, Assert
Most good tests have three parts. Arrange: prepare the input and anything the code needs. Act: call the function once. Assert: check the result. An assertion is a line that says "this must be true", for example "the total must be 165". If it is not true, the test fails and shows you both values. A test runner is the program that finds your test files, runs them and prints which passed and which failed.
import { describe, it, expect } from 'vitest';
import { cartTotal } from './cart.js';
describe('cartTotal', () => {
it('adds price times quantity for every item', () => {
// Arrange
const items = [{ price: 60, qty: 2 }, { price: 45, qty: 1 }];
// Act
const total = cartTotal(items);
// Assert
expect(total).toBe(165);
});
it('returns 0 for an empty cart', () => {
expect(cartTotal([])).toBe(0);
});
it('throws when a quantity is negative', () => {
expect(() => cartTotal([{ price: 60, qty: -1 }])).toThrow();
});
});Look at the test names. "adds price times quantity for every item" tells you the rule. When it fails, the name alone tells you what broke. Names like "test1" or "works" tell you nothing. A good pattern is: what it does, and when. For example "returns 404 when the note does not exist".
Edge cases: where bugs hide
- Empty: an empty cart, an empty string, an empty list of users.
- Zero: a price of 0, a quantity of 0, a GST rate of 0.
- Negative: a refund of -500, a quantity of -1. Should the code accept it or refuse it?
- Very large: 10 crore rupees, a list of one lakh items, a 5000-character name.
- Rounding: 199.99 with 18% GST is 35.9982 rupees. Money must be rounded to paise (2 decimal places) in a fixed, tested way.
- Wrong type or missing value: undefined, null, the text "100" instead of the number 100.
| Test runner | Language | Notes |
|---|---|---|
| Vitest | JavaScript / TypeScript | Fast, modern, works well with Vite and TypeScript projects |
| Jest | JavaScript / TypeScript | Very common in older and larger projects; almost the same API as Vitest |
| node:test | JavaScript | Built into Node.js; no install needed; run with node --test |
| pytest | Python | The usual choice in Python; tests are plain functions using assert |
| JUnit | Java | The standard runner in Java and Spring projects |
# test_cart.py, run with: pytest
from cart import cart_total
def test_adds_price_times_quantity():
items = [{'price': 60, 'qty': 2}, {'price': 45, 'qty': 1}]
assert cart_total(items) == 165
def test_empty_cart_costs_nothing():
assert cart_total([]) == 0Test doubles: stubs, mocks and fakes
Some code needs things that are slow or dangerous in a test: sending a real SMS, charging a real card, reading the current time. A test double is a stand-in object you pass in instead. A stub returns a fixed answer ("the time is always 10:00"). A fake is a small working version (an SMS sender that saves messages in a list instead of sending). A mock also records how it was called, so the test can check "send was called once with this number". To make this possible, the code should receive its helpers as parameters instead of creating them inside. This is called dependency injection.
// Real code: gets its SMS sender as a parameter
async function sendWelcome(user, sms) {
await sms.send(user.phone, 'Welcome to the app, ' + user.name);
}
// Test: a fake sender that only records messages
const sent = [];
const fakeSms = { send: async (to, text) => { sent.push({ to, text }); } };
await sendWelcome({ name: 'Asha', phone: '9876543210' }, fakeSms);
console.log(sent); // [{ to: '9876543210', text: 'Welcome to the app, Asha' }]Watch the lesson
Unit testing: checking one piece at a time · 1/7
Every time you change code, you might break something that used to work. Tests catch that for you, in seconds.
Prefer a video by a teacher? Find free videos on “unit testing javascript vitest tutorial for beginners” on YouTube ↗
In real life: GST on a kirana billing app
A small billing app adds GST to every bill. One day a developer changes the rounding code, and bills of 199.99 rupees start showing 35.99 rupees of tax instead of 36.00. Shop owners notice a few paise of difference only weeks later, when their accounts do not match. With unit tests like "rounds 18% GST on 199.99 to 36.00", the mistake would have failed the test the moment the code was saved, before any bill was printed. GST rates can also change, so the function takes the rate as a parameter and the tests check several rates.
Practice (0/3 done)
Write calculateGst(amount, rate) that returns the GST in rupees, rounded to paise (2 decimal places). rate is a percent, for example 18. Throw an Error if amount is negative. The tests below cover normal values and edge cases. Hint: amount * rate is the GST in paise, so Math.round(amount * rate) / 100 rounds it safely.
Write an async function sendOtp(phone, sms, makeCode). phone must be 10 digits starting with 6, 7, 8 or 9. If it is not valid, return { sent: false, error: "Invalid phone" } and do NOT call sms.send. If it is valid, get a code from makeCode(), call await sms.send(phone, "Your OTP is " + code) and return { sent: true }. The tests pass in a fake sms object and a stub makeCode, so no real SMS is sent.
A function applyCoupon(cartTotal, coupon) gives "SAVE50" = 50 rupees off on carts of 299 rupees or more, and never makes the total below 0. Write a list of 6 test names (in plain English) with the input and expected output for each. Include edge cases.
Quick quiz
Read more (free)
Pass the quiz (60% or more) or mark this topic as done when you are ready.
Your progress is saved on this device only.