Unit Testing vs Integration Testing: Differences, Examples and When to Use Each
Unit tests check one piece of logic alone. Integration tests check that real pieces work together. See both on the same small app, with tests we ran, and learn how much of each you need.
- A unit test checks one function or class in isolation, replacing dependencies with fakes.
- An integration test checks that real components, such as your code and a database, work together.
- Unit tests find logic bugs fast. Integration tests find bugs at the seams that mocks hide.
- Write many unit tests, fewer integration tests and only a few end-to-end tests.
Teams argue about unit testing vs integration testing as if they had to choose one. They do not. The two answer different questions, and a healthy test suite needs both: “does my logic work?” and “do the parts work together?”
In this guide we test one small order feature at both levels, using only Python’s standard library, so you can run everything yourself. We ran it on Python 3.13, and all four tests pass.
The short answer
A unit test takes the smallest testable piece of code, usually a function or a method, and checks it on its own. Anything outside that piece, such as a database or a network call, gets replaced with a fake. So these tests are fast and pinpoint the exact line that broke.
An integration test connects two or more real parts and checks how they behave together. It might run your code against a real database, or call one of your services through its actual API. It is slower, but it tests something a unit test cannot: whether the assumptions each part makes about the others are true.
Unit testing vs integration testing side by side
| Aspect | Unit test | Integration test |
|---|---|---|
| Scope | One function or class | Two or more real components |
| Dependencies | Replaced with mocks or fakes | Real, or close to real |
| Speed | Milliseconds | Milliseconds to minutes |
| Finds | Logic errors and edge cases | Wrong queries, contracts, configuration |
| When it fails | Points to one small area | Takes more investigation |
| Usually written by | Developers | Developers and QA engineers |
| Runs | On every save and commit | On every pull request |
One small app to test
Our feature has three parts: a pure discount calculation, a repository that stores orders in a database, and a function that connects the two.
def discounted(price, percent):
if not 0 <= percent <= 100:
raise ValueError("percent must be 0-100")
return round(price * (1 - percent / 100), 2)
class OrderRepo:
def __init__(self, conn):
self.conn = conn
conn.execute("create table if not exists "
"orders(id integer primary key, total real)")
def save(self, total):
sql = "insert into orders(total) values (?)"
return self.conn.execute(sql, (total,)).lastrowid
def total_of(self, order_id):
sql = "select total from orders where id=?"
return self.conn.execute(sql, (order_id,)).fetchone()[0]
def place_order(repo, price, percent):
return repo.save(discounted(price, percent))
Notice that place_order receives the repository as an argument. This small design choice, called dependency injection, is what makes it easy to swap in a fake during unit tests.
A unit test
The first two tests check pure logic. The third checks place_order with a Mock instead of a real repository, so no database is involved.
import unittest
from unittest.mock import Mock
class UnitTests(unittest.TestCase):
def test_discount(self):
self.assertEqual(discounted(200, 10), 180.0)
def test_bad_percent(self):
with self.assertRaises(ValueError):
discounted(200, 150)
def test_place_order_saves_total(self):
repo = Mock()
repo.save.return_value = 7
self.assertEqual(place_order(repo, 200, 10), 7)
repo.save.assert_called_once_with(180.0)
The mock lets us ask a precise question: did place_order call save with the discounted total? It says nothing about whether save really works. That is a limit of the test, and it is intentional. Picking good input values matters too, and boundary value analysis tells you exactly which percentages to try, such as 0, 100 and 101.
An integration test
This test uses a real, in-memory SQLite database, so the actual SQL runs.
import sqlite3
class IntegrationTests(unittest.TestCase):
def test_order_is_stored(self):
repo = OrderRepo(sqlite3.connect(":memory:"))
order_id = place_order(repo, 200, 10)
self.assertEqual(repo.total_of(order_id), 180.0)
if __name__ == "__main__":
unittest.main(verbosity=2)
Here is the output of the whole file from our run:
test_order_is_stored (__main__.IntegrationTests.test_order_is_stored) ... ok
test_bad_percent (__main__.UnitTests.test_bad_percent) ... ok
test_discount (__main__.UnitTests.test_discount) ... ok
test_place_order_saves_total (__main__.UnitTests.test_place_order_saves_total) ... ok
----------------------------------------------------------------------
Ran 4 tests in 0.009s
OK
Tip: SQLite in memory is great for learning, but test against the same database engine you use in production when you can. Engines differ in types and SQL behaviour, and a container makes that easy. See our Docker Compose tutorial.
Why you need both
Imagine someone renames the column total to amount in the create table statement but forgets the insert query. All three unit tests still pass, because none of them touches SQL. The integration test fails immediately.
Now imagine a bug in the discount formula that appears only at exactly 100 percent. An integration test could catch it, but you would need a database round trip for every edge case. A unit test can check dozens of values in milliseconds.
That is the whole argument. Unit tests give you cheap depth on logic, and integration tests give you confidence at the seams. Most production bugs that survive a strong unit suite live in those seams: database schemas, API formats, permissions, time zones and configuration. When a service talks to another over HTTP, knowing the HTTP status codes helps you write better checks.
The test pyramid
The test pyramid is a simple guide to how many tests to write at each level.
| Level | Quantity | Example |
|---|---|---|
| End-to-end | A few | A browser completes a real checkout |
| Integration | Some | Order service writes to a real database |
| Unit | Many | Discount calculation returns the right value |
The higher the level, the more realistic the test, but also the slower and more fragile. Do not treat the shape as a strict rule, though. A thin service that mostly reads and writes a database has little logic to unit test, so it may reasonably have more integration tests than unit tests. For the top of the pyramid, read our end-to-end testing best practices.
Running both in CI
Order your pipeline by speed, so developers get the fastest feedback first.
- Lint and unit tests on every push. Keep them under a couple of minutes.
- Integration tests on every pull request, with services such as a database started as containers.
- End-to-end tests before merging or before release, covering only the critical journeys.
If a stage fails, the later ones do not run, which saves time and money. New to pipelines? Start with what is CI/CD and then the GitHub Actions CI/CD guide.
Mocks, stubs and fakes
People often call every replacement a “mock”, but the differences help you choose the right one. Together, they are known as test doubles.
| Double | What it does | Example |
|---|---|---|
| Stub | Returns a fixed answer | An exchange rate service that always returns 83 |
| Mock | Records calls so you can check them | Our repo.save.assert_called_once_with(180.0) |
| Fake | A simple working version | An in-memory list used as a repository |
Prefer stubs and fakes where you can, because they check results rather than the exact sequence of calls. Heavy use of mocks ties a test to how the code is written, so a harmless refactor can break it. Use a mock when the call itself is the behaviour you care about, such as confirming that an email was sent.
Which test should you write?
When you are adding a test, these three questions usually settle it:
- Is it pure logic, with inputs and outputs only? Write a unit test.
- Could it break because of something outside your code, such as SQL, a schema or a service’s response format? Write an integration test.
- Would a customer lose money or get blocked if a whole journey failed? Add one end-to-end test as well.
When a bug reaches production, write the test at the lowest level that would have caught it. That keeps your suite fast and stops the same bug returning.
Common mistakes
- Mocking everything. If every dependency is fake, tests can stay green while the real system is broken.
- Calling slow tests “unit tests”. A test that needs a running database is an integration test, so keep the suites separate.
- Testing implementation details. Tests that check private steps break on every refactor. Test behaviour instead.
- Shared test data. Integration tests that depend on each other’s rows fail randomly. Give each test its own data.
- Only chasing coverage. A high percentage does not prove the parts work together.
Final thoughts
Use unit tests for logic and edge cases, and integration tests for everything that crosses a boundary. Start by adding one integration test around your most important database or API call, because that single test often catches what hundreds of mocks cannot.
Frequently asked questions
What is the main difference between unit and integration testing?
Unit testing checks one piece of code in isolation with dependencies faked. Integration testing checks that real components work correctly together.
Which should be done first?
Unit tests usually come first because they are written alongside the code and run fastest. Integration tests follow once the parts can be connected.
Do unit tests use a database?
Normally no. A database is replaced by a mock or fake. A test that talks to a real database is generally treated as an integration test.
Can integration tests replace unit tests?
Not well. They are slower and harder to diagnose, and covering every edge case through them is expensive. Use each for what it does best.
How many integration tests should I write?
Cover the important paths across each boundary, such as every key query and external call. Leave edge cases of pure logic to unit tests.
Keep learning
Need help with testing, WordPress or SEO?
We help teams ship better software and grow their sites. Tell us what you are working on and we will suggest a clear next step.