Unit testing is the practice every team claims to do and almost none does consistently well. The tests that get written break on every refactor, take minutes to run, and require a running database. None of those are unit tests — they are poorly-structured integration tests with the wrong scope. Good unit tests are fast, isolated, and tell you exactly what broke. Here is what that looks like in 2026.
What changed in 2026
- Vitest replaced Jest as the default JS test runner for new projects — it is 3–5× faster via native ESM, requires zero transform config, and shares Vite's configuration.
- pytest remains dominant in Python, but
pytest-asyncio graduated to stable, making async test support first-class.
- AI-assisted test generation (GitHub Copilot, Cursor) accelerates writing the boilerplate, but human review is still required — generated tests tend to test implementation not behaviour.
- Coverage as a gate is declining; mutation testing tools (Stryker, mutmut) that measure test quality over quantity are gaining adoption.
What is a unit?
A "unit" is a single function, method, or class. A unit test:
- Exercises one behaviour (not one method — a single method may have multiple behaviours)
- Runs in memory with no real I/O
- Completes in under 10 ms
- Is deterministic (same result every run)
If your test starts a server, connects to a database, or reads a file, it is not a unit test. That is fine — integration tests have their place — but they belong in a different suite.
Arrange-Act-Assert
Every good test follows AAA:
// Vitest / Jest — TypeScript
import { describe, it, expect } from "vitest";
import { calculateDiscount } from "./pricing";
describe("calculateDiscount", () => {
it("applies 20% discount for premium users", () => {
// Arrange
const user = { tier: "premium" };
const price = 100;
// Act
const result = calculateDiscount(price, user);
// Assert
expect(result).toBe(80);
});
});
Each test should have exactly one reason to fail. If an assertion fails, the test name should tell you what broke.
The testing pyramid
| Layer |
Speed |
Count |
Scope |
| Unit |
~1 ms |
Most (~70%) |
One function/class, no I/O |
| Integration |
~100 ms–1 s |
Some (~20%) |
Multiple classes, real DB or HTTP |
| End-to-end |
~5–30 s |
Few (~10%) |
Full stack, browser or API |
A fast suite needs the pyramid shape: many unit tests, fewer integration tests, a handful of E2E tests. Inverting it — lots of E2E — makes CI slow and flaky.
What to test
| Worth testing |
Not worth testing |
| Business logic with conditions or calculations |
Getters/setters with no logic |
| Error paths and edge cases |
Private methods (test through the public API) |
| Parsing / transformation functions |
Framework routing boilerplate |
| Boundary conditions (empty, max, negative) |
Auto-generated code |
Test behaviour: "when input is X, output is Y." Do not test that a particular internal function was called.
Python example with pytest
# test_pricing.py
import pytest
from pricing import calculate_discount
def test_premium_user_gets_20_percent_off():
assert calculate_discount(100, tier="premium") == 80
def test_free_user_gets_no_discount():
assert calculate_discount(100, tier="free") == 100
def test_negative_price_raises():
with pytest.raises(ValueError, match="price must be positive"):
calculate_discount(-1, tier="free")
Each test function is one behaviour. The test name is the specification.
How to pick what to test first
- Start with pure functions — no mocks, easy to write.
- Cover the happy path first, then add edge cases.
- Write the test for any bug you fix — prevent regression.
- Prioritise code that is expensive to break — billing, auth, data transformations.
Common mistakes
Testing implementation. Asserting that userService.db.find was called. When you refactor, the test breaks even though behaviour is identical. Test outcomes, not calls.
One test per method. Some methods have many behaviours; some code paths need several tests. Name by behaviour, not by method.
Giant setup. If your beforeEach is 50 lines, the test is too coupled to infrastructure. Move that work to a factory or builder.
No test for the unhappy path. Most bugs live in error branches. Test what happens when the input is invalid, the service is down, or the data is empty.
What to skip
- 100% line coverage as a goal — it measures lines touched, not decisions verified. A test that executes every line without asserting anything passes coverage and catches nothing.
- Testing third-party library behaviour — trust the library's own tests.
- Mocking everything — excessive mocking creates tests that test your mocks, not your code. See Mocking explained in 2026.
FAQ
How many assertions per test?
One logical assertion is the rule of thumb. Multiple expect() calls are fine if they all verify the same outcome. If they verify different outcomes, split the test.
Should I write tests before or after the code?
Both work. TDD (tests first) forces good design and clear interfaces. Tests after work when you are exploring. The important thing is that they get written.
How do I test async code?
Vitest and pytest-asyncio both support async test functions natively. Use await inside the test body.
What is mutation testing?
Mutation testing tools (Stryker for JS, mutmut for Python) automatically introduce bugs into your code and check whether your tests catch them. A mutation score above 80% is a meaningful quality signal.
Where to go next
See Mocking explained in 2026, Integration testing explained in 2026, and Dependency injection explained in 2026.