Unit Testing Fundamentals: What to Test, How to Test, and When It's Overkill
A practical guide to unit testing for developers. Covers what makes a good test, testing strategies, mocking, test-driven development, common mistakes, and when testing is genuinely not worth the effort.
Here's an unpopular opinion: most developers either test too much or too little. The ones who test too little ship bugs. The ones who test too much ship slowly and maintain a brittle test suite that breaks every time they rename a variable. The sweet spot is understanding what's actually worth testing and writing tests that protect you without slowing you down.
Unit testing isn't about achieving 100% coverage. It's about confidence. You want to know that when you push code, the important stuff still works. Let's figure out what "important stuff" means and how to test it without losing your mind.
What Is a Unit Test, Really?
A unit test checks a small piece of code in isolation. Usually a function. You give it some input, and you verify the output matches what you expect.
// The function
function calculateDiscount(price, discountPercent) {
if (discountPercent < 0 || discountPercent > 100) {
throw new Error("Discount must be between 0 and 100");
}
return price - (price * discountPercent / 100);
}
// The test
test("applies 20% discount correctly", () => {
expect(calculateDiscount(100, 20)).toBe(80);
});
test("throws on invalid discount", () => {
expect(() => calculateDiscount(100, -5)).toThrow();
expect(() => calculateDiscount(100, 150)).toThrow();
});
test("handles zero discount", () => {
expect(calculateDiscount(50, 0)).toBe(50);
});
That's it. No database. No network calls. No UI rendering. Just "does this function do what it claims to do?" That isolation is the whole point. Unit tests are fast because they don't depend on anything external.
The Mental Model: What to Test
Think of your code in three buckets:
1. Pure logic -- always test this. Functions that take input and return output without side effects. Math, string manipulation, data transformation, validation, business rules. These are the easiest and most valuable tests to write. 2. Integration points -- test selectively. Code that talks to databases, APIs, or the filesystem. These need integration tests more than unit tests, but you can unit test the logic around them by mocking the external dependency. 3. Glue code -- usually skip this. Code that just wires things together. A controller that calls a service that calls a repository. Testing glue code often just tests that you called the right function, which isn't very useful.Here's the practical filter: if a bug in this function would cause real problems, test it. If a bug would be immediately obvious (a button doesn't render, a page 404s), a test adds less value.
Anatomy of a Good Test
Good tests share a pattern called Arrange-Act-Assert (or Given-When-Then if you prefer):
def test_user_full_name():
# Arrange: set up the data
user = User(first_name="Jane", last_name="Doe")
# Act: call the thing you're testing
result = user.full_name()
# Assert: verify the result
assert result == "Jane Doe"
Each test should check one behavior. Not one line of code -- one behavior. If full_name() has special handling for missing last names, that's a separate test:
def test_user_full_name_without_last_name():
user = User(first_name="Jane", last_name="")
assert user.full_name() == "Jane"
Name your tests like sentences. Someone reading the test name should understand what behavior it verifies without reading the code. "test_user_full_name_without_last_name" tells you exactly what's being checked. "test_user_3" tells you nothing.
Testing in JavaScript (Jest / Vitest)
Jest and Vitest are the standard tools for JavaScript testing. Vitest is newer and faster (it uses Vite under the hood), but the API is nearly identical.
import { describe, it, expect } from "vitest";
import { parseEmail } from "./email-utils";
describe("parseEmail", () => {
it("extracts username and domain", () => {
const result = parseEmail("user@example.com");
expect(result).toEqual({
username: "user",
domain: "example.com",
});
});
it("returns null for invalid emails", () => {
expect(parseEmail("not-an-email")).toBeNull();
expect(parseEmail("@example.com")).toBeNull();
expect(parseEmail("user@")).toBeNull();
});
it("handles plus addressing", () => {
const result = parseEmail("user+tag@example.com");
expect(result.username).toBe("user+tag");
});
});
The describe block groups related tests. The it blocks are individual test cases. expect sets up an assertion with matchers like toBe, toEqual, toBeNull, toThrow, etc.
Run tests with:
npx vitest # Watch mode (re-runs on file changes)
npx vitest run # Run once and exit
npx vitest run --coverage # Run with coverage report
Testing in Python (pytest)
pytest is the standard. Forget unittest -- pytest is simpler and more powerful.
import pytest
from cart import ShoppingCart
def test_empty_cart_has_zero_total():
cart = ShoppingCart()
assert cart.total() == 0
def test_adding_items_updates_total():
cart = ShoppingCart()
cart.add_item("Widget", price=9.99, quantity=2)
assert cart.total() == 19.98
def test_discount_code_reduces_total():
cart = ShoppingCart()
cart.add_item("Widget", price=100, quantity=1)
cart.apply_discount("SAVE20")
assert cart.total() == 80.0
def test_invalid_discount_code_raises():
cart = ShoppingCart()
with pytest.raises(ValueError):
cart.apply_discount("FAKE_CODE")
Run tests:
pytest # Run all tests
pytest test_cart.py # Run specific file
pytest -v # Verbose output (shows each test name)
pytest -x # Stop on first failure
pytest --cov=cart # Coverage report for the cart module
pytest discovers tests automatically. Any function starting with test_ in any file starting with test_ gets picked up. No registration, no boilerplate.
Mocking: When You Need to Fake Dependencies
Sometimes your code calls external things -- APIs, databases, the clock. You don't want your unit tests making real HTTP requests. That's where mocking comes in.
JavaScript (Vitest):import { describe, it, expect, vi } from "vitest";
import { fetchUserProfile } from "./user-service";
import * as api from "./api-client";
describe("fetchUserProfile", () => {
it("returns formatted user data", async () => {
// Mock the API call
vi.spyOn(api, "get").mockResolvedValue({
id: 1,
first_name: "Jane",
last_name: "Doe",
email: "jane@example.com",
});
const profile = await fetchUserProfile(1);
expect(profile).toEqual({
id: 1,
displayName: "Jane Doe",
email: "jane@example.com",
});
expect(api.get).toHaveBeenCalledWith("/users/1");
});
});
Python (unittest.mock):
from unittest.mock import patch, MagicMock
from user_service import get_user_display_name
@patch("user_service.database")
def test_get_user_display_name(mock_db):
mock_db.find_user.return_value = {
"id": 1,
"first_name": "Jane",
"last_name": "Doe",
}
result = get_user_display_name(1)
assert result == "Jane Doe"
mock_db.find_user.assert_called_once_with(1)
The mocking rule of thumb: mock things at the boundary of your system. Mock the HTTP client, not three layers deep inside your code. If you're mocking five things to test one function, either the function does too much or you should write an integration test instead.
Edge Cases Worth Testing
Most bugs live at the edges. Here's a checklist of things to consider:
- Empty inputs. Empty strings, empty arrays, null/undefined, zero.
- Boundary values. First element, last element, off-by-one scenarios.
- Invalid inputs. Wrong types, negative numbers, strings where numbers are expected.
- Large inputs. Does your function handle 10,000 items gracefully?
- Unicode and special characters. Names with accents, emoji in strings, RTL text.
describe("truncateText", () => {
it("returns short text unchanged", () => {
expect(truncateText("hello", 10)).toBe("hello");
});
it("truncates long text with ellipsis", () => {
expect(truncateText("hello world", 8)).toBe("hello...");
});
it("handles empty string", () => {
expect(truncateText("", 10)).toBe("");
});
it("handles maxLength of zero", () => {
expect(truncateText("hello", 0)).toBe("...");
});
it("handles text exactly at maxLength", () => {
expect(truncateText("hello", 5)).toBe("hello");
});
});
You don't need to test every possible edge case. But spend 30 seconds thinking "what weird inputs could this receive?" and test the ones that would cause actual bugs.
Test-Driven Development (TDD): The Honest Take
TDD says: write the test first, watch it fail, write the minimum code to make it pass, then refactor. Red-green-refactor.
Here's the honest take: TDD is great for some things and awkward for others.
TDD works well for:- Well-defined algorithms and business logic
- Bug fixes (write a test that reproduces the bug first)
- Utility functions and data transformations
- Code that has clear inputs and outputs
- Exploratory coding where you don't know the shape of the solution yet
- UI code where the "right answer" is subjective
- Prototype code that might get thrown away
When Testing Is Overkill
Not everything needs tests. Here's when I'd skip them:
Trivial getters and setters. If your test is literallyexpect(user.getName()).toBe("Jane") and getName() just returns this.name, you're testing the programming language, not your code.
One-off scripts. A migration script you'll run once and never touch again? Probably not worth a full test suite. Just test it manually.
Prototype code. If you're exploring an idea and might throw away the code tomorrow, writing tests slows down the exploration with no payoff.
Pure configuration. Testing that your config object has the right keys is just duplicating the config file in test form.
Framework glue. Testing that your Express route handler calls res.json() is testing Express, not your code. Test the business logic that the handler calls instead.
The anti-pattern is testing for the sake of coverage numbers. A project with 95% coverage that only tests trivial code is worse than a project with 60% coverage that tests every critical business rule.
Common Mistakes
Testing implementation instead of behavior. Your test should verify what the code does, not how it does it internally. If you refactor the internals without changing the behavior, your tests shouldn't break.// Bad: tests implementation details
test("uses forEach to process items", () => {
const spy = vi.spyOn(Array.prototype, "forEach");
processItems([1, 2, 3]);
expect(spy).toHaveBeenCalled();
});
// Good: tests behavior
test("doubles each item", () => {
expect(processItems([1, 2, 3])).toEqual([2, 4, 6]);
});
Over-mocking. If you mock so many things that your test doesn't resemble real execution at all, it's not testing much. The test passes, but the real code might still be broken.
Flaky tests. Tests that sometimes pass and sometimes fail -- usually because they depend on timing, random data, or shared state. Flaky tests erode trust in the entire test suite. Fix them or delete them.
Not running tests locally. If you only run tests in CI, you find failures 10 minutes after pushing instead of instantly. Set up your editor to run tests on save.
Giant test files. If your test file is 1000 lines, split it up. Group tests by feature or behavior, not by the file they're testing.
Structuring Your Tests
A common project structure:
src/
cart.js
cart.test.js # Co-located with source
utils/
email.js
email.test.js
Or with a separate test directory:
src/
cart.js
utils/
email.js
tests/
cart.test.js
utils/
email.test.js
Both work. Co-location is increasingly popular because it keeps related files together. Use whatever your team prefers.
A Real-World Testing Strategy
Here's what a pragmatic testing approach looks like for a typical web application:
- Unit test all business logic. Validation rules, calculations, data transformations, state machines. These are your highest-value tests.
- Unit test utility functions. Date formatting, string manipulation, data parsing. These are easy to test and frequently reused.
- Integration test critical paths. User signup, checkout flow, data import. These verify that your units work together correctly.
- Skip testing glue code. Route handlers, simple CRUD endpoints, configuration. The integration tests cover these indirectly.
- Write regression tests for every bug. Before fixing a bug, write a test that reproduces it. This ensures it never comes back.
Getting Started
If you have an existing codebase with no tests, don't try to retroactively test everything. Start with:
- Pick the most critical module -- the one where bugs would cost real money or data.
- Write tests for its public API (the functions other code calls).
- Next time you fix a bug anywhere, write a test for it first.
- Next time you add a new feature, write tests alongside it.
If you're building your programming fundamentals and want to practice writing testable code from the start, CodeUp has exercises that reinforce the kind of clean, modular thinking that makes testing natural rather than painful.