LS LOGICIEL SOLUTIONS
Toggle navigation

What Is Unit Testing?

Definition

Unit testing is the practice of testing the smallest testable pieces of a program, usually a single function, method, or class, in isolation from the rest of the system. Each test sets up specific inputs, runs the piece of code under test, and checks that the output or behavior matches what's expected. Everything that piece of code depends on, a database, a network call, another service, gets replaced with a mock or stub, so the test only exercises the logic inside that one unit. A codebase of any real size ends up with hundreds or thousands of these small, focused tests, each checking a narrow slice of behavior on its own.

Unit testing exists because logic bugs are cheapest to find and fix the moment they're introduced, and unit tests are the fastest way to get that feedback. Without them, a mistake in a pricing calculation or a validation rule might not surface until a manual tester, or worse a customer, runs into it days or weeks later. By then, tracing it back to the exact line of code that caused it takes real investigative work. Unit tests catch that class of bug in milliseconds, right where it was introduced, while the change is still fresh in the developer's mind and cheap to fix.

The mechanism is isolation. A unit test doesn't care whether the database is up, whether a third-party API is responding, or whether the network is fast. It replaces every external dependency with a controlled substitute, a mock that returns exactly the value the test tells it to return, so the only variable left is the logic being tested. That's what makes unit tests fast, since no real I/O happens, and precise, since a failure points directly at the function under test because everything around it was fixed and known. Fast and precise together are what make them cheap enough to run constantly without anyone thinking twice about the cost.

By 2026, unit testing is a baseline professional practice, not an optional extra, across nearly every serious engineering organization. Test-driven development, where the test is written before the code, remains one path in, but plenty of teams write tests immediately after the code instead, and it works just as well as long as the discipline holds. AI coding assistants have changed the economics here too. Generating a first draft of unit tests for a new function is a matter of minutes now, which removes one of the most common excuses for skipping them and puts the emphasis back on reviewing test quality instead of authoring every test by hand. That shift has raised expectations rather than lowered the bar. A team with no reasonable excuse left for missing tests gets judged more harshly for shipping code without them than a team was five years ago.

This page covers what counts as a good unit test, how mocking and isolation actually work, where unit testing runs into its limits, and how to build the habit into a team's daily practice instead of treating it as a chore bolted on at the end. Isolating a piece of logic and testing it directly is the cheapest, fastest, most precise way to know whether that logic is correct. That's the whole idea underneath everything else on this page. A team that gets this catches most bugs before they ever leave a developer's machine, and it gets a concrete, teachable answer to what "well-tested code" actually looks like in practice, instead of a vague aspiration nobody can quite define.

Key Takeaways

  • A unit test isolates a single function, method, or class from its dependencies, using mocks or stubs to replace anything external to it.
  • Unit tests are the fastest and cheapest layer of a test suite, which is why they should carry the bulk of a team's overall test coverage.
  • A unit test failure points directly at the code under test, making diagnosis fast compared to failures in integration or end to end tests further up the pyramid.
  • Unit tests cannot verify that components work together correctly in practice; that's the job of integration and end to end tests further up the pyramid.
  • Writing testable code, with dependencies injected rather than hardcoded, is a prerequisite for effective unit testing, not an afterthought bolted on at the end.

What Makes a Test a "Unit" Test

The defining feature of a unit test is isolation, not size. A unit could be a single pure function doing one calculation, or a class with several methods that work together, as long as everything that class depends on outside itself is replaced with a test double. The test exercises real code from the unit under test and fake, controlled substitutes for everything else. Size is almost a distraction here. What matters is that the boundary between what's real and what's faked is drawn deliberately, right at the edge of external dependencies.

This isolation is what separates a unit test from an integration test covering the same functionality. A unit test for an order total calculation might mock out the tax rate lookup entirely, hardcoding a specific tax rate and checking that the calculation logic combines it with the order items correctly. An integration test covering similar ground might use a real tax rate service and check that the whole chain, lookup plus calculation, produces the right number. Both are valid. They answer different questions: the unit test asks whether the calculation logic is correct given known inputs, and the integration test asks whether the calculation works correctly with its real dependency. A team that only writes one of the two is missing half the picture, no matter how well written each individual test is.

Good unit tests are also deterministic and independent of each other. The same test, run a thousand times with the same code, should produce the same result every time. Nothing about the current time, network conditions, or the order tests run in should change the outcome. Tests that fail intermittently for reasons unrelated to the code under test are usually a sign that isolation wasn't done correctly: a global variable, a shared file, or a hidden dependency crept in where a mock should have been. Even something as small as relying on the system clock without controlling it in the test can introduce this kind of hidden nondeterminism, and it's worth hunting down the moment it shows up rather than tolerating an occasional red result.

Naming and structure matter more than they might seem. A test named `test_calculates_total_with_discount` communicates its intent immediately, which matters when a test fails months later and someone needs to understand what broke without re-reading the whole test body. Tests structured around "arrange, act, assert," setting up inputs, running the code, then checking the result, keep this readable and consistent across a codebase, which pays off enormously once the number of tests grows into the thousands. A test suite that reads clearly is also a form of documentation. A new engineer can often learn what a function is supposed to do faster by reading its tests than by reading the implementation itself, and I've seen that shortcut save a new hire an entire afternoon of tracing through code.

Mocks, Stubs, and the Mechanics of Isolation

Mocking is the technique that makes unit testing possible for code that depends on anything external. A mock is a fake version of a dependency, an object or function standing in for the real database call, API request, or file read, that returns a value the test controls directly. Because the mock's behavior is fixed and known, the test can focus entirely on how the code under test reacts to specific inputs and specific responses from that dependency, without the noise a real dependency would introduce, like network latency or an unrelated outage three time zones away.

There's a real distinction between mocks, stubs, and fakes, even though the terms get used loosely in practice. A stub simply returns a canned value when called, with no expectations about how it's used. A mock goes further and can verify it was called in a specific way, with specific arguments, a specific number of times, which matters when the behavior being tested is about the interaction itself, like confirming an email service was called exactly once after a signup. A fake is a lightweight but real working implementation, an in-memory database standing in for a real one, useful when the interaction is complex enough that a simple stub doesn't capture it well. Picking the right one is a matter of matching the tool to the question being asked, not reaching for the same technique out of habit every time.

Overusing mocks is a well-known trap. A test that mocks so much of its own dependencies that it's effectively testing the mocks' behavior instead of the real code provides false confidence. It passes even when the actual integration with the real dependency would fail, because the mock was configured to behave exactly the way the test author assumed the real thing would behave. This is one reason unit tests, however well written, can't replace integration and end to end tests: a unit test can only be as accurate as its mocks are honest about the real world, and a mock that quietly drifts out of sync with how the real dependency actually behaves can hide a bug for months without anyone noticing.

Dependency injection is the design pattern that makes clean mocking possible in the first place. Code that reaches out and creates its own database connection or API client internally is hard to test, because there's no seam to insert a mock. Code that receives its dependencies as parameters, rather than constructing them internally, can have those dependencies swapped for test doubles trivially. Testability is often described as a design quality, not just a testing concern, for exactly this reason. Code written with injection in mind is usually easier to read, reuse, and reason about too, independent of testing, because the dependencies a function needs are stated explicitly instead of buried inside its implementation.

What Unit Testing Can't Tell You

A comprehensive unit test suite, even one with high coverage numbers, says nothing about whether the pieces of a system actually work together. A function that correctly calculates a discount and a function that correctly applies it to an order total can both pass every unit test and still produce the wrong final price if the two are wired together incorrectly, because that wiring is exactly what unit tests don't check by design. This gap is invisible from inside the unit test suite itself. Everything looks green right up until a real customer actually hits the broken seam in production, usually on a Friday afternoon.

Unit tests also can't catch problems that only exist in a real environment: a database index that's missing and makes a query catastrophically slow under real data volumes, a third-party API behaving slightly differently than its documentation suggests, a race condition that only appears under real concurrent load. These are exactly the class of problems integration and end to end tests exist to catch, and no amount of unit test discipline substitutes for them. A team that leans entirely on unit tests for confidence is, in effect, choosing not to look for an entire category of real bugs.

Coverage percentage, a metric most teams track, is also a weaker signal than it looks. A test can execute every line of a function without actually asserting anything meaningful about its behavior, technically counting as "covered" while providing almost no real confidence. High coverage with weak assertions is a common way teams end up with a false sense of security, believing their code is well tested when the tests themselves aren't checking much at all. A test that calls a function and checks only that it didn't throw an exception will happily pass even if the function returns completely wrong data, and that test still counts toward the coverage number.

None of this is an argument against unit testing. It's a reminder that unit tests are one layer of a larger strategy, not a complete one on their own. A team with excellent unit test coverage and nothing else is still exposed to a real category of production risk, the same risk the test pyramid manages by layering integration and end to end tests on top. The right response to this limitation isn't abandoning unit testing. It's being honest about which layer of the pyramid is actually providing which kind of confidence, and not pretending one layer can do another's job.

I've reviewed test suites that hit ninety-five percent coverage and still shipped a broken checkout flow the same week, because every mock in the suite quietly assumed the payment API would always respond in under a second. It didn't, under real load, and nothing in that unit suite was ever going to catch it. That's not a knock on the engineers who wrote those tests. It's just what unit tests are built to do and not built to do, and no amount of additional unit tests fixes a gap that only a different layer can close.

Where Unit Testing Fits and Where It Doesn't

Unit testing earns its keep on business logic: pricing calculations, validation rules, state transitions, anything where a function takes inputs and needs to produce a specific, checkable output. This is where the return on investment is highest, because the logic is exactly what a unit test isolates well, and because bugs in this kind of code are both common and expensive if they reach production undetected. A pricing bug that silently undercharges every customer for a week is exactly the kind of thing a handful of well-placed unit tests would have caught before the code ever merged, and it's the kind of incident that ends up in a postmortem with someone's name attached.

It's a poor fit, or at least an awkward one, for code that's mostly plumbing: a thin function that calls a database library and returns the result, with no real logic of its own. Unit testing that kind of code often means mocking the database call so thoroughly that the test just confirms the mock was called, which provides very little real confidence and mostly adds maintenance burden. That kind of code is usually better covered by an integration test exercising the real database, since the actual risk there is about how the code interacts with a real system, not about internal logic.

It also has diminishing returns on code that changes shape constantly during early exploration, like a rapidly evolving prototype where the interface itself is still being figured out. Writing thorough unit tests against an interface likely to be thrown away within days can slow down useful exploration without adding much value. That changes fast once the code stabilizes and starts carrying real business risk. The moment a prototype turns into something customers actually rely on, the calculus flips, and skipping tests starts costing more than it saves.

The judgment call teams have to make repeatedly is distinguishing logic worth testing directly from plumbing better covered elsewhere. A rough heuristic: if a function has branches, conditions, or calculations that could plausibly be wrong, it deserves a unit test. If a function's only job is to pass data through to something else, testing it in isolation adds less value than making sure the something else is tested properly. This heuristic isn't perfect, but it gives a team a fast, consistent way to decide without relitigating the question on every pull request.

Building Unit Testing Into Daily Practice

The strongest lever a team has is making unit tests part of the definition of done for any change, not a separate task assigned after the fact. Code review that treats "where's the test for this" as a standard question, asked as consistently as questions about naming or structure, keeps the habit from eroding under deadline pressure, which is exactly when testing discipline tends to slip first. Teams that let this slide "just this once" during a crunch usually find that once becomes the norm within a couple of sprints, and then it takes a deliberate push to reverse.

Writing testable code has to be treated as a design skill worth teaching directly, not something engineers are expected to absorb by osmosis. Concepts like dependency injection, pure functions, and separating logic from I/O aren't unit testing techniques exactly. They're code design techniques that happen to make unit testing dramatically easier. Teams that invest in this as an explicit skill, through code review feedback and internal guidelines, end up with codebases where writing a good test is the natural next step rather than a fight against the existing structure. New hires benefit especially from this kind of explicit teaching, since testability habits that feel obvious to a senior engineer are rarely obvious to someone new to the codebase.

Fast feedback only pays off if people actually get it constantly, which means the unit test suite has to stay fast as the codebase grows. A suite that takes thirty seconds today and creeps up to five minutes over a year will quietly get run less often, undermining the entire point. Watching suite run time as a real metric, and refactoring or parallelizing when it climbs, keeps the fast feedback loop the pyramid depends on intact. Some teams set an explicit budget, a maximum acceptable run time for the full unit suite, and treat breaching it as seriously as they'd treat a failing test.

AI-assisted test generation has changed the economics of getting started, but it hasn't changed the judgment required to keep tests meaningful. A generated test that only checks that a function runs without throwing an error isn't doing the actual job of a unit test, which is checking that specific inputs produce specific, correct outputs. Reviewing generated tests with the same scrutiny as generated code, rather than accepting them because they exist, keeps the suite's real value intact rather than just its line count. The temptation to treat a large generated test file as equivalent to real coverage is exactly the kind of shortcut that recreates the coverage-without-confidence problem described earlier, just faster.

Best Practices

  • Write unit tests as part of the same change that introduces the logic, not as a follow-up task that's easy to skip under deadline pressure.
  • Design code with dependency injection in mind so mocking is straightforward instead of a fight against a rigid, tightly coupled internal structure.
  • Focus unit test effort on logic with branches, conditions, and calculations, not on thin plumbing code better covered by integration tests further up the pyramid.
  • Watch for over-mocked tests that mostly verify a mock's configuration rather than the real behavior of the code under test itself.
  • Track unit test suite run time explicitly, since a suite that gets slow gets run less often, quietly undermining the whole point of having it.

Common Misconceptions

  • High code coverage is not the same as a well-tested codebase; a line can be executed without the test actually asserting anything meaningful about its behavior.
  • Unit tests are not a substitute for integration or end to end tests; they can't verify that components actually work together correctly in a real environment.
  • Test-driven development, writing tests first, is one valid path to good unit tests, not the only legitimate way to get there in practice.
  • Mocking everything a function touches doesn't automatically produce a good test; over-mocked tests can end up testing the mocks instead of the real underlying logic.
  • Unit testing is not primarily about catching bugs after the fact; its biggest value is the fast, tight feedback loop it gives a developer while writing the code.

Frequently Asked Questions (FAQ's)

What is unit testing?

Unit testing is the practice of testing a single function, method, or class in isolation from the rest of the system, using mocks or stubs to replace any external dependencies, so only the logic inside that unit gets checked, without the noise of a real database or network call.

What's the difference between a mock and a stub?

A stub simply returns a fixed value when called. A mock can also verify how it was called, including how many times and with what arguments, which matters when the behavior under test is about the interaction itself, not just the return value. A fake goes a step further still, acting as a lightweight but genuinely working substitute, an in-memory database standing in for a real one.

Do unit tests require test-driven development?

No. Test-driven development, writing the test before the code, is one way to arrive at good unit tests, but plenty of teams write tests immediately after the code and get equally reliable coverage as long as the discipline holds consistently. What matters more than the order is that the test actually gets written before the change is considered complete.

Why do unit tests run so much faster than integration tests?

They avoid real I/O entirely. No database connection, no network call, no file access, because every external dependency gets replaced with a fast, in-memory mock. That's what lets thousands of unit tests run in under a minute on a typical development machine.

Can unit tests catch every kind of bug?

No. They catch logic errors within the isolated unit but can't detect problems that only appear when components interact, like a database schema mismatch or a race condition under real concurrent load, which need integration or end to end tests instead. A codebase with only unit tests is still exposed to a real, well-documented category of production risk.

Is 100% code coverage a good goal for unit tests?

Not by itself. Coverage measures which lines executed during testing, not whether the assertions in those tests are meaningful, so a team can hit high coverage numbers while still having weak, low-value tests that don't actually check whether the code behaves correctly.

What does dependency injection have to do with unit testing?

Code that receives its dependencies as parameters, rather than creating them internally, can have those dependencies swapped for mocks easily, which is what makes isolated testing practical. Code without this pattern is much harder to unit test cleanly, often forcing awkward workarounds just to insert a fake dependency.

Should every function have a unit test?

Not necessarily. Functions with real logic, branches, or calculations benefit most from direct unit tests, while thin functions that just pass data through to another system are often better covered by an integration test on the real dependency instead.

How has AI changed unit testing practice?

AI coding assistants can generate a first draft of unit tests quickly, which removes time pressure as an excuse to skip testing, but a generated test still needs human review to confirm it's checking meaningful behavior rather than just running without errors. Treating a generated test suite as finished work without that review risks recreating the coverage-without-confidence problem in a new, faster-to-produce form.