A unit test can be green while the application sends the wrong field to the database. An integration test can fail because an internal detail changed even though the public behavior is still correct. The problem is not choosing one side forever. It is choosing the smallest boundary that proves the right claim.

Use a unit test when the question is about one unit of behavior and its collaborators are outside the proof. Use an integration test when confidence depends on modules cooperating, serialization, or real input and output. The overview of software testing types helps place this choice among the other layers.

The examples below use JavaScript and the built-in node:test runner. I ran the fixture locally with Node.js. It demonstrates the decision boundary, not the behavior of a specific database or provider.

Diagram comparing an isolated unit test with an integration test that crosses a real boundary.

Short answer

  • Test a function in isolation when the claim is about its logic and input data.
  • Cross a boundary when the claim depends on modules cooperating, serialization, or I/O.
  • A fake can prove collaboration inside your application, but it does not prove that a real database or provider accepts the contract.
  • Do not choose the layer from the filename. Choose what must stay real for the proof to be trustworthy.

What question can each layer answer?

The test question should appear in the case name and setup. If you can answer it with in-memory values and a direct call, a unit test is a good starting point. If the answer depends on a route, a query, serialized bytes, or a service, the proof needs to keep that boundary alive.

Question Starting layer What stays real
Does this rule turn the input into the right result? Unit The unit under test
Do this service and repository agree on the format? Narrow integration The chosen modules and adapter
Does the application talk to a database, file, or API as expected? External integration A test dependency or controlled sandbox
Can the user complete the published flow? End-to-end The application through its interface

This table is not a rigid taxonomy. Teams use the names differently. The useful part is to state the boundary. Martin Fowler describes the same difficulty in "The Practical Test Pyramid": the vocabulary for test levels is not fully stable, but different granularities address different risks.

When is a unit test the right choice?

Choose a unit test when the important behavior fits inside a function or module and the proof does not need to confirm collaborator behavior. A pricing rule, normalizer, or permission decision can take fixed data and return a verifiable result without starting a network connection, database, or filesystem.

This is a small module with no external dependencies:

// totals.mjs
export function totalOf(items) {
  return items.reduce(
    (total, item) => total + item.price * item.quantity,
    0,
  );
}

The test can call the function directly. The real collaborator is the rule we want to evaluate:

// totals.unit.test.mjs
import test from "node:test";
import assert from "node:assert/strict";
import { totalOf } from "./totals.mjs";

test("calculates the total for each item", () => {
  assert.equal(
    totalOf([
      { price: 10, quantity: 2 },
      { price: 5, quantity: 1 },
    ]),
    25,
  );
});

The node:test module accepts synchronous tests and functions that return a Promise. The Node.js, "Test runner" documentation also shows that an exception or rejected Promise makes the test fail. That is enough here because the claim does not depend on an HTTP port or a table.

A unit test loses value when it starts repeating the implementation. If it needs to know the exact query, private call order, and several adapter details, it may be trying to prove collaboration. Test the unit's public behavior and let the integration prove the conversation between parts.

When is an integration test necessary?

Use an integration test when the risk sits in the conversation between components. The code can calculate the total correctly and still persist an object that the repository cannot accept. A unit test for the calculation cannot discover that broken agreement because the adapter was removed from the proof.

A narrow integration can keep the application modules real and replace only infrastructure that does not need to be exercised in that case. Here, the service uses an in-memory repository. That proves collaboration between the service and repository, but it does not claim that PostgreSQL, indexes, or transactions are correct.

// orders.mjs
import { totalOf } from "./totals.mjs";

export function createOrder({ items, orders }) {
  const order = { items, total: totalOf(items) };
  return orders.insert(order);
}
// orders.integration.test.mjs
import test from "node:test";
import assert from "node:assert/strict";
import { createOrder } from "./orders.mjs";

test("stores the total produced by the service", () => {
  const stored = [];
  const orders = {
    insert(order) {
      const saved = { id: "order-1", ...order };
      stored.push(saved);
      return saved;
    },
  };

  const result = createOrder({
    items: [{ price: 10, quantity: 2 }],
    orders,
  });

  assert.deepEqual(result, stored[0]);
  assert.equal(stored[0].total, 20);
});

The in-memory adapter is deliberate. It keeps the example fast and reproducible, but it should not be called a database integration. To prove the database contract, add a separate layer that starts a test database, writes a row, and reads it back. For an external API, use a sandbox or controlled test server. Do not use production as a fixture.

What should stay real in the test?

The real collaborator is a decision about the claim, not a reward for using fewer mocks. A unit test can use a stub for an external port. An integration test can use a fake for a remote provider while keeping the service, parser, and adapter under review real.

If the risk is in... Keep real... Replace...
Business logic The function and its values Database, network, and external clock
Module collaboration The service and involved modules Dependencies outside the boundary
JSON or HTTP protocol Serialization, headers, and parser Unstable external network
Schema and transaction Test database and adapter Production data

The Vitest guide to mocking functions separates spies and mocks by purpose: observing a call is not the same as supplying a fake implementation. That distinction helps review a test, but it does not make Vitest a requirement. The same reasoning applies to team-written fakes and Node.js mocking APIs.

If every dependency is replaced, the test may only confirm that the code called its doubles as expected. If nothing is replaced, the case can become slow, fragile, and hard to diagnose. The better choice is to keep exactly what supports the claim real and control the rest.

How do you avoid proving the same thing twice?

A healthy suite does not repeat every edge case at every layer. Put the rule matrix at the cheapest layer that can prove it. Then add an integration test for the boundary the unit does not cross. An end-to-end test should add confidence about the complete path, not copy every internal combination.

Consider an endpoint that calculates an order and stores the total:

  1. A unit test covers discount, invalid quantity, and rounding.
  2. An integration test confirms that the service serializes and stores the expected shape.
  3. An end-to-end test confirms that a published request crosses authentication, routing, and the response.

If the third test fails because the total is wrong, the first should fail too. If only the third fails because the route was not registered, do not copy the discount matrix into the end-to-end test. Fix the layer that lacked a proof.

For an event-delivery flow, test a webhook boundary by separating signatures, duplicates, and retries. When the proof depends on the browser and the published path, use browser end-to-end tests for what smaller layers cannot cross.

The article on testing JavaScript retry logic without waiting uses a similar separation: proving the scheduler is not the same as proving an external effect. Separate claims make failures easier to locate.

How should you organize this choice in CI?

Separate commands by cost and required infrastructure, but do not use command names to hide the boundary. The native runner can execute all test files with node --test; different directories or patterns can organize unit and integration cases according to the project.

A simple convention might be:

{
  "scripts": {
    "test:unit": "node --test test/unit/*.test.mjs",
    "test:integration": "node --test test/integration/*.test.mjs",
    "test": "npm run test:unit && npm run test:integration"
  }
}

The example is illustrative. Confirm the patterns accepted by the Node.js version used in CI and make sure each command finds the expected files. If the integration needs a database, the pipeline should create that service, wait until it is ready, and clean up data without sharing state between cases.

Vitest documents per-file isolation and supports separate projects for unit and integration tests. Use that option when Vitest is already in the project. Do not introduce a new runner only to give names to folders.

What signs show that the layer is wrong?

The test is often in the wrong layer when its cost and claim do not match. The signs appear in setup, not only in total CI time:

  • A test called unit starts a database, network, or filesystem without that being part of the claim.
  • An integration test replaces every relevant module and never crosses the boundary it should protect.
  • The test breaks on every internal refactor even though the public contract did not change.
  • The suite needs sleep, fixed ordering, or shared data to pass.
  • The same business case appears in unit, integration, and end-to-end tests without adding new confidence.

When this happens, write the claim in one sentence. Then mark the collaborators that must stay real for that sentence to be true. The review usually shows whether you need a fast unit test, a narrow integration, or an external proof.

Frequently asked questions

Is a test with a mock always a unit test?

No. The name depends on the boundary the proof keeps real. A test can combine real modules and use a fake for a remote API. Describe the claim and the collaborators kept real instead of inferring the layer from the use of mock.

Should integration tests use the real database?

Use the real dependency when the claim is about that dependency's contract. An isolated test database proves more than an in-memory repository. It must still be disposable and separate from production. For internal rules, database integration may be unnecessary.

Do I need to follow the test pyramid?

Use the pyramid as a heuristic for cost and granularity, not as a mandatory ratio. The mix depends on system risk and boundary type. The more stable rule is to keep fast proofs for local logic and explicit proofs where data, modules, or services can disagree.

Can I call any test an integration test?

You can use a local definition if the team keeps it consistent. Record what the name includes: two in-memory modules, a test database, a sandbox API, or the whole system. Clarity about the contract matters more than winning a vocabulary argument.

Conclusion

Choose a unit test when the claim fits inside an isolated unit. Choose an integration test when confidence depends on collaboration, serialization, or I/O that the unit does not cross. Add an end-to-end test only when the full path adds a new proof.

The result is not a dogmatic suite split. It is a suite where each case explains the risk it covers, keeps the required boundary real, and fails near the cause. When the next change alters the implementation, that separation preserves the tests that still describe behavior.

Sources consulted