Software unit testing is the practice of checking the smallest testable pieces of a program, usually a single function or class, in isolation from everything else. A unit test is a short piece of code that calls one of those pieces with a known input and fails if the result is not what was expected. Unit tests run in milliseconds, need no database or network, and are written by the developers who wrote the code. Their job is to tell you quickly that a piece of logic still does what it did yesterday.
What is unit testing? The definition of a unit test
The plain definition: a unit test is an automated check of one small unit of behaviour, run in isolation, with a pass or fail result that needs no human to interpret it.
Three words in that sentence carry the weight.
- Automated. A person clicking through a screen is testing, though it is not a unit test. A unit test is code that runs the same way every time, on any machine.
- Small. The unit is whatever the team treats as the smallest meaningful piece: often a function, sometimes a class or a few functions that only make sense together. There is no official size. When a test fails, the unit should be small enough that you know where to look.
- Isolated. The test does not depend on a real database, a network call, the file system, the current time or the order in which other tests ran. Anything like that is either replaced with a stand-in or tested at a different level.
If you are commissioning software and not writing it, the short version is this: unit tests are the cheapest safety net a development team has, and their presence or absence tells you a lot about how the code was built.
A small software unit testing example
Here is a function that applies a percentage discount to a price held in pence. It is written in TypeScript, though the idea is the same in any language.
// discount.ts
export function applyDiscount(pricePence: number, percent: number): number {
if (percent < 0 || percent > 100) {
throw new Error('percent must be between 0 and 100')
}
return Math.round((pricePence * (100 - percent)) / 100)
}
And here are unit tests for it, in the style used by test runners such as Jest and Vitest:
// discount.test.ts
import { applyDiscount } from './discount'
test('takes 20 percent off a price', () => {
expect(applyDiscount(1000, 20)).toBe(800)
})
test('rounds to the nearest penny', () => {
expect(applyDiscount(999, 15)).toBe(849)
})
test('leaves the price unchanged at 0 percent', () => {
expect(applyDiscount(1000, 0)).toBe(1000)
})
test('rejects a discount above 100 percent', () => {
expect(() => applyDiscount(1000, 150)).toThrow()
})
Each test has a name that reads as a statement about behaviour, calls the function, and checks one outcome. Nothing here touches a database or a browser, and the whole file runs in well under a second.
Now imagine someone later tidies the function and removes the rounding. The second test goes red before the change leaves the developer's machine. Without it, the first sign would be an invoice that is a fraction of a penny out, found by a customer or an accountant.
What a good unit test looks like
Writing a test that passes is easy. Writing one that earns its keep takes more care. These are the properties I look for.
- It is fast. Slow tests get run less often, and tests that are not run protect nothing.
- It is deterministic. Same code, same result, every time. A test that fails now and then for no reason teaches the team to ignore failures.
- It tests behaviour. The test should check what the unit does, through its public interface, and stay silent about how. Tests tied to internal details break every time the code is tidied, even when nothing is wrong.
- It has one reason to fail. When it goes red, the name of the test should tell you what broke.
- It covers the edges. Zero, empty, negative, the maximum, the invalid input. Bugs gather at boundaries, and the ordinary case is rarely where the trouble is.
- It reads clearly. Most follow a three-step shape known as arrange, act, assert: set up the input, call the unit, check the result. A new team member should understand the test without opening the code it exercises.
- It can fail. A test that passes whatever the code does is decoration. A useful habit is to break the code on purpose once and confirm the test notices.
A word on stand-ins, usually called mocks or stubs. They replace a slow or unpredictable dependency, such as a payment provider, with a fake that returns a fixed answer. They are useful and easy to overdo. A test that mocks everything around a unit ends up checking that the mocks behave as the test author assumed, which says little about the real system.
Unit test vs integration test vs end-to-end test
Unit tests are one of three levels most teams use. The difference is how much of the real system each test exercises.
| Level | What it checks | Speed | What a failure tells you |
|---|---|---|---|
| Unit test | One function or class, in isolation | Milliseconds | Exactly which piece of logic is wrong |
| Integration test | Several real parts working together, such as application code with a database, or an API endpoint | Slower, often seconds | That two parts disagree, though you may have to dig to find which |
| End-to-end test | A whole user journey through the running application, often driven through a browser | Slowest, seconds to minutes each | That something a customer would notice is broken, with little indication where |
The usual advice is to arrange these as a pyramid: many unit tests at the base, fewer integration tests in the middle, and a small number of end-to-end tests at the top covering the journeys that matter most. The reasoning is economic. The higher the level, the slower, more fragile and more expensive each test is to maintain, so you push each check down to the lowest level that can catch that kind of bug.
The pyramid is a guide. An application that is mostly glue between a database and a few external services may get more value from integration tests than from unit tests of thin functions. What matters is that the mix is a deliberate choice.
Working through this on a real project?
Tell us what you are building or fixing. A senior engineer will give you a straight view of it, including when the honest answer is that you do not need us.
What unit tests cannot catch
A green unit test suite means each piece does what its author expected. It leaves several kinds of failure untouched.
- Pieces that do not fit together. Two functions can each pass their tests and still disagree about whether a price is in pounds or pence. Only an integration test sees the join.
- The wrong requirement. If the developer misunderstood the rule, the test encodes the same misunderstanding and passes happily.
- Configuration and infrastructure. A missing environment variable, a database migration that was never run or an expired certificate will take a system down with every unit test passing.
- Performance. A function can return the right answer and still be far too slow at real data volumes.
- Usability and appearance. No unit test will tell you a form is confusing or a button is hidden on a small screen.
- Security weaknesses. Access control mistakes and injection flaws mostly live in how parts are combined and exposed.
- Behaviour that is not deterministic. Timing problems between concurrent operations are hard to pin down in a unit test. Systems built on language models are harder still, which is why there is a separate guide on evaluating an LLM system you cannot see inside.
Exploratory testing by a person, integration and end-to-end automation, and specialist performance and security work cover the rest.
Why coverage numbers are a misleading target
Code coverage is the percentage of lines, or branches, that were executed while the tests ran. It is easy to measure, easy to put in a contract, and widely misread.
Coverage records that a line ran. It does not record that anything checked the result. Consider this:
test('applies a discount', () => {
applyDiscount(1000, 20)
})
That test executes most of the function and asserts nothing. It would still pass if the function returned the wrong number. It raises the coverage figure and protects nobody.
When a percentage becomes a target, people meet the target. Teams told to reach a set figure write tests for trivial code because it is quick to cover, and leave the tangled, risky logic thinly tested because it is slow.
Coverage is more useful read the other way round. A low figure on an important module is a real warning: that code is not being exercised at all. A high figure proves much less. I would treat coverage as a tool for finding untested areas, and judge the suite by a different question: if this critical rule broke tomorrow, would a test fail?
How unit tests fit into CI
Unit tests are worth most when they run automatically, on every change, without anyone having to remember. That is the job of continuous integration, the first half of CI/CD.
A typical pipeline works like this:
- A developer pushes a change or opens a pull request.
- A build server checks out the code and runs the quick checks first: formatting, static analysis and type checking.
- The unit tests run. Because they are fast, the developer has an answer within minutes.
- The slower integration and end-to-end tests run next, or on a schedule if they take too long to run on every change.
- If anything fails, the change cannot be merged until it is fixed.
Two rules keep this working. A failing test has to block the merge, because a red build that people are allowed to ignore soon becomes permanent. And a test that fails at random has to be fixed or removed quickly, because it destroys trust in every other result.
The same suite is what makes later change safe. Code without tests becomes code nobody dares to touch, which is one of the main ways technical debt builds up.
What a buyer should ask about unit tests
You do not need to read code to find out whether a team takes testing seriously. Four questions will do.
- Do tests run automatically on every change? Ask to see the pipeline. A screen of recent runs is hard to fake.
- What happens when a test fails? The answer you want is that the change cannot be merged.
- Which parts of the system are tested most heavily, and why? A thoughtful answer names the areas that handle money, permissions or data that must stay correct.
- Are the tests part of what I am paying for and what I will own? Tests are part of the software. They should be in your repository at handover.
Be wary of a quote that drops tests to reach a lower price. The saving is real for the first release and tends to be paid back, with interest, on the changes that follow. A codebase with no tests at all is also one of the first things I would check for in technical due diligence.
If you want tests added to a product that never had them, or a suite that has stopped being trusted put right, that is the work our QA and software testing and test automation services cover.
Unit testing: frequently asked questions
What is unit testing in simple terms?
It is checking the smallest parts of a program one at a time, automatically. Each unit test gives one piece of code a known input and confirms it produces the expected output. If someone later changes that code and breaks it, the test fails and says so straight away.
What is the difference between a unit test and an integration test?
A unit test checks one piece of logic in isolation, with outside dependencies replaced by stand-ins. An integration test checks that several real pieces work together, for example that application code can save a record to an actual database and read it back. In the unit test vs integration test trade-off, unit tests are faster and pinpoint faults, while integration tests are slower and catch the faults that only appear at the joins. You need both.
Who writes unit tests?
The developers who write the code, at the same time as the code. Unit tests need knowledge of the internals and are part of finishing a piece of work. Separate QA engineers usually concentrate on the levels above: integration, end-to-end and exploratory testing.
How much unit test coverage is enough?
There is no figure that is right for every project. Aim for thorough tests over the logic where a mistake would be costly, lighter testing over simple code, and use the coverage report to spot important areas that are not tested at all. A team that can explain what it chose not to test, and why, is in better shape than one quoting a high percentage.
Is unit testing the same as test-driven development?
No. Unit testing is the practice of having these tests. Test-driven development, or TDD, is one way of producing them: write a failing test first, write just enough code to make it pass, then tidy up. You can have good unit tests without practising TDD.
