Unit Testing
What a unit actually is, what belongs in a unit test and what does not, and the properties that separate a unit suite people run from one they skip.
3 min read · updated 19 September 2026
A unit test is a test that runs in memory, in microseconds, and fails for exactly one reason. Everything else people argue about — whether a unit is a class or a function, whether collaborators may be real — is downstream of those three properties.
#The operational definition
The structural definitions ("a unit is a class", "a unit is a method") fall apart as soon as you meet a codebase where the interesting behaviour spans three small classes that only make sense together. The operational definition holds up better:
A unit test touches no network, no file system, no database, no clock and no environment. It runs in the same process. It can run in any order, concurrently, an arbitrary number of times, and produces the same result every time.
That is a testable definition. If a test violates it, it is an integration test — which is fine, and useful, and belongs in a different suite with a different budget.
#What it looks like
The same test, in four languages. Note that the shape — arrange, act, assert — does not vary; see arrange-act-assert.
// TypeScript, Jest or Vitest
import { applyDiscount } from './pricing';
describe('applyDiscount', () => {
it('takes 20% off an order over the threshold', () => {
const order = { subtotalCents: 12_000, customerTier: 'standard' };
const result = applyDiscount(order, { thresholdCents: 10_000, percent: 20 });
expect(result.discountCents).toBe(2_400);
expect(result.totalCents).toBe(9_600);
});
it('leaves an order under the threshold untouched', () => {
const order = { subtotalCents: 9_999, customerTier: 'standard' };
const result = applyDiscount(order, { thresholdCents: 10_000, percent: 20 });
expect(result.discountCents).toBe(0);
});
});// C#, xUnit
public class PricingTests
{
[Theory]
[InlineData(12_000, 2_400)]
[InlineData(10_000, 2_000)]
[InlineData(9_999, 0)]
public void Discount_applies_only_at_or_above_the_threshold(int subtotal, int expected)
{
var order = new Order(subtotal, CustomerTier.Standard);
var result = Pricing.ApplyDiscount(order, new DiscountPolicy(10_000, 20));
Assert.Equal(expected, result.DiscountCents);
}
}# Python, pytest
import pytest
from pricing import apply_discount, Order, DiscountPolicy
@pytest.mark.parametrize("subtotal,expected", [(12_000, 2_400), (10_000, 2_000), (9_999, 0)])
def test_discount_applies_only_at_or_above_the_threshold(subtotal, expected):
order = Order(subtotal_cents=subtotal, tier="standard")
result = apply_discount(order, DiscountPolicy(threshold_cents=10_000, percent=20))
assert result.discount_cents == expected// Java, JUnit 5
class PricingTest {
@ParameterizedTest
@CsvSource({"12000,2400", "10000,2000", "9999,0"})
void discountAppliesOnlyAtOrAboveTheThreshold(int subtotal, int expected) {
var order = new Order(subtotal, CustomerTier.STANDARD);
var result = Pricing.applyDiscount(order, new DiscountPolicy(10_000, 20));
assertThat(result.discountCents()).isEqualTo(expected);
}
}Note the boundary cases. 10_000 exactly is the test that catches the
off-by-one, and it is the one most often missing.
#The four properties that make a suite survive
Fast. The whole suite, in single-digit seconds. This is not an aesthetic preference: it determines whether the tests run in your editor on save or only in CI, and that in turn determines whether they find bugs in the thirty seconds after you write them or the twenty minutes after you push.
Independent. Any test, in any order, alone or concurrently. The moment one test depends on another having run first, the suite acquires a hidden execution order that nobody wrote down, and parallelising it becomes impossible.
Deterministic. Same input, same result, every time, on every machine. The three classic leaks are the clock, randomness and the file system — all three are injectable, and injecting them is the single highest-return testability change most codebases can make. See dependency injection.
Diagnostic. When it fails, the name tells you what broke and the
assertion tells you how. test_pricing_2 fails and you start reading code;
discount_applies_only_at_or_above_the_threshold fails and you already know.
#What does not belong in a unit test
- Anything crossing a process boundary. That is an integration test.
- Assertions about how the work was done — that this method called that
method — unless the call itself is the observable behaviour. This is the
central argument of unit testing with mocks
and it is worth reading before you write a codebase full of
verify(...)calls. - Getters, setters and constructors with no logic. A test that restates the implementation has negative value: it cannot fail for a reason you care about, and it must be updated every time the implementation is refactored.
- Framework behaviour. You are not testing that your ORM can save, or that your web framework routes. Those are integration concerns and, mostly, somebody else's tests.
#The design feedback
The most valuable thing a unit test tells you is often not whether the code works. It is how hard the test was to write.
A test that needs eleven lines of setup is telling you the unit has eleven
dependencies. A test that cannot be written without a database is telling
you that business logic and persistence are tangled. A test that needs the
clock frozen is telling you that DateTime.Now is buried somewhere it
should not be.
Listen to that. It is the cheapest design review available, and it is the real argument for TDD — not that writing tests first finds more bugs, but that it makes the pain of a bad design arrive immediately rather than in six months.
Common questions
- What counts as a unit?
- A unit is a behaviour, not a class or a method. The useful definition is operational rather than structural — if the test runs in memory, touches no network, disk, clock or database, and fails for exactly one reason, it is a unit test. Whether it exercises one class or five is an implementation detail of the behaviour you are describing.
- Should unit tests touch the database?
- No. A test that touches a real database is an integration test, and a valuable one, but it is not a unit test and should not live in the fast suite. The distinction matters because the fast suite is the one you run on every save, and anything that makes it take more than a few seconds ends that habit.
- How many assertions should a unit test have?
- As many as it takes to describe one outcome. The "one assertion per test" rule is a proxy for "one reason to fail", which is the thing that actually matters. Three assertions about the same returned object are fine; three assertions about three unrelated effects are three tests.
Runnable samples for this page
last test results ↗- TypeScript
typescript/src/testing-levels/unit-testing
Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.
Was this page useful?
Related topics
- Unit Testing with MocksThe solitarist approach — isolate the unit behind test doubles. What it buys, the coupling it creates, and the three rules that keep a mock-heavy suite maintainable.
- Unit Testing without MocksThe sociable approach — real collaborators, state-based assertions, and tests that survive refactoring. What it buys, where it breaks down, and how to keep it fast.
- Test DoublesDummy, stub, spy, mock and fake — Meszaros's five kinds of test double, what each is for, and why using the words precisely makes code reviews shorter.
- Arrange-Act-AssertThe three-part shape every readable test has, why the act step should be one line, and the smells that show up when a test will not fit the pattern.
- Writing Testable CodeThe properties that make code easy to test — pure cores, injected edges, no hidden state — and the specific smells that make it hard.
- The Testing PyramidWhy cheap tests must outnumber expensive ones, what the pyramid actually claims, the trophy and honeycomb alternatives, and how to tell which shape you really have.
- Code CoverageWhat the percentage measures, why it is a finding tool rather than a target, how to collect it in each ecosystem, and how to gate on it without causing harm.
- Coverage MetricsLine, statement, branch, condition and path coverage — what each measures, why branch coverage is worth three times line coverage, and what none of them see.