30% offEnding soon

Unit Tests vs Integration Tests for Frontend Apps

21 min read

A unit test checks one chosen behavior with meaningful collaborators replaced, while a frontend integration test checks multiple real application pieces working together across an explicitly stated boundary.

Unit Tests vs Integration Tests: The Short Answer

The unit might be a function, a hook, a class, or a component. It does not have to be one function. What matters is that the test draws a small boundary around the behavior and excludes or replaces collaborators that would otherwise expand that boundary, when such collaborators exist.

Suppose a search page has a function that removes results that do not match the submitted query. A unit test can call that function with an array and inspect the returned array. It does not need to render React, click a button, or make a request.

A frontend integration test exercises several real application pieces together. It might render the search form, let a user type, submit the form, run the component's state transitions, call the real request module, and display the returned data. The external server can still be replaced at the HTTP boundary.

The testing tool does not settle the classification. Vitest can run both unit and integration tests. Testing Library can render a tiny isolated component or a component tree with providers and routing (Vitest environments, Testing Library setup). A file named Search.test.tsx reveals even less.

The boundary settles the label:

  • If the test keeps only the result-filtering function real, its boundary is a unit.
  • If it keeps the component, state, event handling, request module, and result logic real, its boundary contains an integration.
  • If it drives the application through a browser and production-like system boundary, it moves toward an end-to-end test.

Teams use these terms differently. State the boundary before debating the category, then apply the chosen definition consistently.

The Differences That Matter in Frontend Work

Unit and integration tests make different tradeoffs. Neither type is a substitute for the other.

DimensionUnit testFrontend integration test
ScopeOne chosen behavior, such as filtering search resultsSeveral collaborating behaviors, such as form submission, loading state, requests, and rendering
IsolationReplaces meaningful collaboratorsKeeps the frontend collaborators inside its boundary real
EnvironmentOften Node for pure logic, or an emulated browser when DOM APIs are neededCommonly an emulated browser or real browser because rendered behavior is inside the boundary
DependenciesMay inject or mock a request function, clock, storage adapter, or child componentMay use real components, providers, hooks, and request modules while replacing the external server
SpeedUsually has less setup and less application work per caseUsually performs more setup and work per case
Setup costSmall fixtures and direct calls are often enoughRendering, providers, request handlers, and asynchronous assertions may be required
Failure diagnosisA failure usually points to a narrow behaviorA failure identifies a broken flow, but investigation may span several collaborators
Refactor resistanceCan become coupled to implementation when it asserts calls or private detailsResists internal refactors better when it asserts visible behavior at a stable boundary
ConfidenceShows that an isolated rule handles selected inputsShows that connected frontend pieces produce the expected visible result

A unit test is especially useful for edge cases in deterministic logic. Search ranking, query normalization, validation, bucketing, and data conversion can receive many focused examples without rendering an interface. Problems such as case-sensitive matching or mutation of an input array are easy to locate.

An integration test catches different defects. A correct filter function does not prove that the submit handler calls it. It does not prove that loading text appears, an HTTP error reaches the component, or the result names enter the DOM.

Environment also matters. Vitest uses Node by default. Its jsdom environment supplies an emulated browser environment and browser APIs. DOM Testing Library can interact with DOM nodes in that environment or in a real browser (Vitest environments, DOM Testing Library).

Testing Library encourages assertions against behavior that a user can observe (guiding principles). Internal state, lifecycle methods, and child implementation details are poor targets because they can change without altering the interface. This principle is useful in both unit and integration tests, but a wider integration boundary gives the test more internal freedom.

For practice, a focused problem such as A/B Test Bucketing suits unit tests around deterministic rules. A rendered exercise such as the Signup Form creates more opportunities to test events, validation state, and visible errors together.

One Search UI, Tested Both Ways

The following React and TypeScript files form one canonical implementation. The unit test isolates the search-result rule. The integration test renders the same search behavior and keeps the frontend modules real through the HTTP request boundary.

One search UI, two test boundariesUNIT TESTisolate the rulefilter resultruleReactoutHTTPoutINTEGRATIONkeep pieces realSearch componentReact stateRequest moduleResult rule
The same feature can be tested through a narrow or wide boundary.

The search-result unit has no React dependency

filterSearchResults trims the query, performs locale-dependent lowercased matching, and returns a new array. The original array remains unchanged because filter creates another array.

// src/search-results.ts
export type SearchResult = {
  id: string;
  title: string;
};

export function filterSearchResults(
  results: SearchResult[],
  query: string,
): SearchResult[] {
  const normalizedQuery = query.trim().toLocaleLowerCase();

  if (normalizedQuery === '') {
    return [];
  }

  return results.filter((result) =>
    result.title.toLocaleLowerCase().includes(normalizedQuery),
  );
}

The unit test calls the function directly. React, the DOM, fetch, and the request module all sit outside its boundary.

// src/search-results.test.ts
import { describe, expect, it } from 'vitest';
import { filterSearchResults, type SearchResult } from './search-results';

describe('filterSearchResults', () => {
  const results: SearchResult[] = [
    { id: 'react-textarea', title: 'React Textarea' },
    { id: 'vue-form', title: 'Vue Form Validation' },
    { id: 'react-state', title: 'React State Updates' },
  ];

  it('matches titles without depending on case or outer whitespace', () => {
    expect(filterSearchResults(results, '  REACT ')).toEqual([
      { id: 'react-textarea', title: 'React Textarea' },
      { id: 'react-state', title: 'React State Updates' },
    ]);
  });

  it('returns no results for a blank query', () => {
    expect(filterSearchResults(results, '   ')).toEqual([]);
  });

  it('does not mutate the source array', () => {
    const originalOrder = results.map((result) => result.id);

    filterSearchResults(results, 'react');

    expect(results.map((result) => result.id)).toEqual(originalOrder);
  });
});

These cases diagnose the result rule well. They do not show whether the search form uses that rule.

The request module keeps HTTP details out of the component

The request module owns the endpoint, query encoding, response check, JSON parsing, and compile-time type assertion.

// src/search-api.ts
import type { SearchResult } from './search-results';

export async function fetchSearchResults(
  query: string,
): Promise<SearchResult[]> {
  const response = await fetch(
    new URL(
      `/api/search?q=${encodeURIComponent(query)}`,
      window.location.origin,
    ),
  );

  if (!response.ok) {
    throw new Error('Search request failed');
  }

  return (await response.json()) as SearchResult[];
}

The component owns the form, state transitions, accessible status messages, and visible results. It calls the real request module and the same filterSearchResults function covered by the unit test.

The component is a branching state machineIdleLoadingVisibleresultsEmptystateErroralertsubmitmatchesnonefailure
Submitting a search moves the UI through loading to one visible outcome.
// src/Search.tsx
import { FormEvent, useState } from 'react';
import { fetchSearchResults } from './search-api';
import {
  filterSearchResults,
  type SearchResult,
} from './search-results';

export function Search() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<SearchResult[]>([]);
  const [hasSearched, setHasSearched] = useState(false);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState('');

  async function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setHasSearched(true);
    setIsLoading(true);
    setError('');
    setResults([]);

    try {
      const responseResults = await fetchSearchResults(query);
      setResults(filterSearchResults(responseResults, query));
    } catch {
      setError('Search failed. Try again.');
    } finally {
      setIsLoading(false);
    }
  }

  return (
    <section aria-labelledby="search-heading">
      <h2 id="search-heading">Search interview guides</h2>

      <form onSubmit={handleSubmit}>
        <label htmlFor="search-query">Search terms</label>
        <input
          id="search-query"
          name="query"
          value={query}
          onChange={(event) => setQuery(event.target.value)}
        />
        <button type="submit" disabled={isLoading}>
          Search
        </button>
      </form>

      {isLoading && <p role="status">Loading results...</p>}
      {error && <p role="alert">{error}</p>}

      {!isLoading && !error && hasSearched && results.length === 0 && (
        <p>No results found.</p>
      )}

      {results.length > 0 && (
        <>
          <h3>Results</h3>
          <ul>
            {results.map((result) => (
              <li key={result.id}>{result.title}</li>
            ))}
          </ul>
        </>
      )}
    </section>
  );
}

MSW replaces the external server boundary

Vitest recommends Mock Service Worker for request mocking. MSW intercepts requests without requiring a change to the application code (Vitest request mocking). The test setup fails on any request that lacks an explicit handler, which prevents an accidental external request from passing unnoticed.

// src/test/server.ts
import { setupServer } from 'msw/node';

export const server = setupServer();
// src/test/setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterAll, afterEach, beforeAll } from 'vitest';
import { server } from './server';

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
  cleanup();
  server.resetHandlers();
});
afterAll(() => server.close());
// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'jsdom',
    environmentOptions: {
      jsdom: {
        url: 'http://localhost/',
      },
    },
    setupFiles: ['./src/test/setup.ts'],
  },
});

The integration test uses user-event because it models fuller interactions and checks visibility and interactability. fireEvent dispatches individual DOM events, which is useful when that exact event is the subject of a test (user-event introduction).

// src/Search.integration.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { delay, http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';
import { Search } from './Search';
import { server } from './test/server';

describe('Search integration', () => {
  it('submits a query, shows loading, and renders matching results', async () => {
    server.use(
      http.get('/api/search', async ({ request }) => {
        const url = new URL(request.url);

        expect(url.searchParams.get('q')).toBe('react');
        await delay(100);

        return HttpResponse.json([
          { id: 'react-textarea', title: 'React Textarea' },
          { id: 'vue-form', title: 'Vue Form Validation' },
        ]);
      }),
    );

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

    await user.type(screen.getByLabelText('Search terms'), 'react');
    await user.click(screen.getByRole('button', { name: 'Search' }));

    expect(screen.getByRole('status')).toHaveTextContent(
      'Loading results...',
    );
    expect(
      await screen.findByText('React Textarea'),
    ).toBeInTheDocument();
    expect(
      screen.queryByText('Vue Form Validation'),
    ).not.toBeInTheDocument();
    expect(screen.queryByRole('status')).not.toBeInTheDocument();
  });

  it('shows an empty state when no result matches', async () => {
    server.use(
      http.get('/api/search', () => HttpResponse.json([])),
    );

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

    await user.type(screen.getByLabelText('Search terms'), 'svelte');
    await user.click(screen.getByRole('button', { name: 'Search' }));

    expect(
      await screen.findByText('No results found.'),
    ).toBeInTheDocument();
  });

  it('shows an accessible error when the request fails', async () => {
    server.use(
      http.get(
        '/api/search',
        () =>
          new HttpResponse(null, {
            status: 500,
          }),
      ),
    );

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

    await user.type(screen.getByLabelText('Search terms'), 'react');
    await user.click(screen.getByRole('button', { name: 'Search' }));

    expect(await screen.findByRole('alert')).toHaveTextContent(
      'Search failed. Try again.',
    );
    expect(
      screen.queryByText('No results found.'),
    ).not.toBeInTheDocument();
  });
});

This is a frontend integration test under the boundary defined earlier. It integrates the rendered component, React state, user interaction, request module, result-filtering logic, loading state, result rendering, empty state, and error handling. It replaces the external server at the request boundary.

A team with a narrower definition may call it a component test. That label is workable if the team also records the boundary. Calling it a component test does not reduce the number of real frontend collaborators involved.

The same approach maps to Vue or Svelte. Render the real component, interact through its accessible DOM, keep the state and request modules real, and intercept the external request. The framework changes, but the boundary rule does not.

Mock the edge, not the collaborationREAL FRONTEND BOUNDARYComponentStateResultruleRequestmoduleMSWrealserverHTTPintercepted here
MSW replaces the server while the frontend collaboration remains real.

Readers who want to practise the component and testing techniques in a browser can pair this example with React JavaScript interview questions or the React Textarea guide. The annual UIReady Premium plan is relevant when a longer study plan and the full practice library are useful.

Where Common Frontend Tests Fit

A test category follows its effective boundary, not its framework, suffix, or runner.

Test exampleReal pieces inside the boundaryReplaced boundaryUseful label
Pure utility called with arraysUtility logicNone; the test calls the utility directlyUnit test
Custom hook with an injected request functionHook state and transitionsRequest collaboratorUnit test
Component with mocked children and mocked serviceParent rendering and event logicChildren and serviceIsolated component unit test
Component tree with real context providersComponents, context, and stateExternal systemsFrontend integration test
Router interaction with real route configurationComponents, links, and route stateServer or browser navigation boundaryFrontend integration test
Component reading and writing localStorage in an emulated browserComponent, serialization, and storage API behaviorFull browser and external systemsFrontend integration test
Rendered flow with an MSW request handlerComponents, state, request client, and response handlingExternal serverFrontend integration test
Test against a running frontend and real APIFrontend and server collaborationAny systems explicitly excluded by setupBroad integration or end-to-end test, depending on the entry point
Browser-driven user flow through a production-like applicationBrowser, application, routing, and included servicesOnly systems excluded by the environmentEnd-to-end test

A custom-hook test is not automatically a unit test. A hook rendered with a real provider, router, storage adapter, and request module has a wider boundary than a hook receiving one injected mock function.

A rendered component is not automatically an integration test either. If every child, hook, and service is mocked, the test may isolate the parent component's behavior. The DOM is only the observation surface.

The DOM is only the observation surfaceRendered DOMRendered DOMParentParentMOCKMOCKchildserviceChildServicereal connections
Rendering a component does not reveal how much of the application is real.

Router and storage tests need the same explicit accounting. A memory-backed router can still integrate real route configuration and component behavior. A localStorage test in jsdom can integrate serialization and restoration logic without claiming that it has covered every real-browser condition.

This classification is useful during JavaScript coding interview practice because it explains what a passing test proves. The Test Runner exercise also provides a focused setting for thinking about test boundaries and failure reporting.

Mocking Without Erasing the Integration

A test double is a controlled replacement for a collaborator. Stubs return prepared values. Spies record interactions. Fakes provide a working but simplified implementation. The exact vocabulary varies, so the replacement and its purpose matter more than the label.

For the search example, several replacement points are possible:

  • An injected function mock replaces the request dependency before HTTP code runs. This is useful when the component alone is the chosen unit.
  • A child-component mock removes a child tree from the boundary. This can isolate parent coordination, but it cannot show that parent props produce the correct child output.
  • A request-client mock replaces the application's request module. It gives direct control over success and failure, but it also removes URL construction, response checks, and data handoff from the test.
  • An MSW handler replaces the external server at the request boundary. The component and request module still collaborate through the same observable request interface used by the application.

The widest mock is not automatically the safest choice. If a test mocks the child component, hook, request client, and result function, it may reproduce assumptions about how the component was written. Such a test can pass while the real pieces fail to connect.

Replace the smallest external boundary that makes the test deterministic and useful. For a unit test, that boundary may sit directly beside the chosen behavior. For a frontend integration test, request interception often preserves more application collaboration than mocking the request module.

Unit, Integration, or End-to-End?

An end-to-end test drives a flow through a browser and a deployed or production-like application boundary. It can cover browser behavior, routing, layout, frontend code, and whichever services the test environment includes.

A simulated-DOM integration test has a smaller boundary. The search test above runs the frontend collaboration in Vitest's jsdom environment and replaces the server. It does not claim to prove real layout, browser navigation, or compatibility across browser engines.

Playwright Test supports Chromium, WebKit, and Firefox across major desktop operating systems, locally or in continuous integration (Playwright installation). That makes it suitable for selected flows where real browser behavior is part of the risk. Testing a visible interface still does not automatically make a test end to end.

The testing pyramid is a budgeting idea, not a fixed ratio. Keep many focused checks where small boundaries give fast, precise feedback. Add integration tests where collaboration carries meaningful risk. Reserve broader browser-driven tests for flows whose value depends on the real application boundary.

Budget breadth against cost and diagnosisSELECTEDE2E FLOWCONNECTED UI FLOWSintegration testsFOCUSED RULES + EDGE CASESmany precise unit casesmore setupwider scope
Spend most cases on focused checks and broaden the boundary where risk justifies it.

A mixed frontend suite might test search filtering as a unit, the rendered search flow as an integration, and one critical navigation-to-search journey in a browser. Each layer answers a different question.

How to Explain Your Choice in an Interview

Explain the risk first, then the boundary.

Start with what could failWHICH RISKMATTERS?one rule,many inputspieces connectincorrectlyreal browserbehaviorUNITINTEGRATIONE2E
Choose the test boundary by tracing the risk it must catch.

For a unit test:

I isolated the result-filtering rule because it has several input edge cases and no browser dependency. The small boundary gives precise failures and lets me cover blank, case-insensitive, and nonmatching queries directly.

For an integration test:

I rendered the search component with its real state, request module, and result logic because the main risk is collaboration. I replaced only the external server with an MSW handler, then asserted the loading, result, empty, and error states through the DOM.

For a mixed suite:

I use unit tests for deterministic branches and integration tests for user-visible connections among events, state, requests, and rendering. I would add a browser test only where routing, layout, or real-browser behavior changes the confidence I get.

That answer names the behavior, risk, boundary, feedback speed, and diagnostic value. It also avoids claiming that one test type is universally better.

Frequently asked questions

What is the difference between unit tests and integration tests?
A unit test isolates one chosen behavior and replaces meaningful collaborators. An integration test keeps multiple application pieces real so it can verify that they work together across a stated boundary.
Is a React component test a unit test or an integration test?
It depends on the test boundary. A component tested with mocked children and services may be a unit test, while a rendered component using real state, request code, and providers may be an integration test.
Should frontend apps have both unit and integration tests?
Most frontend apps benefit from both because the test types answer different questions. Unit tests give focused feedback about logic, while integration tests catch broken connections among components, state, events, storage, routing, and requests.
Does MSW turn a component test into an integration test?
MSW alone does not determine the category. A test is reasonably called a frontend integration test when it keeps the frontend modules real and intercepts only the external request boundary, although some teams use the narrower label component test.