Evaluating CodeRabbit? Same review, plus real test runs. See why

Automation Testing12 min read

Unit Testing vs Integration Testing: The Difference

S
Technical Writer, Qodex
The words unit vs integration testing, one function tested at both levels

Unit testing checks one unit of behavior in isolation, usually replacing collaborators with mocks or fakes. Integration testing checks whether real components work together, such as application code and a database. Use unit tests for fast feedback and edge cases. Add integration tests wherever correctness depends on interfaces, configuration, persistence, networks, or third-party services. Most codebases need both, not one or the other.

Qodex adds the layer above both: API and UI scenarios run against your app on every pull request. See Qodex API testing.

Unit testing vs integration testing at a glance

Decision factorUnit testIntegration testChoose this when
ScopeOne unit of behavior: a function, method, or classAn interaction between two or more components or systemsThe thing you doubt is inside one unit, or between units
CollaboratorsReplaced with mocks, stubs, or fakes you controlReal, at least for the boundary the test exists to proveThe collaborator is the risk, or it is noise
EnvironmentIn-process, nothing outside the test runnerA real dependency, in memory, in a container, or deployedThe behavior depends on something the runtime provides
SetupConstruct the object and its doublesCreate schema, seed data, start or connect to the dependencyYou can afford the setup for the confidence it buys
Feedback speedLess setup and less work per test, so the suite stays quickMore setup, so each test costs more to runYou want the quicker answer, or you want the truer one
Failure diagnosisPoints at one unit, so the search area is smallPoints at a collaboration, so the cause can sit on either sideYou want a narrow blame area, or you want the real failure
Defects exposedBranching, arithmetic, parsing, state machines, edge casesSchema drift, contract mismatch, serialization, configuration, wiringThe bug class you fear lives inside logic, or between parts
DeterminismHigh: every input is under your controlLower: clocks, ports, network, and shared data can interfereYou need a stable gate, or you accept cost for realism
MaintenanceBreaks when the unit changes, including harmless refactorsBreaks when the contract or the dependency changesYou can keep doubles honest, or you would rather not try
CI positionEarly, on every push, as the first gateAfter the fast gate, still inside pull request feedbackYou are ordering stages by cost and by blast radius
Representative exampleTotal and tax computed from a quantity and a priceThe same total written to a database and read backYou are picking which of the two to write next

Read the table as one question repeated eleven times: is the thing you are unsure about inside a unit, or between units? That question, not a framework label, decides the level. A test that calls a function with a mock repository is a unit test. The same call against a real database is an integration test. Nothing about the test runner changed.

The scope column carries the most weight. Everything under it follows: scope sets which collaborators are real, real collaborators set the environment, the environment sets the setup cost, and setup cost sets the speed and the CI position.

The table leaves out numbers and ratios on purpose. There is no verified benchmark for how many milliseconds a unit test takes, how many integration tests a codebase should own, or what coverage either level should reach. Those depend on the code and its dependencies.

What unit and integration tests actually prove

ISTQB defines component testing as "A test level that focuses on individual hardware or software components". Developers call this unit testing, applied to a function, a method, a class, or a module. ISTQB defines integration testing as "A test level that focuses on interactions between components or systems". Both definitions classify by focus. Neither mentions mocks, speed, tools, or who writes the test.

That matters because the popular shorthands are wrong often enough to cause arguments. Unit tests are not defined as white box and integration tests are not defined as black box. Integration tests are not owned by a separate QA team by definition, and they do not wait until every component is finished. Those are team habits, not properties of the levels.

What a unit test proves is that one piece of behavior is correct given inputs you chose. If the unit talks to a repository, a clock, or an HTTP client, you hand it a double that answers the way you decided it should. The test then has one job: check the logic in between. When it fails, the defect is inside that unit or inside the assumptions you encoded in the double, which is why the failure is cheap to read.

What an integration test proves is narrower than the name suggests. It proves that a specific collaboration holds. Application code plus a real database proves the SQL, the schema, and the mapping agree. Two services over HTTP prove the contract and the serialization agree. It does not prove the whole product works, and it does not prove the parts it did not touch.

The gap between them is the reason you need both. A mock can agree with your assumptions while the real schema, protocol, configuration, or dependency version disagrees. Every unit test in the suite can pass against a column that no longer exists. Martin Fowler's advice on the labels is worth repeating: do not "get too hung up on sticking to ambiguous terms". Decide what you want proved, then choose the cheapest test that proves it.

The same function tested at both levels

Here is one function, tested twice. The production code and the entry point never change. Only the collaborator does: a mock repository in the first test, a real in-memory SQLite repository in the second. That is the whole difference between the two levels, in code.

The example runs on Python 3.10 or newer with pytest pinned to 9.1.1, the current release on PyPI as of 19 September 2026. In our run the interpreter was Python 3.14.7. pytest costs nothing to use and is MIT licensed. The database is Python's built-in sqlite3 module, which opens a database that exists only in memory when you connect to :memory:, with no server to install. Put the three files in one directory.

orders.py, the production code:

class SQLiteOrderRepo:
    def __init__(self, connection):
        self.connection = connection

    def insert(self, total_cents):
        cursor = self.connection.execute(
            "INSERT INTO orders (total_cents) VALUES (?)", (total_cents,)
        )
        self.connection.commit()
        return cursor.lastrowid

def place_order(repo, quantity, unit_price_cents, tax_rate_bps):
    subtotal = quantity * unit_price_cents
    tax_cents = (subtotal * tax_rate_bps + 5_000) // 10_000
    total_cents = subtotal + tax_cents
    return repo.insert(total_cents)

place_order holds the logic worth doubting. It multiplies, it applies a tax rate in basis points, and it rounds to the nearest cent by adding half of ten thousand before the integer division. Then it hands the total to whatever repository it was given. SQLiteOrderRepo contains only persistence logic: it writes a row, commits, and returns the new id.

test_orders_unit.py, the unit test:

from unittest.mock import Mock
from orders import place_order

def test_place_order_calculates_total_before_saving():
    repo = Mock()
    repo.insert.return_value = 41

    order_id = place_order(repo, quantity=3, unit_price_cents=1299, tax_rate_bps=875)

    assert order_id == 41
    repo.insert.assert_called_once_with(4238)

There is no database here, and no table. The repository is a mock that returns the id 41 when asked. The test asserts two things: the function returned what the repository gave it, and the repository was called exactly once with 4238 cents. Check that by hand: three items at 1299 cents is a subtotal of 3897 cents, 8.75 percent of that is 340.9875 cents, rounding to the nearest cent gives 341, and 3897 plus 341 is 4238. The fractional cent is deliberate, because it is what makes the rounding line worth testing.

test_orders_integration.py, the integration test:

import sqlite3
from orders import SQLiteOrderRepo, place_order

def test_place_order_persists_total_in_sqlite():
    connection = sqlite3.connect(":memory:")
    connection.execute(
        "CREATE TABLE orders (id INTEGER PRIMARY KEY, total_cents INTEGER NOT NULL)"
    )
    repo = SQLiteOrderRepo(connection)

    order_id = place_order(repo, quantity=3, unit_price_cents=1299, tax_rate_bps=875)

    saved = connection.execute(
        "SELECT total_cents FROM orders WHERE id = ?", (order_id,)
    ).fetchone()
    assert saved == (4238,)

Same function, same arguments, same expected number. What changed is everything around it. There is a connection, a schema, a real repository, and a read-back query. The assertion is no longer about a call that was made. It is about a row that exists.

Run both with pytest, which discovers files named test_*.py and treats plain assert statements as expectations:

python -m pip install pytest==9.1.1
python -m pytest -q
..                                                                       [100%]
2 passed in 0.01s

Now look at what each failure tells you. Break the arithmetic first: delete + 5_000 from the tax line in place_order, so the division truncates instead of rounding. Both tests fail, and the unit failure names the number.

E           AssertionError: expected call not found.
E           Expected: insert(4238)
E             Actual: insert(4237)

The defect is inside one function, and the mock proves no database was involved. The search area is the four lines you just read.

Now break the schema instead. Rename total_cents to amount_cents in the CREATE TABLE statement and leave the production code alone. The unit test still passes, because it was never looking. The integration test fails, because the SQL and the schema no longer agree.

E       sqlite3.OperationalError: table orders has no column named total_cents
FAILED test_orders_integration.py::test_place_order_persists_total_in_sqlite
1 failed, 1 passed in 0.03s

That failure is the one a mock can never produce, and it is the reason the second test exists. Not every schema change is caught, though. Drop the NOT NULL from the column and both tests still pass, because this test never inserts a null. An integration test proves the paths it walks, not the constraint it happens to sit next to.

Notice what the integration test does not prove. SQLite is a real database, so the test proves the repository contract and the SQL persist a value and read it back. It does not prove that the same statement behaves identically on PostgreSQL or MySQL, where types, defaults, and constraint handling differ. The test is honest about its own boundary, which is the right way to read any integration test.

One detail is worth copying. Both tests call place_order, the function the application actually calls. Demonstrating the two levels on two different entry points hides the only thing that matters: the level is a choice about collaborators, not about which code you are allowed to reach.

For more on writing the first kind, see unit testing examples and best practices. For the approaches and tools behind the second, see integration testing types, tools, and practices.

When to choose a unit test or an integration test

Work from the risk, not from a quota. Ask what would have to be wrong for this code to misbehave in production, then write the test that would catch that.

  • Pure logic: unit test. Branching, arithmetic, parsing, validation, state transitions, and edge cases belong at the unit level. They have many cases and no infrastructure, so covering them anywhere else is slower for no extra confidence.

  • A database: integration test. Queries, migrations, constraints, transactions, and the mapping between rows and objects are only proved against a real engine. Microsoft's guidance is blunt about the other direction: "Try not to introduce dependencies on infrastructure when writing unit tests", because those dependencies make tests slow and brittle and belong in integration tests.

  • An API you call: integration test at the boundary. Test your client against a real or contract-faithful server for the request shape, the auth, the error handling, and the parsing. Unit test the logic that decides what to send.

  • A queue or a filesystem: integration test. Serialization, acknowledgement, retries, ordering, paths, and permissions live in the collaboration, not in your function.

  • Framework wiring: integration test. Routing, dependency injection, middleware, and configuration are the framework's behavior plus yours. A unit test of a handler skips the wiring, which is the part it cannot see.

  • A third-party service: both, in layers. Unit test your handling of every documented response. Add a small number of integration tests against a sandbox to prove the responses are still what the documentation says.

One rule keeps the suite from doubling. A higher-level test earns its place when it adds confidence you cannot get lower down. Repeating every edge case at both levels adds runtime and maintenance without adding matching confidence, which is the practical core of Fowler's test pyramid. Cover the arithmetic once, at the unit level, and let the integration test prove the boundary and one happy path through it. The shape this produces is described in the software testing pyramid.

How to combine both in CI

Order the pipeline by cost, not by ceremony. Run the fast, isolated suite first, on every push. It needs nothing but the test runner, it returns the quickest answer the pipeline has, and a failure there points at code somebody just wrote. Gating on it early means the expensive stages never run against a change that is already broken.

Run the boundary tests after that, still inside the pull request. The job now has to provide the dependency, which in practice means an in-memory engine, a container, or an ephemeral schema per run. Give each run its own data so tests do not fight over shared rows, and tear it down at the end. Tests that pass alone and fail together are the signature of shared state, so look there first.

Keep the whole pull request answer inside the time a developer will actually wait. The split is yours to choose, since it depends on how many tests you have and what they touch. The ordering rule holds either way: cheapest and narrowest first, broadest and most realistic last.

Broader checks that need a deployed environment sit after merge or on a schedule. Some of those are still integration tests, pointed at deployed components instead of in-process ones. The ones that walk a whole user journey across the system are end to end tests, a different level again, covered in what is end to end testing. For wiring the jobs themselves, see CI/CD testing.

Common mistakes

Mocking the boundary the test exists to prove. An integration test that mocks the database has become a slow unit test. If the point is the SQL, the SQL has to run.

Duplicating every assertion at both levels. Running the same tax edge cases at the unit level and again through a real database buys no extra information and costs the database setup every time. Keep the case count low above the unit level, and prove the boundary once.

Testing implementation details. Asserting on private methods, internal call order, or the exact number of times a helper ran turns a harmless refactor into a test failure. Assert on the behavior the caller can see.

Sharing mutable state between tests. A module-level connection or a seeded table reused by multiple tests turns the suite into an ordering puzzle. Build fresh state per test, as the example above does.

Calling every HTTP test an end to end test. A test that sends one request to one service with a real database is an integration test. End to end means a journey across a deployed system. Getting this wrong makes people think they have coverage they do not have.

Treating the label as the decision. Ask which collaborators are real and what a failure would localize, then write the test and move on.

The short version

The level is a choice about collaborators. Replace them and you have a unit test that pins logic and points at one function when it fails. Keep them real and you have an integration test that proves a contract a mock would happily lie about. Pick by what you doubt, prove each thing once, and let the fast suite run first.

Frequently Asked Questions

What is the difference between unit testing and integration testing?

A unit test checks one unit of behavior on its own, with collaborators replaced by mocks or fakes you control. An integration test checks that real components work together, such as your code and a database. ISTQB classifies both by focus: individual components against interactions between components or systems. Same code can be reached either way; the collaborators are what differ.

Are integration tests better than unit tests?

Neither is better. They answer different questions. A unit test tells you a function computes the right value and points at the function when it does not. An integration test tells you two parts agree about a schema, a contract, or a configuration, which no mock can prove. A suite with only one of them has a blind spot you will find in production.

Can integration tests replace unit tests?

Not sensibly. You can drive every branch through a real database, but each case then carries schema setup, data, and teardown, and a failure no longer says which layer broke. Edge cases are cheap at the unit level and expensive above it. Use integration tests for the collaborations, and let the unit tests carry the case count.

Should integration tests use mocks?

Use the real dependency for the boundary the test exists to prove, otherwise the test proves nothing new. Faking a dependency outside that boundary is reasonable when the real one is unsafe, unavailable, or costly, such as a payment provider or a third-party API with rate limits. Say in the test name which boundary is real, so the next reader knows what passed.

Is API testing unit or integration testing?

It depends on what is real. A test of the function that builds a request, with the HTTP client mocked, is a unit test. A test that sends a request to a running service and asserts on the response is an integration test at that boundary. If it walks a user journey across a deployed system, it is an end to end test instead.

Which tests should run first in CI?

Run the fast, isolated suite first, on every push. It needs no dependencies, returns the quickest answer in the pipeline, and stops the expensive stages from running against a change that is already broken. Run the boundary tests after it, still inside pull request feedback, with fresh data per run. Broader checks against a deployed environment come after merge or on a schedule.

Ship continuously. Test continuously.

Qodex explores your app, writes runnable tests, and replays them on every change at zero LLM cost.