What is unit testing? How to write, structure, and run unit tests
Unit testing is the practice of testing the smallest testable parts of a program — functions, methods, or classes — in isolation. Learn what a unit test is, the arrange-act-assert structure, test doubles and mocking, the testing pyramid, and best practices for writing good unit tests.
On this page
- What counts as a "unit"
- The arrange–act–assert structure
- Test doubles: mocks, stubs, fakes, and spies
- The testing pyramid
- Test-driven development
- What makes a good unit test
- Where unit testing fits with the rest of QA
- FAQ
- What is the difference between a unit test and an integration test?
- Do I need a separate testing framework, or can I write my own assertions?
- What is code coverage and how much should I aim for?
- When should I not write a unit test?
- Summary
A unit test is an automated test that exercises the smallest testable part of a program — typically a single function, method, or class — in isolation, and checks that the code produces the expected output for a given input. Unit testing is the practice of writing and running many such tests so that you can catch regressions early, document how your code is meant to be used, and refactor with confidence. The idea dates back to the early days of software engineering and was popularised in its modern form in 1997, when Kent Beck and Erich Gamma released JUnit, a unit-testing framework for Java whose design was quickly ported to nearly every mainstream language.
Unlike a manual check — running the program, typing values, and eyeballing the result — a unit test is a piece of code that runs other code and reports pass or fail automatically. It can be executed thousands of times during development, integrated into a CI pipeline, and run again on every commit. If the behavior under test changes unexpectedly, the test fails and tells you exactly which case broke and where.
What counts as a "unit"
The "unit" in unit testing is deliberately the smallest meaningful piece of code you can test on its own. In practice this usually means:
- A pure function, such as
addTax(price, rate)orslugify(title). - A single method on a class, such as
Cart.add(item)updating the running total. - A small module or component whose only collaborators are passed in as arguments.
The point of the unit boundary is isolation. A unit test should not talk to a real database, hit the network, read from the file system, or depend on a specific wall-clock time. When those external systems are required, you reach for integration tests instead and stub the boundary so the unit test stays fast and deterministic.
The arrange–act–assert structure
Most unit tests follow a simple three-step shape that web.dev's testing course calls arrange, act, assert:
- Arrange — set up the inputs, the system under test, and any collaborators the function needs. This is where you create the object, prepare the arguments, and decide what mocks to install.
- Act — invoke the function or method under test exactly once. If you find yourself calling the code many times in one test, that is usually a sign the test is checking more than one thing.
- Assert — compare the actual result against the expected one. The test passes if the assertion holds; it fails otherwise, with a clear message pointing to the difference.
Keeping these three steps distinct — and naming the cases so the failure tells you the scenario, not just the line number — is what makes unit tests useful as documentation of the code's intended behavior.
Test doubles: mocks, stubs, fakes, and spies
When the code under test depends on something you do not want to exercise in a unit test — a database, an HTTP client, the system clock, a payment gateway — you replace that dependency with a test double. Wikipedia's article on unit testing groups these under the umbrella of test doubles; in practice the four most common kinds are:
- Stub — returns canned answers to specific calls. Used to force the code down a known path (for example, making a "fetch user" call always return a fixed user object).
- Mock — a stub that also records how it was called, so the test can assert that the function called the dependency the right number of times with the right arguments.
- Fake — a lightweight working implementation, such as an in-memory database, that behaves realistically enough for the test but does not touch real infrastructure.
- Spy — wraps a real implementation and records the calls made to it, useful when you want to keep the original behavior but still observe how it was used.
Mocking is a powerful tool, but it has a sharp edge. As the web.dev article on what to test warns, heavily mocking dependencies means you are testing your mocks, not your code. The best unit tests minimise mocking, prefer fakes over hand-rolled stubs where possible, and only mock at boundaries that are genuinely slow, non-deterministic, or external.
The testing pyramid
Tests come in many sizes. web.dev describes a common testing pyramid that places unit tests at the wide base, integration tests in the middle, and end-to-end (E2E) tests at the narrow top. The shape is intentional:
- Unit tests are cheap to write, fast to run, and pinpoint exactly which unit broke. Because they are fast you can afford hundreds or thousands of them.
- Integration tests combine a few units and verify they work together, often using real databases, real HTTP calls to a local server, or fakes. They are slower and fewer.
- End-to-end tests drive the whole system from the outside — for example, opening a real browser, logging in as a test user, and clicking through a checkout flow. They are powerful but slow, flaky, and expensive to maintain, so you write fewer of them.
The pyramid is a guideline rather than a law. Different codebases use different shapes — testing diamonds, ice cones, trophies — but the core idea is the same: lots of small fast tests, a moderate number of medium tests, and a small set of high-value end-to-end tests. Unit tests form the foundation because they are the cheapest way to catch the largest class of bugs.
Test-driven development
Test-driven development (TDD) is a workflow in which unit tests drive the design of the code, not the other way around. Wikipedia describes the cycle as:
- Write a failing unit test for the next small piece of behavior you want.
- Write the simplest production code that makes the test pass.
- Refactor the code and the test together, removing duplication and improving names.
- Repeat.
The benefit is not that every test is written first, but that every line of production code exists because a test demanded it. This makes the test suite a thorough specification of what the code does, and it forces the author to think about inputs, outputs, and edge cases before settling on an implementation. TDD is one common way to produce a healthy unit test suite, but it is not the only one — many teams write tests after the code and still keep them passing.
What makes a good unit test
The web.dev article on what to test summarises the qualities of a good unit test:
- It tests one thing, so a failure tells you exactly what regressed.
- It is fast, so the whole suite can run on every save and in CI.
- It is deterministic — given the same inputs and the same code, it always produces the same result. No reliance on time, randomness, network, or shared state between tests.
- It is independent of other tests; the order in which tests run must not change the outcome.
- It uses clear, descriptive names so a failure in the test report reads almost like a sentence: "Cart.applyDiscount — zero percent discount leaves total unchanged."
What a good unit test does not do is assert against implementation details. A test should pin down behavior, not internal structure — so it survives a refactor that reorganises the code without changing what the code does. A test that breaks every time a private variable is renamed is coupling the test to the implementation, not the behavior.
Where unit testing fits with the rest of QA
Unit testing is one layer of a healthy quality strategy, not a replacement for the others. Static analysis tools catch a different class of problems (style, type errors, unused code) without running the program. Integration tests catch mismatches between components. End-to-end tests catch the rare cross-cutting failures that only show up when the whole stack is wired up. Manual exploratory testing catches the issues that no scripted check can anticipate.
The practical recommendation from web.dev is to start with unit tests, because they give the most signal for the least cost, then add integration and E2E tests as the system grows. The shape of any project's test suite should reflect the risks of that project: a payments API will skew heavily toward integration tests, a UI-heavy product will need more visual and end-to-end coverage, and a library will live almost entirely on its unit tests.
FAQ
What is the difference between a unit test and an integration test?
A unit test exercises a single function, method, or class in isolation, with any external collaborators replaced by test doubles. An integration test exercises several units together and lets them talk to each other — and often to real or realistic infrastructure such as a test database — to confirm that the combined behavior is correct. Unit tests are cheaper and faster; integration tests catch the bugs that only appear when the pieces meet.
Do I need a separate testing framework, or can I write my own assertions?
You can write your own — a unit test is just code that calls other code and compares the result to an expected value. In practice everyone uses a framework because it gives you, for free, a runner that discovers and executes your tests, a reporter that shows what passed and failed, a way to group related cases, and helpers for common assertions. Common choices are Jest and Vitest for JavaScript, pytest for Python, JUnit for Java, and Go's built-in testing package.
What is code coverage and how much should I aim for?
Code coverage is the percentage of lines, branches, or statements in your code that are exercised by your test suite. It is a useful way to spot code that has no test at all, but high coverage does not guarantee good tests. The web.dev article on testing strategy recommends aiming for meaningful coverage of the code that actually matters — algorithms, business rules, error paths — rather than a number like 100%, which can be reached with shallow assertions that would still pass on broken code.
When should I not write a unit test?
Skip unit tests for code that is trivial, that you will throw away, that exists only to call a well-tested third-party API, or whose behavior is so tied to its environment (a complex UI animation, a hardware interaction) that a unit test would be a mock-heavy fiction with little value. For those cases integration or end-to-end tests are a better fit, and some exploratory work is best left to manual testing.
Summary
Unit testing is the practice of writing automated tests that exercise the smallest pieces of your code in isolation and report pass or fail. Good unit tests are fast, deterministic, named after the behavior they pin down, and structured around arrange-act-assert. They sit at the base of the testing pyramid, supported by integration and end-to-end tests, and they are the cheapest way to catch most regressions before they reach users. Start small, write tests for the code that matters, and let the test suite be the executable specification of what your program is meant to do.
Sources
- Wikipedia — Unit testing (en.wikipedia.org/wiki/Unit_testing
- CC BY-SA 4.0)
- web.dev — Learn Testing: What testing is (web.dev/learn/testing/get-started/what-testing-is
- CC BY 4.0 content / Apache 2.0 code)
- web.dev — Learn Testing: Types of automated testing (web.dev/learn/testing/get-started/test-types
- CC BY 4.0 content / Apache 2.0 code)
- web.dev — Three common types of test automation (web.dev/articles/ta-types
- CC BY 4.0)
Share this guide
Related guides
- What is a Regular Expression (regex)? A practical guide
A regular expression (regex or regexp) is a sequence of characters that forms a search pattern for matching and manipulating text. Built on a formal mathematical foundation and common to almost every programming language and tool, regex lets you validate input, find and replace text, extract data, and transform strings with a compact, declarative syntax. This guide explains what regex is, its core syntax, how it is implemented, and how to use it safely.
- What is Object-Oriented Programming (OOP)? A complete guide
Object-oriented programming is a paradigm based on objects that combine data and behavior. Learn the four pillars — encapsulation, inheritance, polymorphism, and abstraction — with practical examples and how they work in JavaScript, Java, and Python.
- What is WebAssembly (WASM)? How it works and when to use it
WebAssembly is a binary instruction format for a stack-based virtual machine — a portable compilation target that lets you run C, C++, Rust, and other compiled languages in the browser and beyond. Here's how it works and when it's worth using.