Skip to content
End To End Tester

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
// 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);
  });
});
csharp
// 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
# 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
// 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 ↗
  • TypeScripttypescript/src/testing-levels/unit-testing

Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.

Was this page useful?