Comparisons

Unit vs Integration Tests: AI Rules for Each

AI generates the wrong test type for the code. Pure functions get integration tests with database mocks. API routes get unit tests without HTTP context. Guide to AI rules that match the right test type to the right code.

7 min read·June 12, 2025

Database mocks on a pure add() function. No HTTP context on an API route test. The AI picks the wrong test type without rules.

Unit test rules, integration test rules, when to mock vs use real, test organization, and behavior-based naming

The Wrong Test Type Problem

Without testing rules: the AI defaults to a generic testing approach for every piece of code. Pure utility function (add two numbers): the AI generates a test with database mocks and HTTP setup (over-engineered — a simple expect(add(1, 2)).toBe(3) would suffice). API route handler (POST /api/users): the AI generates a test that imports the handler function and calls it directly without HTTP context (under-tested — the middleware, validation, and response formatting are not tested). The AI does not distinguish: which code needs which type of test.

The correct mapping: unit tests for pure logic (functions that take inputs and return outputs with no side effects — utilities, transformers, validators, pure hooks). Integration tests for code with dependencies (API routes that involve HTTP, database, and middleware; database queries that need a real or test database; components that render with data fetching). The test type matches: the code's dependency profile. Pure code: unit test (fast, no mocks). Code with dependencies: integration test (slower, real or mocked dependencies).

This article provides: the AI rules that tell the test type to generate based on the code being tested, when to mock vs use real dependencies, and the test file organization pattern. The goal: every AI-generated test is the right type for the code, testing the right level of behavior. No over-engineered unit tests. No under-tested integration scenarios.

Rules for Unit Tests: Pure Functions and Utilities

When to generate unit tests: "Generate unit tests for: utility functions (formatCurrency, slugify, calculateDiscount), pure transformation functions (mapUserToDTO, parseApiResponse), validation functions (isValidEmail, validateFormInput — separate from Zod schemas which are tested implicitly), custom hooks with no side effects (useDebounce logic, useMediaQuery logic), and type guards (isUser, isAdmin). Unit tests: no mocks, no database, no HTTP, no external services. Test: input → output. That is all."

Unit test pattern rules: "Unit test format: describe(functionName, () => { it('returns X when given Y', () => { expect(fn(input)).toBe(expected); }); }). Test: happy path (normal input → expected output), edge cases (null, undefined, empty string, zero, negative numbers, very long strings), error cases (invalid input → expected error or error return), and boundary conditions (exactly at the limit, one below, one above). For pure functions: 5-10 test cases cover most behaviors. No beforeEach setup needed for pure functions."

The unit test rule prevents: the AI from adding database mocks to a test for formatCurrency (no database is involved), creating HTTP test setup for a pure validation function (no HTTP context), or importing and mocking 5 modules for a function that depends on nothing. The rule tells the AI: if the code is pure (no side effects, no dependencies), the test should be pure (no mocks, no setup, just input and output). The simplest test: is the correct test for pure code.

  • Unit test for: utilities, transformers, validators, pure hooks, type guards
  • No mocks, no database, no HTTP, no external services — pure input → output testing
  • Test cases: happy path, edge cases (null, empty, zero), error cases, boundary conditions
  • Format: describe(fn, () => { it('returns X when Y', () => expect(fn(input)).toBe(expected)) })
  • 5-10 test cases per pure function covers most behaviors. No beforeEach needed
💡 Pure Code = Pure Tests

If the code is pure (no side effects, no dependencies): the test should be pure (no mocks, no setup, just input and output). expect(add(1, 2)).toBe(3). Five lines. The AI without rules: adds database mocks, HTTP setup, and beforeEach blocks to this test. The rule: 'Unit tests for pure functions: no mocks. Test input → output.'

Rules for Integration Tests: APIs and Database

When to generate integration tests: "Generate integration tests for: API route handlers (test the full HTTP request → response cycle including middleware, validation, and response formatting), database query functions (test with a real test database or in-memory database, not mocked queries), components with data fetching (test with MSW for API mocking, not mocked fetch), and middleware chains (test the full middleware pipeline, not individual middleware in isolation). Integration tests: exercise the interaction between components, not just individual functions."

Integration test pattern rules: "API route test: const response = await request(app).post('/api/users').send({ name: 'Alice', email: 'alice@example.com' }).expect(201); expect(response.body.data.name).toBe('Alice'). Database test: beforeEach: seed test data. afterEach: clean up. Test: call the query function, assert the database state. Component test: render(<UserProfile userId='123' />); await screen.findByText('Alice'); — MSW intercepts the API call and returns test data."

The integration test rule prevents: the AI from testing an API route by importing and calling the handler function directly (misses middleware, validation, and HTTP context), mocking the database in a database query test (defeats the purpose — you are testing whether the query works, not whether the mock returns the right data), or testing a data-fetching component without MSW (the fetch fails because no server is running). Integration tests: test the integration. Mocking the integration away: eliminates the value of the test.

  • Integration test for: API routes (full HTTP cycle), database queries (real DB), data-fetching components (MSW)
  • API test: request(app).post('/api/users').send(data).expect(201) — test the full HTTP pipeline
  • DB test: seed data, call query, assert result, clean up — test with real database, not mocked
  • Component test: render + MSW for API — test real rendering with mocked network, not mocked component
  • Integration tests: test the interaction between parts. Mocking it away: eliminates the test value
⚠️ Mocking the Integration Away Eliminates the Test

AI testing a database query with a mocked database: tests whether the mock returns the right data (it always does — you defined it). Does NOT test: whether the SQL is correct, the indexes work, or the query handles edge cases. Use a real test database for database query tests. Mock the integration = test nothing.

When to Mock vs Use Real Dependencies

Mock when: the dependency is external and unreliable (third-party API — mock with MSW, not real API calls), the dependency is slow (email sending, PDF generation — mock to keep tests fast), the dependency has side effects you cannot undo (payment processing, SMS sending — mock to prevent real charges), or the dependency is not available in the test environment (cloud services in local tests — mock the SDK). AI rule: "Mock: external APIs (MSW), email/SMS (mock sender), payments (mock gateway), cloud services (mock SDK). Never mock: the database (use a test database), the HTTP layer (use supertest), or the component rendering (use React Testing Library)."

Use real when: the dependency is what you are testing (database queries need a real database to test SQL correctness), the dependency is fast and local (in-process services, local file operations), or the dependency is the integration point (HTTP middleware should be tested with real HTTP, not mocked request objects). AI rule: "Use real: test database (SQLite in-memory or Postgres test DB), HTTP layer (supertest for API routes), component rendering (React Testing Library with jsdom), and file operations (temp directory for test files)."

The mock decision rule prevents: the AI from mocking the database when testing a database query (you are testing whether the SQL is correct — the mock always returns what you told it to), using real payment APIs in tests (charges real money), or mocking React rendering in component tests (you are testing what the user sees — mocking the render eliminates the test). The rule provides: a clear decision framework. Mock: external, slow, side-effect-prone. Real: fast, local, and the actual thing being tested.

  • Mock: external APIs (MSW), email/SMS, payments, cloud services — unreliable, slow, side effects
  • Real: test database, HTTP layer (supertest), component rendering (RTL), file operations
  • Never mock: what you are testing. DB query test with mocked DB: tests the mock, not the query
  • Never use real: payment APIs, SMS in tests. Real charges, real messages — mock always
  • Decision: external + slow + side effects = mock. Local + fast + being tested = real
ℹ️ Mock External, Real Local

Mock: external APIs (MSW), payment processing, email sending — unreliable, slow, has side effects. Real: database (test DB), HTTP layer (supertest), component rendering (RTL). The decision: external + slow + side effects = mock. Local + fast + being tested = real. One decision framework for every test.

Test File Organization Rules

AI rule for test organization: "Test files: *.test.ts alongside the source file (co-located). UserService.ts and UserService.test.ts in the same directory. Not: tests/ directory with mirrored folder structure (hard to find, drifts from source). Exception: E2E tests in e2e/ or tests/e2e/ directory (they test the entire application, not one file). Integration test fixtures: in __fixtures__/ or test-utils/ directories. Shared test helpers: in src/test/ or tests/helpers/."

Why co-location matters for AI: when the AI generates a new file (UserService.ts), it should also generate the test file in the same directory (UserService.test.ts). With co-located rules: the AI creates both files together. With tests/ directory rules: the AI must navigate to a different directory (tests/services/UserService.test.ts) which is: harder to generate correctly (the path must mirror the source structure) and more likely to be created in the wrong location (the AI may put it in tests/UserService.test.ts instead of tests/services/UserService.test.ts).

Test naming rules: "Test file names match source: Component.tsx → Component.test.tsx. Hooks: useAuth.ts → useAuth.test.ts. Utilities: formatCurrency.ts → formatCurrency.test.ts. API routes: route.ts → route.test.ts. Describe block: the function or component name. It block: describes the behavior being tested ('returns formatted currency for USD', 'renders user name when loaded', 'returns 404 when user not found'). Behavior-based test names: describe WHAT it does, not HOW."

  • Co-located: UserService.ts + UserService.test.ts in the same directory
  • Exception: E2E tests in e2e/ or tests/e2e/ (test the app, not one file)
  • Naming: Component.test.tsx matches Component.tsx — AI creates both together
  • Describe: function/component name. It: behavior ('returns X when Y', not 'calls fn with Z')
  • Behavior-based names: WHAT it does, not HOW it does it

Test Type Rule Summary

Summary of AI rules for unit vs integration tests.

  • Unit: pure functions, utilities, validators, type guards — no mocks, no DB, input → output
  • Integration: API routes (full HTTP), DB queries (real DB), components (RTL + MSW)
  • Mock: external APIs, email, payments, cloud — unreliable, slow, side effects
  • Real: database, HTTP layer, rendering, file ops — local, fast, being tested
  • Never mock what you are testing: DB mock in DB test = tests the mock, not the query
  • Co-located: test file alongside source. E2E: separate e2e/ directory
  • Test names: behavior-based ('returns X when Y'), not implementation ('calls fn with Z')
  • AI generates wrong type without rules: pure functions get DB mocks, API routes get no HTTP context