Unit tests are the fastest feedback loop in software development — milliseconds per test, no external services, no shared state. A good unit test suite catches regressions instantly, documents expected behavior, and makes refactoring safe. A bad unit test suite breaks on every change, gives false confidence, and becomes a maintenance burden. The difference is almost entirely in what you choose to test and how.
What changed in 2026
- AI-assisted test generation (GitHub Copilot, Cursor) writes the boilerplate fast, but the test cases it generates often assert on implementation rather than behavior — developers need to review and improve them, not just accept.
- Vitest replaced Jest as the dominant JavaScript unit test runner — it is faster, has native ESM support, and shares configuration with Vite.
- pytest remained dominant in Python with richer plugin support;
pytest-asyncio and anyio made testing async code straightforward.
- Property-based testing (Hypothesis for Python, fast-check for JS) moved from niche to a recommended complement to example-based tests for complex logic.
The core principle: test behavior
A unit test should answer: "given this input, does the function produce the correct output or trigger the correct side effect?"
# Bad: tests implementation details
def test_process_order_calls_helper():
order = Order(items=[...])
with patch.object(order, "_calculate_subtotal") as mock:
order.process()
mock.assert_called_once() # breaks if you rename the method
# Good: tests observable behavior
def test_process_order_returns_correct_total():
order = Order(items=[Item(price=10, qty=2), Item(price=5, qty=1)])
result = order.process()
assert result.total == 25
If the test breaks when you rename an internal method, it is testing implementation — that is a smell.
The Arrange-Act-Assert structure
Every unit test has three phases:
def test_transfer_reduces_sender_balance():
# Arrange
sender = Account(balance=1000)
receiver = Account(balance=200)
# Act
transfer(sender, receiver, amount=300)
# Assert
assert sender.balance == 700
One assertion per test is a useful discipline — when a test fails, you know exactly which expectation was violated. More than one assertion is acceptable when they form a single logical check (e.g., asserting multiple fields of a returned object), but avoid testing two unrelated behaviors in one test.
What to mock
Mock at the boundary of the unit under test — the interfaces that connect it to the outside world:
from unittest.mock import MagicMock, patch
def test_send_welcome_email_on_signup(mock_email_client):
email_client = MagicMock()
user_service = UserService(email_client=email_client)
user_service.register(email="user@example.com", password="secret")
email_client.send.assert_called_once_with(
to="user@example.com",
template="welcome",
)
Mock: HTTP clients, database sessions, file I/O, clocks, random number generators.
Do not mock: the code under test, utility functions within the same module, data classes.
Injecting dependencies (via constructor or function parameter) makes mocking easy. Global state and direct imports make it painful.
JavaScript example with Vitest
// src/pricing.ts
export function applyDiscount(price: number, coupon: string): number {
if (coupon === "SAVE10") return price * 0.9;
if (coupon === "SAVE20") return price * 0.8;
return price;
}
// src/pricing.test.ts
import { describe, it, expect } from "vitest";
import { applyDiscount } from "./pricing";
describe("applyDiscount", () => {
it("applies 10% for SAVE10", () => {
expect(applyDiscount(100, "SAVE10")).toBe(90);
});
it("applies 20% for SAVE20", () => {
expect(applyDiscount(100, "SAVE20")).toBe(80);
});
it("returns full price for unknown coupon", () => {
expect(applyDiscount(100, "FAKE")).toBe(100);
});
});
Cover the happy path, edge cases, and invalid inputs. Three tests for a three-branch function is the right starting point.
Coverage: what to aim for
| Code type |
Coverage goal |
| Core business logic (pricing, rules, calculations) |
90–100% |
| Service/orchestration layer |
80–90% |
| Utilities and helpers |
80%+ |
| Controllers / route handlers |
60–80% (lean on integration tests) |
| Generated code, migrations |
Skip |
| Boilerplate getters/setters |
Skip |
Coverage is a floor, not a ceiling. 100% coverage does not mean the code is correct — it means every line ran. A function that returns None instead of raising on bad input can have 100% coverage and wrong behavior.
How to pick what to test first
- Business logic with branching? High priority — every branch is a potential bug.
- Input validation and edge cases? High priority — these are the failure modes users encounter.
- Pure functions (no side effects)? Easy to test, high ROI; do them first.
- Functions with complex mocking requirements? Consider refactoring to separate concerns first — hard-to-test code is usually poorly structured.
- Framework glue code (routing, ORM queries)? Low priority for unit tests; cover with integration tests.
Common mistakes
Testing that mocks were called instead of testing outcomes. If your test only checks that send_email was called, you learn nothing about whether the email content is correct. Verify the call arguments.
One giant test file per module. Group tests by behavior, not by file. test_checkout_with_valid_card, test_checkout_with_expired_card is better than one test_checkout with a dozen assertions.
Not running tests on save. Vitest and pytest both support watch mode. Tight feedback loops catch issues immediately.
Brittle date/time assertions. Do not assert result.created_at == datetime.now() — the clock moves. Inject a clock mock or assert within a tolerance.
Slow unit tests. If a unit test takes more than 100ms, it is probably doing real I/O. Find and mock the slow dependency.
What to skip
- Testing third-party libraries — they have their own tests. Test how your code uses them.
- 100% coverage as a hard requirement — it shifts effort from high-value tests to testing trivial code.
- AI-generated tests you have not read — they may assert on the wrong thing, giving false confidence.
FAQ
What is the difference between unit and integration tests?
Unit tests test a single unit in isolation, with dependencies mocked. Integration tests test multiple units working together, often against real or in-process databases.
Should I write tests before or after code?
TDD (test-first) forces you to think about the interface before the implementation, often leading to better design. Writing tests after works too — the important thing is that they exist and are maintained.
How many unit tests should a function have?
At minimum: one per code path (one per branch). Also add tests for boundary values and invalid inputs. A function with 3 if-branches needs at least 3–5 tests.
What is property-based testing?
Instead of handwriting specific inputs, you define invariants ("the output is always non-negative") and a framework like Hypothesis generates hundreds of random inputs to try to falsify them. Excellent for finding edge cases in parsing, calculations, and transformations.
Where to go next