What Is Unit Testing?
Unit testing checks the smallest testable piece of code, a function, a method, or a class, in isolation from the rest of the system. Developers write unit tests alongside the code, the tests usually run in milliseconds, and after every change they report whether that piece still works. A simple example: calculateDiscount(100) must return 10. A green unit test suite does not tell you whether the requirement behind the code was understood correctly, and some of the most expensive defects hide exactly there.
The ISTQB calls this test level component testing and adds “also known as unit testing”; older literature speaks of module testing. All three names describe the same thing. The ISTQB glossary also carries its own unit testing definition: “a test level that focuses on evaluating the smallest part of code testable in isolation.” In everyday project language, unit test is the term that stuck. I cannot remember a single project in my 25 years where the team called it anything else.
Unit testing is the earliest point at which a test can execute new code, and the ISTQB lists “early testing saves time and money” among its seven testing principles. A bug that a unit test catches sits close to the code, and the developer usually finds the cause quickly. The same bug found in system testing first has to be traced back through several layers. Unit tests also keep code changeable, because after every change they show within seconds whether existing behavior still holds.
The design shows in the unit tests as well. When someone says “you can’t unit test this,” the code usually has too many dependencies, units that are too large, or coupling that is too tight. The same applies to execution time: if unit tests run slowly, the way the units are cut deserves a second look.
What Does a Unit Test Look Like? An Example
A unit test calls one function with defined inputs and compares the result with the expected value. Take an online shop that grants a 10 percent discount on every order. The function calculateDiscount(orderValue) should return 10 for an order of 100, return 0 for an empty order, and reject negative values. In TypeScript with the Vitest framework, the three test cases look like this:
import { describe, it, expect } from "vitest";
import { calculateDiscount } from "./pricing";
describe("calculateDiscount", () => {
it("returns 10 percent of the order value", () => {
expect(calculateDiscount(100)).toBe(10);
});
it("returns 0 for an empty order", () => {
expect(calculateDiscount(0)).toBe(0);
});
it("rejects a negative order value", () => {
expect(() => calculateDiscount(-1)).toThrow();
});
});All three follow the Arrange, Act, Assert pattern in compact form: the input is set up inline, calling the function is the act, and expect() is the assert. In the third test, act and assert merge into one line, because the call itself has to throw. The first test covers the standard case, the second the boundary at zero, the third an input outside the specification. A single function with real logic usually yields three to five test cases this way. In Java the same tests would use JUnit, in Python pytest, with the same structure.
Who Writes Unit Tests?
Developers write unit tests, as a rule for the code they wrote themselves. The ISTQB Foundation Level syllabus states that component testing is normally performed by developers in their development environments. Nobody knows the inner logic of a unit better than its author, and the tests should exist before the change reaches the shared codebase.
Testers rarely write unit tests themselves, but they make them sharper. Boundary values, equivalence partitions, and negative cases are test design knowledge. When a tester reviews the unit tests with that perspective, missing edge cases surface earlier.
How Does a Unit Test Differ from an Integration Test?
A unit test checks one unit on its own; an integration test checks how several units work together across their interfaces. To isolate a unit, stubs and mocks replace its dependencies and simulate the calls and responses of the neighboring components. A test that does not run in isolation also exercises those dependencies, so a failure can originate in any of them.
| Unit test | Integration test | |
|---|---|---|
| Test object | a single function, class, or module | interplay of several components |
| Focus | inner logic of the unit | interfaces and data exchange |
| Dependencies | replaced by stubs and mocks | real or partially simulated |
| Test basis | component specification, design, code | architecture, interface specifications |
| Typical defects | calculations, boundaries, exceptions | data formats, call sequences, error propagation |
| Execution time | milliseconds to seconds | noticeably slower |
| Responsibility | development | development, at higher levels also test teams |
In the ISTQB model, the next level up is component integration testing, which targets the interfaces and interactions between the units. Incompatible data formats or faulty call sequences stay invisible until that level.
How to Write a Unit Test
Writing a unit test starts with three decisions: what the test is based on, which test cases it needs, and where it runs with which data. In classic projects these decisions go into a unit test plan, the level test plan for this test level. Agile teams usually record them in their definition of done instead.
Test Basis
The test basis is everything that describes the unit: details from design or architecture, parts of a user story or requirement, and sometimes component specifications or models. For structure-based test cases and branch coverage, the source code itself belongs to the test basis.
Deriving Test Cases
The structured test design techniques suit this level particularly well: equivalence partitioning, boundary value analysis, and decision tables, plus combinatorial approaches such as pairwise testing or the classification tree. Value ranges, data types, and conditions are far more concrete here than in system or acceptance testing, which makes test cases easier to derive. In ISTQB terms, a test case is a set of preconditions, inputs, actions (where applicable), expected results, and postconditions, developed from test conditions; in unit testing, one test case usually fits into a single test method.
Negative tests deserve the most attention, because the defects that become expensive later sit in exactly these cases. A unit test that only covers the standard case says little about how the unit handles corrupt data, boundary values, or parameters outside the specification.
Environment and Data
Unit tests usually run in two places: in the development environment, quickly and without ceremony, and on the build server or in the CI pipeline with every commit. Test drivers, stubs, and mocks stand in for the calling and called classes. The test data stays small because each test covers one aspect, and it lives next to the test cases or in a shared repository. In larger projects a test data generator helps with edge cases that would be tedious to maintain by hand.
Is Unit Testing Difficult?
Writing a single unit test is not difficult: with a framework it takes a few lines, as the example above shows. Unit testing gets difficult when the code was not designed for it. A class that opens its own database connection, reads the system clock, and calls a web service can only be isolated after its design changes.
Legacy code is the second hurdle. Without existing tests, every refactoring meant to make the code testable is a risk in itself, so teams often start with tests around the parts they have to change next. The third hurdle is judgment: which cases matter, how much to mock, and when a test checks behavior rather than mirroring the implementation. Test design techniques help with the first of these questions, since equivalence partitioning and boundary value analysis give a systematic answer to which cases a unit needs.
What Makes a Good Unit Test?
A good unit test is fast, independent of other tests, repeatable with the same result every time, and self-validating: it turns green or red without anyone having to read log files. The clean code community sums these properties up as the FIRST principles: Fast, Independent, Repeatable, Self-validating, Timely.
Readability comes on top. A good unit test checks one aspect, carries a name that states what it expects, and follows the Arrange, Act, Assert structure. Tests written this way also document how a class is meant to be used.
Mocking with a Limit
Mocking replaces a unit’s dependencies with simulated objects so the test runs isolated and fast. The ISTQB groups such stand-ins under the term test double and separates several kinds: a stub provides predefined responses, while a mock simulates the expected behavior of the component during the test. Instead of the real database, a stub might return a fixed customer record; a mock of a web service can also check whether the unit called it as expected. Test doubles pay off when a dependency is slow, unavailable, or hard to bring into a defined state.
A test that replaces every dependency mostly checks that simulated behavior passes through correctly and tells little about the unit’s own logic. My rule: mock what is slow or external, and use real objects wherever they are fast and deterministic.
Test First: Test-Driven Development
Unit tests are best written alongside the code or before it, and at the latest before the change reaches the shared codebase. Tests postponed until “later” tend never to get written, because the effort grows as soon as the details of the unit fade from memory.
Test-driven development (TDD) reverses the order completely: write a test, watch it fail, write just enough code to make it pass, then clean up. This rhythm builds testability into the code from the start, and it shows in the architecture. TDD demands discipline, though, and it works best for logic with a clear specification.
How Much Coverage Do Unit Tests Need?
No fixed number answers this well. Code coverage shows how much of the source code the tests execute, measured for example as statement coverage or branch coverage. Many teams turn it into an exit criterion or part of the definition of done: “unit tests must reach 80 percent code coverage.”
Teams then chase the number with the simplest possible tests, and special cases and negative tests fall away because they barely move the percentage. One hundred percent coverage with weak tests is worth less than seventy percent with good ones. Coverage works as a diagnostic tool that shows which areas no test touches, though it says little about test quality.
Can Unit Tests Run Manually?
Technically yes, in practice no. Automation is the default on this test level, because a unit test earns its value by running within seconds on every code change. Stepping through a unit in the debugger or a REPL helps when hunting a bug, and it still does not replace an automated test case.
Frameworks and the CI Pipeline
A unit testing framework supplies the structure for this: it organizes the test cases, runs them, and reports the results. Every common language has established ones: JUnit for Java, pytest for Python, NUnit for .NET, and Jest or Vitest for JavaScript and TypeScript. In the CI pipeline, the framework runs as a build step on every commit or pull request, in GitHub Actions, GitLab CI, or Jenkins, for example, and a failing test breaks the build. This setup needs a short run time (minutes, not hours), a readable test report, and the rule that a red build gets fixed immediately.
Agile teams depend on this most. A team that ships every two weeks cannot re-verify the whole product by hand before each release. The test pyramid captures the idea. Mike Cohn’s original model has three layers: unit tests at the base, service tests in the middle, and UI tests at the top; a popular variant names them unit, integration, and end-to-end tests. In both, the bottom layer holds many small, fast, isolated tests and the top layer a few slow tests that cover large parts of the functionality.
Where Unit Tests Do Not Pay Off
Any unit with logic of its own needs unit tests. The exceptions are trivial code without such logic, plain getters and setters for example, throwaway prototypes, and areas where the risk lies in the interplay, such as pure configuration or UI layout.
Exploratory work on an unclear problem is a special case. There it can make sense to hold off on the tests until the structure has settled.
Three Problems I Keep Running Into
In projects, I keep running into the same three problems:
- Negative tests and special cases are missing. The unit tests then show that the standard case works, but not what happens with corrupt data or parameters outside the specification.
- Unit tests never get written. At first this goes well: the software is still small, everything runs, and progress looks great. In the background, though, technical debt piles up that is hard to pay down later.
- Tests count as a separate task. “I’m done, I just need to write the tests” shows that the team sees unit tests as an add-on. The teams I see work best count a task as done only when its tests exist too, and that attitude leads to markedly better results.
What a Green Test Suite Cannot Show
Unit tests verify that a unit does what its developer intended. They cannot tell whether that intention was right: a misunderstanding in the requirements travels into code and test alike. Catching it takes the higher test levels and a comparison against the requirements. Unit tests also leave the interplay of the units to integration testing.
From Practice
For me, unit testing is the most important of all test levels. It is the first thing I look at when I start a new consulting project, because this is where I see how stable an application is. Most teams have some unit tests by now, more than on any other level, and this is the level where a project gains the most for the least effort. Implementation often falters, though: the focus sits on the coverage number, or the team treats writing tests as a necessary evil.