ScreenshotNeo

BlogEngineering

Unit Testing vs. Integration Testing: Differences and Examples

Unit tests check behavior in isolation; integration tests check whether components work together. Compare examples, tradeoffs, and how to choose the right boundary.

By the ScreenshotNeo team4 October 20268 min read

A unit test checks a small, defined unit of behavior in relative isolation. An integration test checks whether separate components work together across a boundary, such as an application and its database or a request pipeline and its handlers. The practical difference is the scope exercised and which dependencies are real or replaced—not the test’s filename or framework.

Use the narrowest test layer that answers the question. Test deterministic business rules as unit tests; add focused integration tests for important boundaries where configuration, serialization, infrastructure, or component interaction can fail. Teams use these labels differently, so document the boundary each test covers. Microsoft Learn recommends choosing a unit test when either layer can verify the behavior.

1. What makes a test a unit or integration test?

A unit is a team-defined piece of behavior. It might be a function, method, class, or a small group of code that the team treats as one unit. It does not have to be a class. The important point is that the test is focused enough to explain what behavior it checks.

An integration test crosses a boundary between components. It might include a real database, file system, HTTP request pipeline, or service API. It can still replace some dependencies: “integration” does not mean every dependency must be live. State what is real, what is simulated, and what boundary the test exercises.

Dimension Unit test tendency Integration test tendency
Scope One small behavior or unit Interaction across components or a system boundary
Dependencies Controlled inputs; fakes or mocks often replace collaborators Real components commonly participate; some dependencies may still be replaced
Setup and feedback Usually simpler and faster Usually requires more setup and processing
Confidence Local logic, decisions, and edge cases Interfaces, configuration, persistence, serialization, and interaction
Maintenance risks Can become coupled to implementation details May depend on test data, services, and environment setup

These are common tendencies, not fixed rules. Martin Fowler notes that “integration test” is a blurred term; his writing distinguishes focused checks of collaborators from broader system-level checks. A label alone does not tell another developer what ran.

2. Unit test example: validate a price calculation

A deterministic calculation is a good unit-test candidate. This Python example uses only the standard library. Save it as test_pricing.py and run python -m unittest -v. The test exercises the pricing rule directly; it does not connect to a database or network.

from decimal import Decimal
import unittest


def discounted_total(subtotal: Decimal, discount_rate: Decimal) -> Decimal:
    if subtotal < 0:
        raise ValueError("subtotal must be non-negative")
    if not Decimal("0") <= discount_rate <= Decimal("1"):
        raise ValueError("discount_rate must be between 0 and 1")
    return (subtotal * (Decimal("1") - discount_rate)).quantize(Decimal("0.01"))


class DiscountedTotalTests(unittest.TestCase):
    def test_applies_discount(self):
        self.assertEqual(
            discounted_total(Decimal("100.00"), Decimal("0.15")),
            Decimal("85.00"),
        )

    def test_zero_discount_keeps_subtotal(self):
        self.assertEqual(
            discounted_total(Decimal("12.50"), Decimal("0")),
            Decimal("12.50"),
        )

    def test_rejects_negative_subtotal(self):
        with self.assertRaises(ValueError):
            discounted_total(Decimal("-1"), Decimal("0.1"))

    def test_rejects_discount_out_of_range(self):
        with self.assertRaises(ValueError):
            discounted_total(Decimal("10"), Decimal("1.1"))


if __name__ == "__main__":
    unittest.main()

The assertions describe outcomes rather than private implementation steps. Add cases when they represent meaningful behavior—such as boundary values or rounding rules—rather than multiplying every possible input combination.

3. Integration test example: exercise an HTTP boundary

An HTTP integration test can send a request through an application and assert the response. The exact setup depends on the framework: for example, ASP.NET Core provides a test host pattern in its integration-testing guidance. The following runnable Node.js example makes the boundary explicit using a local HTTP server and a real HTTP request. It needs Node.js 18 or later and no third-party packages.

Save as integration.test.mjs, then run node --test integration.test.mjs. The test starts a server, sends a request through its HTTP pipeline, checks the response, and closes the server.

import test from "node:test";
import assert from "node:assert/strict";
import { createServer } from "node:http";

function makeServer() {
  return createServer((req, res) => {
    if (req.method === "GET" && req.url === "/health") {
      res.writeHead(200, { "content-type": "application/json" });
      res.end(JSON.stringify({ status: "ok" }));
      return;
    }
    res.writeHead(404, { "content-type": "application/json" });
    res.end(JSON.stringify({ error: "not found" }));
  });
}

test("GET /health returns the application health response", async (t) => {
  const server = makeServer();
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
  t.after(() => new Promise((resolve, reject) =>
    server.close((error) => error ? reject(error) : resolve())
  ));

  const address = server.address();
  const response = await fetch(`http://127.0.0.1:${address.port}/health`);
  assert.equal(response.status, 200);
  assert.deepEqual(await response.json(), { status: "ok" });
});

This small example integrates the HTTP client, server, routing condition, status, headers, and JSON serialization. A production application test should start the application using its supported test host or test configuration, then assert the contract that matters. For a persistence boundary, a focused test can write a record and read it back using the database setup intended for the application.

4. Choosing the right layer

  1. Write down the behavior and boundary. Is the question about a calculation, or whether a request is routed, authorized, serialized, and persisted correctly?
  2. Choose the narrowest layer that can answer it. If controlled inputs can verify the behavior, a unit test is usually easier to run and diagnose.
  3. Use integration tests for important interactions. Cover boundaries where isolated tests cannot detect mismatched configuration, query behavior, serialization, or contract errors.
  4. Prioritize by risk and impact. Cover critical workflows and failure modes; do not create every data permutation at every layer.
  5. Make the environment repeatable. Keep test data isolated, use a local or dedicated test service where possible, and avoid sending automated test traffic to production.

The testing pyramid is a useful model: lower layers tend to be more isolated and faster, while broader layers tend to be slower. It is guidance for balancing feedback and confidence, not a required ratio or test count. The ISTQB Foundation Level syllabus describes this model and its tradeoffs.

5. Database, service, and visual boundaries

Database integration

Exercise a small set of meaningful read, write, update, or delete paths against an isolated test database or suitable dedicated instance. Check the behavior the application depends on, including schema and serialization assumptions. Clean up test data so a rerun does not depend on leftovers.

External service integration

Prefer a local service instance or dedicated test environment when available. Verify that the application handles representative responses and failures. Avoid production traffic from automated tests. When an external service cannot be used reliably in a test, isolate its adapter with unit tests and retain a smaller number of tests for the boundary contract.

Browser-rendered output

A rendered page can be one observable result of an application integration path. Screenshot comparison may help detect visual changes, but it does not replace assertions about business behavior, accessibility, or API contracts. Control viewport, data, fonts, timing, and third-party content to reduce irrelevant variation.

6. Or skip the browser setup

If an integration workflow needs a rendered website capture, ScreenshotNeo returns a PNG, JPEG, WebP, or PDF from one API request. For example, this cURL request captures a page as WebP:

curl -G "https://api.screenshotneo.com/v1/shot" \
  -d access_key=YOUR_API_KEY \
  --data-urlencode url=https://stripe.com \
  -o shot.webp

See the ScreenshotNeo API documentation for request options. Cookie and consent banners, newsletter popups, and chat widgets are removed before capture; each cleanup step can be turned off. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers report the page verdict and billing status. An MCP server gives AI agents tools for screenshots, page information, and PDF capture. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000.

import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({
  access_key: "YOUR_API_KEY",
  url: "https://stripe.com",
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await Bun.write("shot.webp", res);

Start free with 1,000 screenshots a month and no card.

7. Troubleshooting test suites

Symptom Likely cause Fix
Unit test fails after a harmless refactor It asserts private call order or implementation details Assert observable behavior and keep collaborators replaceable where needed.
Integration test passes locally but fails in CI Environment configuration, service readiness, ports, or test data differ Use explicit test configuration, wait for dependencies to be ready, isolate data, and capture useful logs.
Tests pass alone but fail in a suite Shared state, resource leakage, or order dependence Reset data and state per test; close servers and connections in cleanup hooks.
Database test is slow or flaky Uncontrolled database state, broad setup, or timing assumptions Keep the scenario focused, use isolated data, and wait on a real condition instead of a fixed sleep.
External API test changes unexpectedly It depends on production data, credentials, or remote availability Use a dedicated test service or local instance when available; separately test adapter behavior with controlled responses.
Screenshot assertions differ across runs Fonts, viewport, animation, timestamps, dynamic content, or third-party widgets vary Fix the viewport and test data, wait for a stable page condition, and remove or control volatile content.
Integration test does not catch a production defect The test environment does not match a relevant production boundary Review which components and configuration are real, then add a focused test for the missing interaction.

8. Performance, reliability, and cost

Unit tests generally give faster feedback because they avoid infrastructure setup and can run many cases with controlled inputs. Integration tests require more setup and processing, so keep them focused on boundaries whose failures matter. A smaller, reliable suite is more useful than a large suite that developers routinely skip.

Reliability comes from explicit dependencies, repeatable data, deterministic inputs, cleanup, and useful failure output. Parallel execution can reduce elapsed time, but only when tests do not compete over shared ports, records, or external services. The ongoing cost is engineering time: maintaining fixtures, environments, and test data. Balance it against the risk and impact of defects at each boundary. Do not infer a universal unit-to-integration ratio from the pyramid.

9. Frequently asked questions

Can a test be both a unit test and an integration test?

Labels vary by team, and test scope can sit between common categories. Describe what the test exercises and which collaborators are real so the intent remains clear.

Are mocks required for unit tests?

No. A unit test may use plain values and no mocks. Replace a collaborator when controlling it makes the behavior deterministic or keeps the test within the intended boundary.

Do integration tests need a production database?

No. Use a repeatable test database or dedicated test instance suitable for the behavior under test. The important point is to exercise the relevant integration boundary safely.

Which kind should run on every commit?

Run the fast, dependable checks that give useful feedback for the project. Teams commonly include unit tests and selected integration tests in commit or CI workflows, with broader or environment-heavy checks in an appropriate pipeline stage.

Sources