Skip to main content

Command Palette

Search for a command to run...

Testing Philosophy — What to Test, What Not to Test, and Why Most Tests Are Wrong

Day 31 of 40 — React System Design Series

Updated
6 min read
Testing Philosophy — What to Test, What Not to Test, and Why Most Tests Are Wrong
R

Hi, I’m Richa — a Senior Frontend Engineer with 5+ years of experience building scalable, production-grade web interfaces for enterprise and consumer applications. I work primarily with React, TypeScript, and modern frontend architectures, focusing on component systems, performance, and maintainability. Most of my experience comes from building real-world products in regulated domains like banking and insurance, where clarity, reliability, and long-term ownership matter more than quick demos. Through this blog, I write about frontend engineering fundamentals, scalable UI design, problem-solving, and the lessons I’ve learned working on large codebases. My goal is to share practical insights — not shortcuts — for developers who want to grow strong engineering foundations. I also mentor early-career developers and strongly believe that curiosity, asking the right questions, and understanding why something works are more important than memorizing tools. If you’re serious about improving as an engineer, you’re in the right place.


Hey everyone, Richa here! 👋

testing gif

Day 31. Week 7. Testing week.

I want to start this week with an honest confession.

For the first three years of my React career, I wrote tests that felt productive but weren't. Tests that tested implementation details. Tests that broke every time I refactored. Tests that gave me false confidence and then let real bugs through anyway.

It wasn't until I truly internalised Kent C. Dodds' testing philosophy — and the principle behind React Testing Library — that my tests actually started catching bugs and surviving refactors.

This week we fix testing. Starting with the mindset.


The wrong question — "what should I test?"

Most developers ask: "What code should I test?"

The right question is: "What behaviour should I verify?"

The difference seems subtle. It isn't.

Testing code → you write tests tied to implementation details. When you refactor (same behaviour, different code), tests break. You spend time updating tests, not adding features.

Testing behaviour → you write tests that verify what the user experiences. When you refactor, tests still pass. Because the user experience didn't change.


The testing trophy — not a pyramid

You've probably heard of the "testing pyramid": lots of unit tests at the bottom, fewer integration tests in the middle, a few E2E tests at the top.

For frontend React apps, this is the wrong shape. Kent C. Dodds introduced the testing trophy:

        /\
       /  \         E2E (few)
      /----\
     /      \       Integration (most)
    /--------\
   /          \     Unit (some)
  /------------\
 /              \   Static analysis (always — TypeScript, ESLint)
/________________\

Static analysis (TypeScript, ESLint) — catches typos, type errors, common mistakes at zero runtime cost. Always.

Unit tests — for pure functions, utilities, complex logic in isolation. Not for React components.

Integration tests — render a component tree, interact with it as a user would, assert on the output. This is where most of your tests should live.

E2E tests — full browser automation. For critical user journeys (login, checkout, key workflows). Slow but highest confidence.

The reason integration tests dominate: they test real behaviour, survive refactors, and catch real bugs. Unit tests for components test implementation — they break on rename and give false confidence.


The guiding principle

"The more your tests resemble the way your software is used, the more confidence they can give you." — Kent C. Dodds

What does this mean in practice?

// ❌ Testing implementation — what internal method was called?
test('calls setUser when form is submitted', () => {
  const setUser = jest.fn();
  render(<LoginForm setUser={setUser} />);
  fireEvent.submit(screen.getByRole('form'));
  expect(setUser).toHaveBeenCalled();
});

// ✅ Testing behaviour — what does the user see?
test('shows the dashboard after successful login', async () => {
  render(<App />);
  await userEvent.type(screen.getByLabelText('Email'), 'richa@example.com');
  await userEvent.type(screen.getByLabelText('Password'), 'password123');
  await userEvent.click(screen.getByRole('button', { name: /sign in/i }));
  expect(await screen.findByText('Welcome back, Richa')).toBeInTheDocument();
});

The second test will still pass after you rename setUser to setCurrentUser, refactor the login form component entirely, or change the internal auth mechanism. The first test breaks on any of those refactors.


What NOT to test

This is as important as knowing what to test.

❌ Don't test implementation details:

  • Internal state values (component.state.isLoading)
  • Private method calls
  • CSS class names (unless they directly affect behaviour)
  • Component instance methods

❌ Don't test third-party libraries:

  • Don't test that React Query fetches data — it has its own test suite
  • Don't test that Zustand updates the store correctly — trust the library
  • Test your code's integration with them, not their internals

❌ Don't test things that TypeScript already catches:

  • If TypeScript prevents passing a string where a number is required, you don't need a test for it
  • Static analysis and tests are complementary — don't duplicate what TypeScript does

❌ Don't test trivial code:

  • A component that just renders some props into HTML — no meaningful behaviour to test
  • A simple utility that wraps a built-in (e.g., const add = (a, b) => a + b)

What TO test

✅ User interactions with side effects:

  • Form submission — does it call the API? Does it show an error if it fails?
  • Button clicks — does it navigate? Does it open a modal?
  • Keyboard interactions — does tab order work? Does Escape close the modal?

✅ Conditional rendering:

  • Loading state — does the skeleton appear?
  • Error state — does the error message appear?
  • Empty state — does the empty state appear?
  • Permission-based UI — does the Delete button only appear for admins?

✅ Complex business logic in utility functions:

  • Price calculation with discounts, taxes
  • Form validation rules
  • Data transformation utilities

✅ Critical user journeys (E2E):

  • Login → land on dashboard
  • Add to cart → checkout → order confirmation
  • Create post → post appears in feed

The confidence gap — why coverage % is meaningless

100% code coverage does not mean your app works. It means every line was executed during tests. These two things are completely different.

// 100% coverage. Zero confidence.
function add(a: number, b: number) { return a + b; }

test('add function exists', () => {
  expect(add).toBeDefined();  // executes the function — 100% coverage!
});

Coverage tells you which code ran. It says nothing about whether the behaviour is correct.

The metric that matters: do your tests catch real bugs before users do? If yes — they're good tests. If no — fix the tests, not the coverage number.


The testing setup — Vitest + Testing Library

For a Vite + React project:

npm install --save-dev vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
    globals: true,
  },
});
// src/test/setup.ts
import '@testing-library/jest-dom';
// package.json
{
  "scripts": {
    "test": "vitest",
    "test:ui": "vitest --ui",
    "test:coverage": "vitest --coverage"
  }
}

This setup gives you:

  • vitest — fast test runner (Vite-native, much faster than Jest for Vite projects)
  • @testing-library/react — render components and query the DOM
  • @testing-library/user-event — simulate real user interactions (better than fireEvent)
  • @testing-library/jest-dom — extra matchers like toBeInTheDocument, toBeVisible

Today's takeaway

The mindset shift that changed my testing:

Before: "I need to test this component."

After: "I need to verify this behaviour — what a user does and what they see."

When you make that shift, your tests:

  • Survive refactors
  • Catch real bugs
  • Give genuine confidence
  • Don't slow down development

The rest of this week is mechanics — RTL, hooks testing, async testing, Playwright. But the mechanics are only useful if the mindset is right first.

testing done gif

See you tomorrow for Day 32 — React Testing Library: the right way to test components.


Day 30 — Design a Shopping Cart Day 32 — React Testing Library


Part of the React System Design Series — 40 days, one topic per day.

1 views

React System Design — Learning in Public

Part 31 of 34

I'm a React developer with 5.5 years of experience who spent 2 years in Lit.js — and slowly forgot how React really works at a senior level. This series is my public journey back. Every day I cover one topic: state management, authentication, performance, system design interviews, testing — the real stuff that separates a mid-level dev from a senior. Written honestly, with real code, for developers at every level.

Up next

Testing Custom Hooks — renderHook and What to Actually Assert

Day 33 of 40 — React System Design Series