30% offEnding soon

Unit vs Integration Testing: Frontend Examples

21 min read

Unit testing checks one deliberately isolated frontend unit, while integration testing checks whether multiple real frontend parts cooperate correctly across a stated boundary.

Unit Testing vs Integration Testing: The Short Answer

A unit test chooses one production unit and controls or excludes its collaborators. That unit might be a function, hook, component, state reducer, or network client. The test supplies inputs directly, observes outputs, and replaces dependencies when their real behavior falls outside the chosen boundary.

Suppose filterSuggestions receives a list of products and a search query. A unit test can call that function with fixed values and verify case handling, whitespace handling, ordering, and empty input. It does not need to render a component or send a request.

An integration test deliberately includes two or more real application parts. A search test might render the real search component, use its real state, type into its input, let it call the real frontend client, and verify the child results list. The backend can remain outside the boundary through an intercepted request.

The boundary sets the test typeUNITone real partfixedinputfilterfunctionresultINTEGRATIONreal parts cooperateinputstateclientresultsone tested path
The chosen boundary determines whether one part or several cooperating parts are under test.

The label follows that decision:

Chosen boundary: filterSuggestions
Real production parts exercised: filterSuggestions
Classification: unit test

Chosen boundary: search interface
Real production parts exercised: input handler, state, client, parser, filter, results component
Classification: integration test

The tool does not settle the question. Testing Library can support unit, integration, and end-to-end tests, according to its DOM Testing Library FAQ. Jest or Vitest can also run tests at different scopes.

Terminology still varies between teams. One team may call a network-intercepted UI test an integration test because it integrates several frontend parts. Another may call it a component test because the backend is replaced. State the boundary and the real collaborators first. The label then becomes useful shorthand rather than the entire explanation.

For a broader treatment of test scope, see unit tests versus integration tests for frontend apps.

The Differences at a Glance

Unit and integration tests trade isolation for broader evidence. Neither type is automatically better.

Two kinds of confidence move differentlyFailure pinpointingCollaboration proofUNITINTEGRATION
Moving toward integration exchanges diagnostic precision for evidence about more collaboration.
QuestionUnit testIntegration test
What is the scope?One deliberately chosen unitTwo or more cooperating application parts
What happens to dependencies?Collaborators are controlled, replaced, or excludedRelevant production collaborators remain real inside the boundary
How much setup is typical?Small inputs and direct callsRendering, providers, request interception, or browser setup may be needed
What does a failure identify?Usually a narrow logic errorA broken interaction somewhere inside the tested path
What realism does it provide?Precise evidence about isolated behaviorEvidence that connected parts produce the expected observable behavior
What maintenance does it require?Tests may change when private structure is over-specifiedTests may change when shared contracts or visible workflows change
What defects is it positioned to find?Branches, calculations, transformations, and edge casesWiring, event handling, provider configuration, parsing, and state transitions

Unit tests often need less setup because they execute a smaller boundary. Their narrow scope can also make failures easier to diagnose. A failed expectation in filterSuggestions points toward the filtering rule rather than the entire search interface.

Integration tests cover more production collaboration per test. That broader scope can reveal a correct function connected to the wrong event, a provider omitted from the rendered tree, or a response parser reading the wrong property. The same breadth can make diagnosis slower because several parts participated.

Mocks, stubs, and fakes all provide controlled substitutes, though teams do not always use those words identically. A mock commonly records how it was called. A stub returns test-selected values. A fake supplies a working but simplified implementation. What matters for classification is which production collaborators the substitute removes from the exercised boundary.

End-to-end tests use a wider boundary. They exercise a user flow through a deployed or deployable application and more of its surrounding system. A frontend integration test can stop at an intercepted network request. It still tests real cooperation inside the frontend, but it does not prove that the live backend, authentication system, and deployed routing all work together.

Wider tests include more of the real systemEND TO END: DEPLOYED SYSTEMFRONTEND INTEGRATIONinputclientresults UIUNITfilterauthdeployedrouteslive backend
Unit, frontend integration, and end-to-end tests cover increasingly large nested portions of the system.

What Counts as a Unit in Frontend Code?

A unit is the production boundary the test deliberately isolates. It is not always the smallest function in the file.

A pure function is an obvious unit because its output depends on its arguments. A hook can also be treated as a unit when the test isolates its state rules and controls outside services. A component can be a unit when its children and service dependencies are replaced or excluded.

A larger rendered tree may form the integration boundary. For example, a test can include a form component, validation hook, context provider, and error summary because confidence depends on their cooperation.

Use the same reasoning for common frontend subjects:

  • A router helper tested with path strings can be a unit.
  • A screen rendered inside the real application router is an integration test when navigation and route selection are part of the claim.
  • A context reducer tested through direct actions can be a unit.
  • A component using the real provider and consumer integrates those application parts.
  • A network client tested with a controlled transport can be a unit.
  • A component using the real client and parser integrates frontend parts even when the request is intercepted.
  • A browser API wrapper tested against a replacement object can be a unit.
  • A feature using the real wrapper, component state, and visible fallback UI is broader.

Rendering DOM does not automatically create an integration test. DOM Testing Library can query either a simulated DOM or a real browser, as its documentation explains. Vitest likewise provides different environments, including a default Node environment, a jsdom environment that emulates browser APIs, and Browser Mode for native browser execution, according to the Vitest environment guide.

These environments affect which platform behavior is available. They do not determine how many production collaborators remain real.

“Component test” and “DOM test” describe the test subject or medium. “Unit” and “integration” describe the chosen scope. A component test can therefore belong to either category.

Medium and scope are different choicesMORE REAL PARTSdirect callDOM / browserPure functionUNITComponentwith mockedchildren: UNITReducer plusreal providerINTEGRATIONScreen plusreal routerINTEGRATION
DOM rendering answers how the test runs; the boundary answers what kind of test it is.

The Same Feature Tested Both Ways

Consider a product autocomplete. It fetches suggestions after typing, filters malformed or irrelevant results, and renders loading, success, empty, and error states.

One input event can lead to four UI statesIs the query blank?IDLEclear resultsLOADINGshow statusSUCCESSresults or emptyERRORshow alertYESNOrequest OKrequest fails
The autocomplete state is determined first by the query and then by the request outcome.

The following files form one canonical implementation. The unit and integration tests import from this implementation rather than redefining it.

The product search implementation

filterSuggestions.js contains the dense filtering logic:

export function filterSuggestions(products, query) {
  const normalizedQuery = query.trim().toLowerCase();

  if (!normalizedQuery) {
    return [];
  }

  return products
    .filter(
      product =>
        typeof product.id === 'number' &&
        typeof product.name === 'string' &&
        product.name.toLowerCase().includes(normalizedQuery)
    )
    .slice(0, 5);
}

searchProducts.js owns the frontend request and response parsing:

export async function searchProducts(query) {
  const response = await fetch(
    `/api/products?q=${encodeURIComponent(query)}`
  );

  if (!response.ok) {
    throw new Error('Product search failed');
  }

  const body = await response.json();

  if (!Array.isArray(body.products)) {
    throw new Error('Invalid product response');
  }

  return body.products;
}

SearchResults.jsx is the real child component:

export function SearchResults({ products }) {
  if (products.length === 0) {
    return <p>No matching products</p>;
  }

  return (
    <ul aria-label="Product suggestions">
      {products.map(product => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

ProductSearch.jsx connects input events, state, the network client, filtering logic, and the results component:

import { useState } from 'react';
import { filterSuggestions } from './filterSuggestions.js';
import { searchProducts } from './searchProducts.js';
import { SearchResults } from './SearchResults.jsx';

export function ProductSearch() {
  const [status, setStatus] = useState('idle');
  const [products, setProducts] = useState([]);

  async function handleChange(event) {
    const query = event.target.value;

    if (!query.trim()) {
      setProducts([]);
      setStatus('idle');
      return;
    }

    setStatus('loading');

    try {
      const responseProducts = await searchProducts(query);
      setProducts(filterSuggestions(responseProducts, query));
      setStatus('success');
    } catch {
      setProducts([]);
      setStatus('error');
    }
  }

  return (
    <section aria-labelledby="product-search-heading">
      <h2 id="product-search-heading">Find a product</h2>

      <label htmlFor="product-query">Product name</label>
      <input
        id="product-query"
        type="search"
        onChange={handleChange}
      />

      {status === 'loading' && <p role="status">Loading products</p>}
      {status === 'error' && (
        <p role="alert">Product search is unavailable</p>
      )}
      {status === 'success' && (
        <SearchResults products={products} />
      )}
    </section>
  );
}

The unit test isolates filtering logic

The unit test calls filterSuggestions directly with controlled inputs:

import { describe, expect, it } from 'vitest';
import { filterSuggestions } from './filterSuggestions.js';

describe('filterSuggestions', () => {
  it('matches names without caring about case', () => {
    const products = [
      { id: 1, name: 'Mechanical Keyboard' },
      { id: 2, name: 'Laptop Stand' }
    ];

    expect(filterSuggestions(products, 'KEY')).toEqual([
      { id: 1, name: 'Mechanical Keyboard' }
    ]);
  });

  it('ignores malformed products', () => {
    const products = [
      { id: 1, name: 'USB Keyboard' },
      { id: '2', name: 'Gaming Keyboard' },
      { id: 3 }
    ];

    expect(filterSuggestions(products, 'keyboard')).toEqual([
      { id: 1, name: 'USB Keyboard' }
    ]);
  });

  it('returns no suggestions for a blank query', () => {
    expect(
      filterSuggestions(
        [{ id: 1, name: 'Mechanical Keyboard' }],
        '   '
      )
    ).toEqual([]);
  });

  it('limits suggestions to the first five matches', () => {
    const products = Array.from({ length: 6 }, (_, index) => ({
      id: index + 1,
      name: `Keyboard ${index + 1}`
    }));

    expect(filterSuggestions(products, 'keyboard')).toEqual(
      products.slice(0, 5)
    );
  });
});

This test can catch case-sensitive matching, missing input normalization, malformed data handling, and limit errors. Each failure points directly to the filtering boundary.

It cannot show whether the input calls the search client, whether the query is serialized correctly, whether the loading state appears, or whether the filtered values reach the child component. It also cannot expose a parser that reads body.results when the response contains body.products.

The integration test exercises the frontend path

The integration test keeps the production component, client, parser, filter, state, and child component real. It replaces the network boundary with request handlers.

Two boundaries around the same featureFRONTEND INTEGRATION BOUNDARYinputeventstateclientparserfilterUNIT BOUNDARYresultscomponentrequest interceptedlive backendnot exercised
The integration test follows the complete frontend loop while stopping before the live backend.

Vitest recommends Mock Service Worker for request mocking without changing application code, as described in its request mocking guide. The test setup starts the request server and restores handlers between cases:

import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterAll, afterEach, beforeAll } from 'vitest';
import { setupServer } from 'msw/node';

export const server = setupServer();

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
  cleanup();
  server.resetHandlers();
});
afterAll(() => server.close());

The success and error tests use the same production implementation:

// @vitest-environment jsdom

import { http, HttpResponse } from 'msw';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
import { server } from './testSetup.js';
import { ProductSearch } from './ProductSearch.jsx';

describe('ProductSearch integration', () => {
  it('shows loading and then matching products', async () => {
    let releaseResponse;

    server.use(
      http.get('/api/products', ({ request }) => {
        const url = new URL(request.url);

        expect(url.searchParams.get('q')).toBe('key');

        return new Promise(resolve => {
          releaseResponse = () =>
            resolve(
              HttpResponse.json({
                products: [
                  { id: 1, name: 'Mechanical Keyboard' },
                  { id: 2, name: 'Laptop Stand' }
                ]
              })
            );
        });
      })
    );

    const user = userEvent.setup();
    render(<ProductSearch />);

    const searchbox = screen.getByRole('searchbox', {
      name: 'Product name'
    });
    await user.click(searchbox);
    await user.paste('key');

    expect(screen.getByRole('status')).toHaveTextContent(
      'Loading products'
    );

    releaseResponse();

    expect(
      await screen.findByText('Mechanical Keyboard')
    ).toBeInTheDocument();
    expect(
      screen.queryByText('Laptop Stand')
    ).not.toBeInTheDocument();
  });

  it('shows an error when the request fails', async () => {
    server.use(
      http.get('/api/products', () =>
        HttpResponse.json(
          { message: 'Unavailable' },
          { status: 503 }
        )
      )
    );

    const user = userEvent.setup();
    render(<ProductSearch />);

    await user.type(
      screen.getByRole('searchbox', { name: 'Product name' }),
      'key'
    );

    expect(
      await screen.findByRole('alert')
    ).toHaveTextContent('Product search is unavailable');
    expect(
      screen.queryByLabelText('Product suggestions')
    ).not.toBeInTheDocument();
  });
});

The test finds the control by role and accessible name, then checks the status message by role and text content. Testing Library places getByRole with an accessible name at the top of its query preference list for most elements in its query guidance. Its user-event library dispatches the sequence associated with an interaction and performs additional checks, rather than treating typing as one isolated event.

This integration boundary catches defects that the unit test cannot:

  • The input has no working change handler.
  • The client serializes the wrong query parameter.
  • The response parser reads the wrong property.
  • Loading state is never rendered or never cleared.
  • Filtered products are not passed to SearchResults.
  • A required child component is missing from the rendered tree.

The intercepted request means the test does not prove that a real backend accepts the request or returns the documented response. A team using a narrower taxonomy may call this a component test. Under the boundary defined here, it is a frontend integration test because several real production parts cooperate inside the test.

How to Choose the Right Test

Choose the smallest boundary that can provide the confidence the behavior requires.

Use a unit test when confidence depends on dense logic with many meaningful inputs. Filtering, sorting, validation rules, reducers, parsers, and date transformations often fit this shape. Direct inputs make edge cases quick to express, and a failure usually identifies the broken rule.

Use an integration test when confidence depends on cooperation. Relevant evidence may include:

  • An event reaches the correct handler.
  • State changes cause the correct DOM update.
  • A provider supplies the value a component consumes.
  • A router selects the expected screen.
  • A client serializes input and parses a response.
  • Loading, success, empty, and error states replace one another correctly.

Add both types when they answer different questions. For the autocomplete, unit tests can cover blank queries, malformed entries, capitalization, matching, and result limits. Integration tests can cover one representative success path and one failure path through the rendered interface.

Do not repeat every filtering permutation through the component. That makes the broader tests carry the cost of rendering and request setup without gaining new evidence. Likewise, do not rely only on the unit suite when the feature can fail through wiring.

Five questions expose the right boundary:

  1. What observable behavior needs proof?
  2. Which production collaborators must be real for that proof to mean anything?
  3. What can be replaced without weakening the claim?
  4. If the test fails, will the boundary still give useful diagnostic information?
  5. Is the broader boundary's runtime and setup cost justified by distinct confidence?

For the autocomplete, that cost favors many direct filtering cases and only representative rendered success and failure paths.

A browser environment can matter when confidence depends on behavior that a simulated DOM does not provide accurately enough. That is an environment decision, separate from whether the test is a unit or integration test.

How This Comes Up in Frontend Interviews

Interviewers may ask for tests directly, or they may ask how a solution would be tested. Name the boundary before naming the framework.

For an array utility, use direct input and output examples. The utility is the unit, and cases should cover empty arrays, duplicates, mutation expectations, and invalid values where the contract defines them. The JavaScript coding interview guide has related practice patterns.

For debounce logic, isolate clock-dependent behavior with controlled time. Explain whether callback scheduling alone is under test or whether the test also renders an input that uses the debounce function.

For an accessible modal, broader confidence depends on rendered behavior. Test how the modal opens, which accessible role and name it exposes, how focus behaves, and how the user closes it. A pure state reducer test cannot prove those DOM interactions.

For form validation, unit-test complicated validation rules. Add an integration test for submission, visible error messages, and the connection between fields and the error summary. The signup form exercise provides a concrete UI task where that distinction matters.

For data fetching, keep production parsing and UI state inside the integration boundary while intercepting the request. State plainly that this does not verify the live service.

For router-driven screens, render the real route configuration when navigation is part of the claim. Mocking the destination component may be appropriate if only route selection matters, but it narrows what the test proves.

A strong interview explanation sounds like this: “I would unit-test the transformation because it has many edge cases. I would add one integration test with the real component, state, and client because the feature can still fail through event wiring, response parsing, or loading-state transitions.”

Browser-based exercises such as the Test Runner question help practice the mechanics. UIReady Premium 1-Year access is relevant when a structured practice plan across JavaScript and UI testing tasks would be useful.

Common Testing Mistakes

Testing private implementation details creates brittle tests. An assertion about a state variable, internal method, or exact child call may fail after a harmless refactor even though the user-visible behavior remains correct. Prefer inputs, accessible DOM output, returned values, and boundary calls that belong to the public contract.

Excessive module mocking can erase the collaboration the test claims to cover. If a component test mocks the client, parser, hook, provider, and child component, it may be a valid unit test of the parent. It is not evidence that those parts work together.

Rendering a component does not automatically make a test an integration test. A DOM is a test medium, not a scope definition.

Calling a production service from routine tests introduces external state and availability into the result. Intercept the boundary for frontend integration tests. Reserve broader service coverage for a test environment designed for it.

Duplicating identical cases at every layer adds maintenance without distinct confidence. Put exhaustive logic cases at the narrow boundary and representative workflows at the broader one.

Finally, avoid spending the discussion on labels alone. Say which production parts are real, which boundaries are replaced, what behavior is observed, and what the test cannot prove.

Frequently asked questions

What is the difference between unit testing and integration testing?
A unit test checks one deliberately chosen unit while controlling or excluding its collaborators. An integration test checks whether two or more real application parts work together across a boundary you chose.
Is every React component test an integration test?
No. A component test can be a unit test when its children, providers, and services are replaced or excluded. It becomes an integration test when it deliberately exercises cooperation among real application parts.
Does rendering with jsdom make a test an integration test?
No. jsdom supplies simulated browser APIs, but it does not decide which production collaborators the test exercises. The chosen boundary determines the classification.
Should frontend applications have both unit and integration tests?
Usually, because the two types answer different questions. Unit tests give precise feedback about dense logic, while integration tests catch mistakes in wiring, state transitions, DOM behavior, providers, and request handling.