सामग्री पर जाएं
·33 मिनट पढ़ने का समय

Vitest Testing Guide: Zero से Production Confidence तक

Unit tests, integration tests, Testing Library से component tests, mocking strategies, coverage configuration, और वो testing philosophy जो actually बेहतर software produce करती है।

साझा करें:X / TwitterLinkedIn

ज़्यादातर teams tests वैसे लिखती हैं जैसे ज़्यादातर लोग exercise करते हैं: पता है करना चाहिए, guilt feel होती है जब नहीं करते, और जब finally करते हैं तो पहले दिन इतना push करते हैं कि शुक्रवार तक छोड़ देते हैं। Codebase में कुछ shallow snapshot tests बिखरे होते हैं जो हर CSS class change पर break होते हैं, कुछ integration tests जिन पर कोई trust नहीं करता, और README में coverage badge जो झूठ बोलता है।

मैं दोनों sides पर रहा हूं। Zero tests के साथ projects ship किए और हर deploy पर पसीना आया। ऐसी teams पर भी रहा जिन्होंने 100% coverage chase किया और features लिखने से ज़्यादा time tests maintain करने में बिताया। दोनों काम नहीं करते। जो काम करता है वो है सही tests लिखना, सही जगहों पर, सही tools के साथ।

Vitest ने मेरी testing के बारे में सोच बदल दी। इसलिए नहीं कि नए concepts invent किए — fundamentals Kent Beck के दशकों पहले लिखने से नहीं बदले। बल्कि इसलिए कि इसने इतना friction remove किया कि tests लिखना chore नहीं development loop का हिस्सा लगने लगा। जब आपका test runner आपके dev server जितना तेज़ है और same config use करता है, तो बहाने खत्म हो जाते हैं।

यह post testing के बारे में वो सब कुछ है जो मैं जानता हूं Vitest के साथ, initial setup से लेकर उस philosophy तक जो इसे worthwhile बनाती है।

Jest से Vitest क्यों#

अगर आपने Jest use किया है, तो Vitest की ज़्यादातर API आपको पहले से पता है। ये design से है — Vitest API level पर Jest-compatible है। describe, it, expect, beforeEach, vi.fn() — सब काम करता है। तो switch क्यों करें?

Native ESM Support#

Jest CommonJS के लिए बनाया गया था। ये ESM handle कर सकता है, लेकिन configuration, experimental flags, और कभी-कभी दुआ चाहिए। अगर आप import/export syntax use कर रहे हैं (जो आज सब कुछ modern है), तो आपने शायद Jest की transform pipeline से लड़ाई की होगी।

Vitest Vite पर चलता है। Vite ESM को natively समझता है। आपके source code के लिए कोई transform step नहीं — बस काम करता है। ये जितना लगता है उससे ज़्यादा मायने रखता है। पिछले सालों में मैंने जितने Jest issues debug किए उनमें से आधे module resolution से जुड़े थे: SyntaxError: Cannot use import statement outside a module, या mocks काम नहीं करते क्योंकि module पहले से different format में cache हो चुका था।

Dev Server जैसा ही Config#

अगर आपका project Vite use करता है (और अगर आप 2026 में React, Vue, या Svelte app बना रहे हैं, तो शायद करता है), Vitest आपका vite.config.ts automatically पढ़ता है। आपके path aliases, plugins, और environment variables बिना किसी additional configuration के tests में काम करते हैं। Jest के साथ, आप एक parallel configuration maintain करते हैं जो आपके bundler setup के साथ sync में रहनी चाहिए। हर बार जब आप vite.config.ts में path alias जोड़ते हैं, आपको jest.config.ts में corresponding moduleNameMapper जोड़ना याद रखना होगा। ये छोटी बात है, लेकिन छोटी बातें compound होती हैं।

Speed#

Vitest तेज़ है। Meaningfully तेज़। "दो seconds बचाता है" वाला तेज़ नहीं — "काम करने का तरीका बदल देता है" वाला तेज़। ये Vite के module graph का use करके समझता है कि file change से कौन से tests affected हैं और सिर्फ वही run करता है। इसका watch mode वही HMR infrastructure use करता है जो Vite के dev server को instant feel कराता है।

400+ tests वाले project पर, Jest से Vitest पर switch करने से हमारा watch-mode feedback loop ~4 seconds से 500ms से कम हो गया। ये "test pass होने का इंतज़ार करूंगा" और "उंगलियां अभी keyboard पर हैं तभी terminal देख लूंगा" के बीच का फर्क है।

Built-in Benchmarking#

Vitest performance testing के लिए out of the box bench() include करता है। कोई अलग library नहीं चाहिए:

typescript
import { bench, describe } from "vitest";
 
describe("string concatenation", () => {
  bench("template literals", () => {
    const name = "world";
    const _result = `hello ${name}`;
  });
 
  bench("string concat", () => {
    const name = "world";
    const _result = "hello " + name;
  });
});

vitest bench से run करें। ये main event नहीं है, लेकिन benchmark.js install किए और अलग runner wire किए बिना same toolchain में performance testing होना अच्छा है।

Setup#

Installation#

bash
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom

Configuration#

अपने project root में vitest.config.ts बनाएं:

typescript
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
 
export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: "jsdom",
    setupFiles: ["./src/test/setup.ts"],
    include: ["src/**/*.{test,spec}.{ts,tsx}"],
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
    css: false,
  },
});

या, अगर आपके पास पहले से vite.config.ts है, तो इसे extend कर सकते हैं:

typescript
/// <reference types="vitest/config" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
 
export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      "@": "/src",
    },
  },
  test: {
    globals: true,
    environment: "jsdom",
    setupFiles: ["./src/test/setup.ts"],
  },
});

globals: true का फैसला#

जब globals true होता है, आपको describe, it, expect, beforeEach, आदि import करने की ज़रूरत नहीं — ये Jest की तरह हर जगह available हैं। जब false, तो आप explicitly import करते हैं:

typescript
// globals: false
import { describe, it, expect } from "vitest";
 
describe("math", () => {
  it("adds numbers", () => {
    expect(1 + 1).toBe(2);
  });
});

मैं globals: true use करता हूं क्योंकि ये visual noise कम करता है और ज़्यादातर developers जो expect करते हैं उससे match करता है। अगर आप ऐसी team पर हैं जो explicit imports को value करती है, false set करें — यहां कोई गलत जवाब नहीं है।

अगर आप globals: true use करते हैं, TypeScript इन्हें recognize करे इसके लिए अपने tsconfig.json में Vitest के types जोड़ें:

json
{
  "compilerOptions": {
    "types": ["vitest/globals"]
  }
}

Environment: jsdom vs happy-dom vs node#

Vitest आपको per test या globally DOM implementation चुनने देता है:

  • node — कोई DOM नहीं। Pure logic, utilities, API routes, और कुछ भी जो browser को नहीं छूता।
  • jsdom — Standard। पूरा DOM implementation। भारी लेकिन ज़्यादा complete।
  • happy-dom — jsdom से हल्का और तेज़ लेकिन कम complete। कुछ edge cases (जैसे Range, Selection, या IntersectionObserver) काम नहीं कर सकते।

मैं globally jsdom default करता हूं और per-file override करता हूं जब node चाहिए:

typescript
// src/lib/utils.test.ts
// @vitest-environment node
 
import { formatDate, slugify } from "./utils";
 
describe("slugify", () => {
  it("converts spaces to hyphens", () => {
    expect(slugify("hello world")).toBe("hello-world");
  });
});

Setup Files#

Setup file हर test file से पहले run होती है। यहां आप Testing Library matchers और global mocks configure करते हैं:

typescript
// src/test/setup.ts
import "@testing-library/jest-dom/vitest";
 
// IntersectionObserver mock करें — jsdom इसे implement नहीं करता
class MockIntersectionObserver {
  observe = vi.fn();
  unobserve = vi.fn();
  disconnect = vi.fn();
}
 
Object.defineProperty(window, "IntersectionObserver", {
  writable: true,
  value: MockIntersectionObserver,
});
 
// window.matchMedia mock करें — responsive components के लिए ज़रूरी
Object.defineProperty(window, "matchMedia", {
  writable: true,
  value: vi.fn().mockImplementation((query: string) => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: vi.fn(),
    removeListener: vi.fn(),
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    dispatchEvent: vi.fn(),
  })),
});

@testing-library/jest-dom/vitest import आपको toBeInTheDocument(), toHaveClass(), toBeVisible(), और कई अन्य matchers देता है। ये DOM elements पर assertions को readable और expressive बनाते हैं।

अच्छे Tests लिखना#

AAA Pattern#

हर test same structure follow करता है: Arrange, Act, Assert। जब आप explicit comments नहीं भी लिखते, structure visible होना चाहिए:

typescript
it("calculates total with tax", () => {
  // Arrange
  const items = [
    { name: "Widget", price: 10 },
    { name: "Gadget", price: 20 },
  ];
  const taxRate = 0.08;
 
  // Act
  const total = calculateTotal(items, taxRate);
 
  // Assert
  expect(total).toBe(32.4);
});

जब मैं ऐसा test देखता हूं जो arrangement, action, और assertion को method calls की single chain में mix करता है, मुझे पता है fail होने पर इसे समझना मुश्किल होगा। तीन phases को visually distinct रखें भले ही comments न जोड़ें।

Test Naming#

दो schools हैं: it('should calculate total with tax') और it('calculates total with tax')। "should" prefix verbose है बिना information जोड़े। जब test fail होता है, आपको दिखेगा:

FAIL  ✕ calculates total with tax

ये पहले से complete sentence है। "should" जोड़ना सिर्फ noise जोड़ता है। मैं direct form prefer करता हूं: it('renders loading state'), it('rejects invalid email'), it('returns empty array when no matches found')

describe blocks के लिए, test किए जा रहे unit का नाम use करें:

typescript
describe("calculateTotal", () => {
  it("sums item prices", () => { /* ... */ });
  it("applies tax rate", () => { /* ... */ });
  it("returns 0 for empty array", () => { /* ... */ });
  it("handles negative prices", () => { /* ... */ });
});

ज़ोर से पढ़ें: "calculateTotal sums item prices।" "calculateTotal applies tax rate।" अगर sentence काम करता है, naming काम करता है।

एक Assertion Per Test vs Practical Grouping#

Purist rule कहता है एक assertion per test। Practical rule कहता है: एक concept per test। ये अलग-अलग हैं।

typescript
// ये ठीक है — एक concept, multiple assertions
it("formats user display name", () => {
  const user = { firstName: "John", lastName: "Doe", title: "Dr." };
  const result = formatDisplayName(user);
 
  expect(result).toContain("John");
  expect(result).toContain("Doe");
  expect(result).toStartWith("Dr.");
});
 
// ये ठीक नहीं — multiple concepts एक test में
it("handles user operations", () => {
  const user = createUser("John");
  expect(user.id).toBeDefined();
 
  const updated = updateUser(user.id, { name: "Jane" });
  expect(updated.name).toBe("Jane");
 
  deleteUser(user.id);
  expect(getUser(user.id)).toBeNull();
});

पहले test में तीन assertions हैं लेकिन सब एक चीज़ verify करते हैं: display name का format। अगर कोई assertion fail होती है, आपको exactly पता है क्या broken है। दूसरा test तीन अलग tests एक में ठूंसा हुआ है। अगर दूसरी assertion fail होती है, आपको पता नहीं creation broken है या updating, और तीसरी assertion कभी run ही नहीं होती।

Documentation के रूप में Test Descriptions#

अच्छे test suites living documentation का काम करते हैं। Code से अनजान कोई भी test descriptions पढ़कर feature के behavior को समझ सके:

typescript
describe("PasswordValidator", () => {
  describe("minimum length", () => {
    it("rejects passwords shorter than 8 characters", () => { /* ... */ });
    it("accepts passwords with exactly 8 characters", () => { /* ... */ });
  });
 
  describe("character requirements", () => {
    it("requires at least one uppercase letter", () => { /* ... */ });
    it("requires at least one number", () => { /* ... */ });
    it("requires at least one special character", () => { /* ... */ });
  });
 
  describe("common password check", () => {
    it("rejects passwords in the common passwords list", () => { /* ... */ });
    it("performs case-insensitive comparison", () => { /* ... */ });
  });
});

जब ये test suite run होता है, output specification की तरह पढ़ा जाता है। यही goal है।

Mocking#

Mocking आपकी testing toolkit का सबसे powerful और सबसे खतरनाक tool है। अच्छी तरह use करें, तो ये unit under test को isolate करता है और tests fast और deterministic बनाता है। खराब तरीके से use करें, तो ऐसे tests बनते हैं जो code कुछ भी करे pass होते हैं।

vi.fn() — Mock Functions बनाना#

सबसे simple mock एक function है जो अपनी calls record करता है:

typescript
const mockCallback = vi.fn();
 
// इसे call करें
mockCallback("hello", 42);
mockCallback("world");
 
// Calls पर assert करें
expect(mockCallback).toHaveBeenCalledTimes(2);
expect(mockCallback).toHaveBeenCalledWith("hello", 42);
expect(mockCallback).toHaveBeenLastCalledWith("world");

इसे return value दे सकते हैं:

typescript
const mockFetch = vi.fn().mockResolvedValue({
  ok: true,
  json: () => Promise.resolve({ id: 1, name: "Test" }),
});

या successive calls पर different values return करवा सकते हैं:

typescript
const mockRandom = vi.fn()
  .mockReturnValueOnce(0.1)
  .mockReturnValueOnce(0.5)
  .mockReturnValueOnce(0.9);

vi.spyOn() — Real Methods देखना#

जब आप किसी method को replace किए बिना observe करना चाहते हैं:

typescript
const consoleSpy = vi.spyOn(console, "warn");
 
validateInput("");
 
expect(consoleSpy).toHaveBeenCalledWith("Input cannot be empty");
consoleSpy.mockRestore();

spyOn default रूप से original implementation रखता है। ज़रूरत पड़ने पर .mockImplementation() से override कर सकते हैं लेकिन बाद में .mockRestore() से original restore करें।

vi.mock() — Module-Level Mocking#

ये बड़ा वाला है। vi.mock() पूरे module को replace करता है:

typescript
// पूरा module mock करें
vi.mock("@/lib/api", () => ({
  fetchUsers: vi.fn().mockResolvedValue([
    { id: 1, name: "Alice" },
    { id: 2, name: "Bob" },
  ]),
  fetchUser: vi.fn().mockResolvedValue({ id: 1, name: "Alice" }),
}));
 
// Import अब mocked version use करता है
import { fetchUsers } from "@/lib/api";
 
describe("UserList", () => {
  it("displays users from API", async () => {
    const users = await fetchUsers();
    expect(users).toHaveLength(2);
  });
});

Vitest vi.mock() calls को file के top पर automatically hoist करता है। इसका मतलब कोई भी imports run होने से पहले mock ready है। आपको import order की चिंता नहीं करनी।

Automatic Mocking#

अगर आप बस हर export को vi.fn() से replace चाहते हैं:

typescript
vi.mock("@/lib/analytics");
 
import { trackEvent, trackPageView } from "@/lib/analytics";
 
it("tracks form submission", () => {
  submitForm();
  expect(trackEvent).toHaveBeenCalledWith("form_submit", expect.any(Object));
});

Factory function के बिना, Vitest सभी exports auto-mock करता है। हर exported function vi.fn() बन जाता है जो undefined return करता है। ये उन modules के लिए उपयोगी है जिन्हें आप silence करना चाहते हैं (जैसे analytics या logging) बिना हर function specify किए।

Clearing vs Resetting vs Restoring#

ये किसी न किसी point पर सबको confuse करता है:

typescript
const mockFn = vi.fn().mockReturnValue(42);
mockFn();
 
// mockClear — call history reset करता है, implementation रखता है
mockFn.mockClear();
expect(mockFn).not.toHaveBeenCalled(); // true
expect(mockFn()).toBe(42); // अभी भी 42 return करता है
 
// mockReset — call history और implementation दोनों reset करता है
mockFn.mockReset();
expect(mockFn()).toBeUndefined(); // अब 42 return नहीं करता
 
// mockRestore — spies के लिए, original implementation restore करता है
const spy = vi.spyOn(Math, "random").mockReturnValue(0.5);
spy.mockRestore();
// Math.random() अब normally काम करता है

Practice में, tests के बीच call history reset करने के लिए beforeEach में vi.clearAllMocks() use करें। अगर spyOn use कर रहे हैं और originals वापस चाहिए तो vi.restoreAllMocks() use करें:

typescript
beforeEach(() => {
  vi.clearAllMocks();
});
 
afterEach(() => {
  vi.restoreAllMocks();
});

Over-Mocking का खतरा#

ये सबसे important mocking advice है जो मैं दे सकता हूं: हर mock एक झूठ है जो आप अपने test को बता रहे हैं। जब आप dependency mock करते हैं, आप कह रहे हैं "मुझे भरोसा है कि ये चीज़ सही काम करती है, इसलिए मैं इसे simplified version से replace करूंगा।" अगर आपकी assumption गलत है, test pass होता है लेकिन feature broken है।

typescript
// Over-mocked — कुछ useful test नहीं करता
vi.mock("@/lib/validator");
vi.mock("@/lib/formatter");
vi.mock("@/lib/api");
vi.mock("@/lib/cache");
 
it("processes user input", () => {
  processInput("hello");
  expect(validator.validate).toHaveBeenCalledWith("hello");
  expect(formatter.format).toHaveBeenCalledWith("hello");
});

ये test verify करता है कि processInput validate और format call करता है। लेकिन अगर processInput इन्हें गलत order में call करे? अगर ये उनकी return values ignore करे? अगर validation format step को run होने से रोकने वाली थी? Test नहीं जानता। आपने सारा interesting behavior mock कर दिया।

अंगूठे का नियम: boundaries पर mock करें, बीच में नहीं। Network requests, file system access, और third-party services mock करें। अपनी utility functions mock न करें जब तक compelling reason न हो (जैसे चलाना expensive है या side effects हैं)।

React Components Test करना#

Testing Library की Basics#

Testing Library एक philosophy enforce करती है: components को वैसे test करें जैसे users interact करते हैं। Internal state check नहीं, component instances inspect नहीं, shallow rendering नहीं। आप component render करते हैं और DOM के through interact करते हैं, बिल्कुल जैसे user करेगा।

typescript
import { render, screen } from "@testing-library/react";
import { Button } from "@/components/ui/Button";
 
describe("Button", () => {
  it("renders with label text", () => {
    render(<Button>Click me</Button>);
    expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument();
  });
 
  it("applies variant classes", () => {
    render(<Button variant="primary">Submit</Button>);
    const button = screen.getByRole("button");
    expect(button).toHaveClass("bg-primary");
  });
 
  it("is disabled when disabled prop is true", () => {
    render(<Button disabled>Submit</Button>);
    expect(screen.getByRole("button")).toBeDisabled();
  });
});

Queries: getBy vs queryBy vs findBy#

यहीं beginners confuse होते हैं। तीन query variants हैं और हर एक का specific use case है:

getBy* — Element return करता है या not found होने पर throw करता है। जब आप expect करते हैं कि element exist करता है:

typescript
// Button नहीं मिलने पर throw करता है — helpful error से test fail होता है
const button = screen.getByRole("button", { name: "Submit" });

queryBy* — Element return करता है या not found होने पर null। जब आप assert कर रहे हैं कि कुछ present नहीं है:

typescript
// null return करता है — throw नहीं करता
expect(screen.queryByText("Error message")).not.toBeInTheDocument();

findBy* — Promise return करता है। Asynchronously appear होने वाले elements के लिए:

typescript
// Element appear होने तक 1000ms तक wait करता है
const successMessage = await screen.findByText("Saved successfully");
expect(successMessage).toBeInTheDocument();

Accessibility-First Queries#

Testing Library ये queries deliberate priority order में provide करती है:

  1. getByRole — सबसे अच्छी query। ARIA roles use करती है। अगर आपका component role से findable नहीं, इसमें accessibility problem हो सकती है।
  2. getByLabelText — Form elements के लिए। अगर आपके input में label नहीं है, पहले वो fix करें।
  3. getByPlaceholderText — Acceptable लेकिन कमज़ोर। User type करता है तो placeholders गायब हो जाते हैं।
  4. getByText — Non-interactive elements के लिए। Visible text content से ढूंढता है।
  5. getByTestId — आखिरी विकल्प। जब कोई semantic query काम न करे।
typescript
// ये prefer करें
screen.getByRole("textbox", { name: "Email address" });
 
// इसके बजाय
screen.getByPlaceholderText("Enter your email");
 
// और definitely इसके बजाय
screen.getByTestId("email-input");

Ranking arbitrary नहीं है। ये assistive technology page navigate करने के तरीके से match करती है। अगर आप element को उसकी role और accessible name से ढूंढ सकते हैं, screen readers भी कर सकते हैं। अगर आप सिर्फ test ID से ढूंढ सकते हैं, तो accessibility gap हो सकता है।

User Events#

fireEvent use न करें। @testing-library/user-event use करें। फर्क मायने रखता है:

typescript
import userEvent from "@testing-library/user-event";
 
describe("SearchInput", () => {
  it("filters results as user types", async () => {
    const user = userEvent.setup();
    const onSearch = vi.fn();
 
    render(<SearchInput onSearch={onSearch} />);
 
    const input = screen.getByRole("searchbox");
    await user.type(input, "vitest");
 
    // user.type हर CHARACTER के लिए keydown, keypress, input, keyup fire करता है
    // fireEvent.change बस value set करता है — realistic event flow skip करता है
    expect(onSearch).toHaveBeenLastCalledWith("vitest");
  });
 
  it("clears input on escape key", async () => {
    const user = userEvent.setup();
 
    render(<SearchInput onSearch={vi.fn()} />);
 
    const input = screen.getByRole("searchbox");
    await user.type(input, "hello");
    await user.keyboard("{Escape}");
 
    expect(input).toHaveValue("");
  });
});

userEvent पूरी event chain simulate करता है जो real browser fire करेगा। fireEvent.change एक single synthetic event है। अगर आपका component onKeyDown listen करता है या onChange की बजाय onInput use करता है, fireEvent.change उन handlers को trigger नहीं करेगा लेकिन userEvent.type करेगा।

हमेशा beginning में userEvent.setup() call करें और returned user instance use करें। ये proper event ordering और state tracking ensure करता है।

Component Interactions Test करना#

Realistic component test ऐसा दिखता है:

typescript
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TodoList } from "@/components/TodoList";
 
describe("TodoList", () => {
  it("adds a new todo item", async () => {
    const user = userEvent.setup();
    render(<TodoList />);
 
    const input = screen.getByRole("textbox", { name: /new todo/i });
    const addButton = screen.getByRole("button", { name: /add/i });
 
    await user.type(input, "Write tests");
    await user.click(addButton);
 
    expect(screen.getByText("Write tests")).toBeInTheDocument();
    expect(input).toHaveValue("");
  });
 
  it("marks a todo as completed", async () => {
    const user = userEvent.setup();
    render(<TodoList initialItems={[{ id: "1", text: "Buy groceries", done: false }]} />);
 
    const checkbox = screen.getByRole("checkbox", { name: /buy groceries/i });
    await user.click(checkbox);
 
    expect(checkbox).toBeChecked();
  });
 
  it("removes completed items when clear button is clicked", async () => {
    const user = userEvent.setup();
    render(
      <TodoList
        initialItems={[
          { id: "1", text: "Done task", done: true },
          { id: "2", text: "Pending task", done: false },
        ]}
      />
    );
 
    await user.click(screen.getByRole("button", { name: /clear completed/i }));
 
    expect(screen.queryByText("Done task")).not.toBeInTheDocument();
    expect(screen.getByText("Pending task")).toBeInTheDocument();
  });
});

ध्यान दें: कोई internal state inspection नहीं, कोई component.setState() नहीं, कोई implementation details check नहीं। हम render करते हैं, interact करते हैं, और user क्या देखेगा उस पर assert करते हैं। अगर component internal state management useState से useReducer में refactor करता है, ये tests अभी भी pass होते हैं। यही point है।

Async Code Test करना#

waitFor#

जब component asynchronously update होता है, waitFor assertion pass होने तक poll करता है:

typescript
import { render, screen, waitFor } from "@testing-library/react";
 
it("loads and displays user profile", async () => {
  render(<UserProfile userId="123" />);
 
  // Initially loading दिखाता है
  expect(screen.getByText("Loading...")).toBeInTheDocument();
 
  // Content appear होने तक wait करें
  await waitFor(() => {
    expect(screen.getByText("John Doe")).toBeInTheDocument();
  });
 
  // Loading indicator gone होना चाहिए
  expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
});

waitFor callback को हर 50ms (default) पर retry करता है जब तक pass नहीं होता या timeout (1000ms default) नहीं होता। दोनों customize कर सकते हैं:

typescript
await waitFor(
  () => expect(screen.getByText("Done")).toBeInTheDocument(),
  { timeout: 3000, interval: 100 }
);

Fake Timers#

setTimeout, setInterval, या Date use करने वाले code test करते समय, fake timers आपको time control करने देते हैं:

typescript
describe("Debounce", () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });
 
  afterEach(() => {
    vi.useRealTimers();
  });
 
  it("delays execution by the specified time", () => {
    const callback = vi.fn();
    const debounced = debounce(callback, 300);
 
    debounced();
    expect(callback).not.toHaveBeenCalled();
 
    vi.advanceTimersByTime(200);
    expect(callback).not.toHaveBeenCalled();
 
    vi.advanceTimersByTime(100);
    expect(callback).toHaveBeenCalledOnce();
  });
 
  it("resets timer on subsequent calls", () => {
    const callback = vi.fn();
    const debounced = debounce(callback, 300);
 
    debounced();
    vi.advanceTimersByTime(200);
 
    debounced(); // timer reset करें
    vi.advanceTimersByTime(200);
    expect(callback).not.toHaveBeenCalled();
 
    vi.advanceTimersByTime(100);
    expect(callback).toHaveBeenCalledOnce();
  });
});

Important: हमेशा afterEach में vi.useRealTimers() call करें। Tests के बीच leak होने वाले fake timers सबसे confusing failures देते हैं जो आप कभी debug करेंगे।

Fake Timers और Async Rendering के साथ Testing#

Fake timers को React component testing के साथ combine करने में care चाहिए। React की internal scheduling real timers use करती है, इसलिए आपको अक्सर timers advance करने और React updates flush करने दोनों एक साथ करने होंगे:

typescript
import { render, screen, act } from "@testing-library/react";
 
describe("Notification", () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });
 
  afterEach(() => {
    vi.useRealTimers();
  });
 
  it("auto-dismisses after 5 seconds", async () => {
    render(<Notification message="Saved!" autoDismiss={5000} />);
 
    expect(screen.getByText("Saved!")).toBeInTheDocument();
 
    // React updates flush करने के लिए act() के अंदर timers advance करें
    await act(async () => {
      vi.advanceTimersByTime(5000);
    });
 
    expect(screen.queryByText("Saved!")).not.toBeInTheDocument();
  });
});

MSW के साथ API Mocking#

Data fetching test करने के लिए, Mock Service Worker (MSW) network level पर network requests intercept करता है। इसका मतलब आपके component का actual fetch/axios code बिल्कुल वैसा run होता है जैसा production में होगा — MSW बस network response replace करता है:

typescript
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { render, screen } from "@testing-library/react";
 
const server = setupServer(
  http.get("/api/users", () => {
    return HttpResponse.json([
      { id: 1, name: "Alice", email: "alice@example.com" },
      { id: 2, name: "Bob", email: "bob@example.com" },
    ]);
  }),
  http.get("/api/users/:id", ({ params }) => {
    return HttpResponse.json({
      id: Number(params.id),
      name: "Alice",
      email: "alice@example.com",
    });
  })
);
 
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
 
describe("UserList", () => {
  it("displays users from API", async () => {
    render(<UserList />);
 
    expect(await screen.findByText("Alice")).toBeInTheDocument();
    expect(await screen.findByText("Bob")).toBeInTheDocument();
  });
 
  it("shows error state when API fails", async () => {
    // इस एक test के लिए default handler override करें
    server.use(
      http.get("/api/users", () => {
        return new HttpResponse(null, { status: 500 });
      })
    );
 
    render(<UserList />);
 
    expect(await screen.findByText(/failed to load/i)).toBeInTheDocument();
  });
});

MSW fetch या axios directly mock करने से बेहतर है क्योंकि:

  1. आपके component का actual data fetching code run होता है — आप real integration test करते हैं।
  2. आप per test handlers override करके error handling test कर सकते हैं।
  3. Same handlers tests और browser dev mode (Storybook, मिसाल के तौर पर) दोनों में काम करते हैं।

Integration Tests#

Unit Tests vs Integration Tests#

Unit test एक single function या component isolate करता है और बाकी सब mock करता है। Integration test multiple units को साथ काम करने देता है और सिर्फ external boundaries (network, file system, databases) mock करता है।

सच ये है: production में मैंने जो ज़्यादातर bugs देखे हैं वो units के बीच की boundaries पर होते हैं, उनके अंदर नहीं। Function isolation में perfectly काम करता है लेकिन fail होता है क्योंकि caller data थोड़ा different format में pass करता है। Component mock data के साथ ठीक render होता है लेकिन break होता है जब actual API response में extra nesting level होता है।

Integration tests ये bugs पकड़ते हैं। ये unit tests से धीमे हैं और fail होने पर debug करना कठिन है, लेकिन per test ज़्यादा confidence देते हैं।

Multiple Components साथ Test करना#

typescript
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ShoppingCart } from "@/components/ShoppingCart";
import { CartProvider } from "@/contexts/CartContext";
 
// सिर्फ API layer mock करें — बाकी सब real है
vi.mock("@/lib/api", () => ({
  checkout: vi.fn().mockResolvedValue({ orderId: "ORD-123" }),
}));
 
describe("Shopping Cart Flow", () => {
  const renderCart = (initialItems = []) => {
    return render(
      <CartProvider initialItems={initialItems}>
        <ShoppingCart />
      </CartProvider>
    );
  };
 
  it("displays item count and total", () => {
    renderCart([
      { id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
      { id: "2", name: "Mouse", price: 49.99, quantity: 2 },
    ]);
 
    expect(screen.getByText("3 items")).toBeInTheDocument();
    expect(screen.getByText("$179.97")).toBeInTheDocument();
  });
 
  it("updates quantity and recalculates total", async () => {
    const user = userEvent.setup();
 
    renderCart([
      { id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
    ]);
 
    const incrementButton = screen.getByRole("button", { name: /increase quantity/i });
    await user.click(incrementButton);
 
    expect(screen.getByText("$159.98")).toBeInTheDocument();
  });
 
  it("completes checkout flow", async () => {
    const user = userEvent.setup();
    const { checkout } = await import("@/lib/api");
 
    renderCart([
      { id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
    ]);
 
    await user.click(screen.getByRole("button", { name: /checkout/i }));
 
    expect(checkout).toHaveBeenCalledWith({
      items: [{ id: "1", quantity: 1 }],
    });
 
    expect(await screen.findByText(/order confirmed/i)).toBeInTheDocument();
    expect(screen.getByText("ORD-123")).toBeInTheDocument();
  });
});

इस test में, ShoppingCart और CartProvider और उनके internal components (item rows, quantity selectors, totals display) सब real code के साथ काम करते हैं। सिर्फ API call mock है, क्योंकि हम tests में real network requests नहीं करना चाहते।

Integration vs Unit Tests कब Use करें#

Unit tests use करें जब:

  • Logic complex है और कई edge cases हैं (date parser, state machine, calculation)।
  • आपको किसी specific function के behavior पर fast feedback चाहिए।
  • Unit अपेक्षाकृत isolated है और दूसरी units पर ज़्यादा depend नहीं करता।

Integration tests use करें जब:

  • Multiple components को सही ढंग से साथ काम करना है (validation और submission वाला form)।
  • Data कई layers से flow होता है (context → component → child component)।
  • आप user workflow test कर रहे हैं, function की return value नहीं।

Practice में, healthy test suite features के लिए integration tests पर heavy है और complex utilities के लिए unit tests रखती है। Components खुद integration tests के through test होते हैं — आपको हर छोटे component के लिए अलग unit test नहीं चाहिए अगर integration test इसे exercise करता है।

Coverage#

Coverage Run करना#

bash
vitest run --coverage

आपको coverage provider चाहिए। Vitest दो support करता है:

bash
# V8 — तेज़, V8 के built-in coverage use करता है
npm install -D @vitest/coverage-v8
 
# Istanbul — ज़्यादा mature, ज़्यादा configuration options
npm install -D @vitest/coverage-istanbul

अपने Vitest config में configure करें:

typescript
export default defineConfig({
  test: {
    coverage: {
      provider: "v8",
      reporter: ["text", "html", "lcov"],
      include: ["src/**/*.{ts,tsx}"],
      exclude: [
        "src/**/*.test.{ts,tsx}",
        "src/**/*.spec.{ts,tsx}",
        "src/test/**",
        "src/**/*.d.ts",
        "src/**/types.ts",
      ],
      thresholds: {
        statements: 80,
        branches: 75,
        functions: 80,
        lines: 80,
      },
    },
  },
});

Istanbul vs V8#

V8 coverage V8 engine की built-in instrumentation use करता है। ये तेज़ है क्योंकि कोई code transformation step नहीं। लेकिन कुछ edge cases के लिए कम accurate हो सकता है, खासकर ES module boundaries के आसपास।

Istanbul tests run करने से पहले आपके source code को counters से instrument करता है। ये धीमा है लेकिन ज़्यादा battle-tested और ज़्यादा accurate branch coverage देता है। अगर आप CI में coverage thresholds enforce कर रहे हैं, Istanbul की accuracy मायने रख सकती है।

मैं local development के लिए V8 (तेज़ feedback) और CI में Istanbul (ज़्यादा accurate enforcement) use करता हूं। ज़रूरत हो तो per environment different providers configure कर सकते हैं।

Coverage का असली मतलब#

Coverage बताती है कि tests के दौरान कौन सी lines of code execute हुईं। बस। ये नहीं बताती कि वो lines सही ढंग से test हुई थीं। ये देखिए:

typescript
function divide(a: number, b: number): number {
  return a / b;
}
 
it("divides numbers", () => {
  divide(10, 2);
  // कोई assertion नहीं!
});

ये test divide function की 100% coverage देता है। ये बिल्कुल कुछ test भी नहीं करता। Test pass होगा अगर divide null return करे, error throw करे, या missiles launch करे।

Coverage useful negative indicator है: low coverage मतलब definitely untested paths हैं। लेकिन high coverage का मतलब आपका code well-tested है ऐसा नहीं। इसका बस मतलब है हर line किसी test के दौरान run हुई।

Lines vs Branches#

Line coverage सबसे common metric है लेकिन branch coverage ज़्यादा valuable है:

typescript
function getDiscount(user: User): number {
  if (user.isPremium) {
    return user.yearsActive > 5 ? 0.2 : 0.1;
  }
  return 0;
}

getDiscount({ isPremium: true, yearsActive: 10 }) वाला test हर line hit करता है (100% line coverage) लेकिन सिर्फ दो तीन branches test करता है। isPremium: false path और yearsActive <= 5 path untested हैं।

Branch coverage इसे पकड़ती है। ये conditional logic के हर possible path track करती है। अगर coverage threshold enforce करना है, branch coverage use करें।

Generated Code Ignore करना#

कुछ code को coverage में count नहीं करना चाहिए। Generated files, type definitions, configuration — ये बिना value जोड़े metrics inflate करते हैं:

typescript
// vitest.config.ts
coverage: {
  exclude: [
    "src/**/*.d.ts",
    "src/**/types.ts",
    "src/**/*.stories.tsx",
    "src/generated/**",
    ".velite/**",
  ],
}

अपने source code में specific lines या blocks भी ignore कर सकते हैं:

typescript
/* v8 ignore start */
if (process.env.NODE_ENV === "development") {
  console.log("Debug info:", data);
}
/* v8 ignore stop */
 
// या Istanbul के लिए
/* istanbul ignore next */
function devOnlyHelper() { /* ... */ }

इसे sparingly use करें। अगर आप code के बड़े chunks ignore कर रहे हैं, तो या तो उन chunks को tests चाहिए या उन्हें coverage report में होना ही नहीं चाहिए।

Next.js Test करना#

next/navigation Mock करना#

Next.js components जो useRouter, usePathname, या useSearchParams use करते हैं उन्हें mocks चाहिए:

typescript
import { vi } from "vitest";
 
vi.mock("next/navigation", () => ({
  useRouter: () => ({
    push: vi.fn(),
    replace: vi.fn(),
    back: vi.fn(),
    prefetch: vi.fn(),
    refresh: vi.fn(),
  }),
  usePathname: () => "/en/blog",
  useSearchParams: () => new URLSearchParams("?page=1"),
  useParams: () => ({ locale: "en" }),
}));

उन tests के लिए जिन्हें verify करना है कि navigation call हुआ:

typescript
import { useRouter } from "next/navigation";
 
vi.mock("next/navigation", () => ({
  useRouter: vi.fn(),
}));
 
describe("LogoutButton", () => {
  it("redirects to home after logout", async () => {
    const mockPush = vi.fn();
    vi.mocked(useRouter).mockReturnValue({
      push: mockPush,
      replace: vi.fn(),
      back: vi.fn(),
      prefetch: vi.fn(),
      refresh: vi.fn(),
      forward: vi.fn(),
    });
 
    const user = userEvent.setup();
    render(<LogoutButton />);
 
    await user.click(screen.getByRole("button", { name: /log out/i }));
 
    expect(mockPush).toHaveBeenCalledWith("/");
  });
});

next-intl Mock करना#

next-intl use करने वाले internationalized components के लिए:

typescript
vi.mock("next-intl", () => ({
  useTranslations: () => (key: string) => key,
  useLocale: () => "en",
}));

ये सबसे simple approach है — translations key खुद return करती हैं, तो t("hero.title") "hero.title" return करता है। Assertions में, आप actual translated string की बजाय translation key check करते हैं। ये tests language-independent बनाता है।

अगर किसी specific test में actual translations चाहिए:

typescript
vi.mock("next-intl", () => ({
  useTranslations: () => {
    const translations: Record<string, string> = {
      "hero.title": "Welcome to My Site",
      "hero.subtitle": "Building things for the web",
    };
    return (key: string) => translations[key] ?? key;
  },
}));

Route Handlers Test करना#

Next.js Route Handlers regular functions हैं जो Request लेती हैं और Response return करती हैं। ये straightforward test होती हैं:

typescript
import { GET, POST } from "@/app/api/users/route";
import { NextRequest } from "next/server";
 
describe("GET /api/users", () => {
  it("returns users list", async () => {
    const request = new NextRequest("http://localhost:3000/api/users");
    const response = await GET(request);
    const data = await response.json();
 
    expect(response.status).toBe(200);
    expect(data).toEqual(
      expect.arrayContaining([
        expect.objectContaining({ id: expect.any(Number), name: expect.any(String) }),
      ])
    );
  });
 
  it("supports pagination via search params", async () => {
    const request = new NextRequest("http://localhost:3000/api/users?page=2&limit=10");
    const response = await GET(request);
    const data = await response.json();
 
    expect(data.page).toBe(2);
    expect(data.items).toHaveLength(10);
  });
});
 
describe("POST /api/users", () => {
  it("creates a new user", async () => {
    const request = new NextRequest("http://localhost:3000/api/users", {
      method: "POST",
      body: JSON.stringify({ name: "Alice", email: "alice@test.com" }),
    });
 
    const response = await POST(request);
    const data = await response.json();
 
    expect(response.status).toBe(201);
    expect(data.name).toBe("Alice");
  });
 
  it("returns 400 for invalid body", async () => {
    const request = new NextRequest("http://localhost:3000/api/users", {
      method: "POST",
      body: JSON.stringify({ name: "" }),
    });
 
    const response = await POST(request);
    expect(response.status).toBe(400);
  });
});

Middleware Test करना#

Next.js middleware edge पर run होता है और हर request process करता है। इसे function की तरह test करें:

typescript
import { middleware } from "@/middleware";
import { NextRequest } from "next/server";
 
function createRequest(path: string, headers: Record<string, string> = {}): NextRequest {
  const url = new URL(path, "http://localhost:3000");
  return new NextRequest(url, { headers });
}
 
describe("middleware", () => {
  it("redirects unauthenticated users from protected routes", async () => {
    const request = createRequest("/dashboard");
    const response = await middleware(request);
 
    expect(response.status).toBe(307);
    expect(response.headers.get("location")).toContain("/login");
  });
 
  it("allows authenticated users through", async () => {
    const request = createRequest("/dashboard", {
      cookie: "session=valid-token",
    });
    const response = await middleware(request);
 
    expect(response.status).toBe(200);
  });
 
  it("adds security headers", async () => {
    const request = createRequest("/");
    const response = await middleware(request);
 
    expect(response.headers.get("x-frame-options")).toBe("DENY");
    expect(response.headers.get("x-content-type-options")).toBe("nosniff");
  });
 
  it("handles locale detection", async () => {
    const request = createRequest("/", {
      "accept-language": "tr-TR,tr;q=0.9,en;q=0.8",
    });
    const response = await middleware(request);
 
    expect(response.headers.get("location")).toContain("/tr");
  });
});

Server Actions Test करना#

Server Actions async functions हैं जो server पर run होती हैं। चूंकि ये बस functions हैं, इन्हें directly test कर सकते हैं — लेकिन server-only dependencies mock करनी पड़ सकती हैं:

typescript
vi.mock("@/lib/db", () => ({
  db: {
    user: {
      update: vi.fn().mockResolvedValue({ id: "1", name: "Updated" }),
      findUnique: vi.fn().mockResolvedValue({ id: "1", name: "Original" }),
    },
  },
}));
 
vi.mock("next/cache", () => ({
  revalidatePath: vi.fn(),
  revalidateTag: vi.fn(),
}));
 
import { updateProfile } from "@/app/actions/profile";
import { revalidatePath } from "next/cache";
 
describe("updateProfile", () => {
  it("updates user and revalidates profile page", async () => {
    const formData = new FormData();
    formData.set("name", "New Name");
    formData.set("bio", "New bio text");
 
    const result = await updateProfile(formData);
 
    expect(result.success).toBe(true);
    expect(revalidatePath).toHaveBeenCalledWith("/profile");
  });
 
  it("returns error for invalid data", async () => {
    const formData = new FormData();
    // Required fields missing
 
    const result = await updateProfile(formData);
 
    expect(result.success).toBe(false);
    expect(result.error).toBeDefined();
  });
});

Practical Patterns#

Custom Render Function#

ज़्यादातर projects को हर component के around same providers wrap करने होते हैं। Custom render बनाएं:

typescript
// src/test/utils.tsx
import { render, type RenderOptions } from "@testing-library/react";
import { ReactElement } from "react";
import { ThemeProvider } from "@/contexts/ThemeContext";
import { CartProvider } from "@/contexts/CartContext";
 
interface CustomRenderOptions extends RenderOptions {
  theme?: "light" | "dark";
  initialCartItems?: CartItem[];
}
 
function AllProviders({ children, theme = "light", initialCartItems = [] }: {
  children: React.ReactNode;
  theme?: "light" | "dark";
  initialCartItems?: CartItem[];
}) {
  return (
    <ThemeProvider defaultTheme={theme}>
      <CartProvider initialItems={initialCartItems}>
        {children}
      </CartProvider>
    </ThemeProvider>
  );
}
 
export function renderWithProviders(
  ui: ReactElement,
  options: CustomRenderOptions = {}
) {
  const { theme, initialCartItems, ...renderOptions } = options;
 
  return render(ui, {
    wrapper: ({ children }) => (
      <AllProviders theme={theme} initialCartItems={initialCartItems}>
        {children}
      </AllProviders>
    ),
    ...renderOptions,
  });
}
 
// Testing library से सब re-export करें
export * from "@testing-library/react";
export { renderWithProviders as render };

अब हर test file @testing-library/react की बजाय आपके custom utils से import करती है:

typescript
import { render, screen } from "@/test/utils";
 
it("renders in dark mode", () => {
  render(<Header />, { theme: "dark" });
  // Header और उसके सभी children को ThemeProvider और CartProvider access है
});

Custom Hooks Test करना#

Vitest @testing-library/react के renderHook के साथ काम करता है:

typescript
import { renderHook, act } from "@testing-library/react";
import { useCounter } from "@/hooks/useCounter";
 
describe("useCounter", () => {
  it("starts at initial value", () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });
 
  it("increments", () => {
    const { result } = renderHook(() => useCounter(0));
 
    act(() => {
      result.current.increment();
    });
 
    expect(result.current.count).toBe(1);
  });
 
  it("decrements with floor", () => {
    const { result } = renderHook(() => useCounter(0, { min: 0 }));
 
    act(() => {
      result.current.decrement();
    });
 
    expect(result.current.count).toBe(0); // min से नीचे नहीं जाता
  });
});

Error Boundaries Test करना#

typescript
import { render, screen } from "@testing-library/react";
import { ErrorBoundary } from "@/components/ErrorBoundary";
 
const ThrowingComponent = () => {
  throw new Error("Test explosion");
};
 
describe("ErrorBoundary", () => {
  // Expected errors के लिए console.error suppress करें
  beforeEach(() => {
    vi.spyOn(console, "error").mockImplementation(() => {});
  });
 
  afterEach(() => {
    vi.restoreAllMocks();
  });
 
  it("displays fallback UI when child throws", () => {
    render(
      <ErrorBoundary fallback={<div>Something went wrong</div>}>
        <ThrowingComponent />
      </ErrorBoundary>
    );
 
    expect(screen.getByText("Something went wrong")).toBeInTheDocument();
  });
 
  it("renders children when no error", () => {
    render(
      <ErrorBoundary fallback={<div>Error</div>}>
        <div>All good</div>
      </ErrorBoundary>
    );
 
    expect(screen.getByText("All good")).toBeInTheDocument();
    expect(screen.queryByText("Error")).not.toBeInTheDocument();
  });
});

Snapshot Testing (सावधानी से)#

Snapshot tests की बुरी reputation है क्योंकि लोग इन्हें real assertions के substitute के रूप में use करते हैं। पूरे component के HTML output का snapshot एक maintenance burden है — हर CSS class change पर break होता है और कोई diff carefully review नहीं करता।

लेकिन targeted snapshots useful हो सकते हैं:

typescript
import { render } from "@testing-library/react";
import { formatCurrency } from "@/lib/format";
 
// अच्छा — pure function के output का छोटा, targeted snapshot
it("formats various currency values consistently", () => {
  expect(formatCurrency(0)).toMatchInlineSnapshot('"$0.00"');
  expect(formatCurrency(1234.5)).toMatchInlineSnapshot('"$1,234.50"');
  expect(formatCurrency(-99.99)).toMatchInlineSnapshot('"-$99.99"');
  expect(formatCurrency(1000000)).toMatchInlineSnapshot('"$1,000,000.00"');
});
 
// खराब — विशाल snapshot जो कोई review नहीं करेगा
it("renders the dashboard", () => {
  const { container } = render(<Dashboard />);
  expect(container).toMatchSnapshot(); // ये मत करें
});

Inline snapshots (toMatchInlineSnapshot) file snapshots से बेहतर हैं क्योंकि expected value test में ही visible है। आप एक नज़र में देख सकते हैं function क्या return करता है बिना अलग .snap file खोले।

Testing Philosophy#

Behavior Test करें, Implementation नहीं#

ये principle इतना important है कि dedicated section deserve करता है। Same feature के लिए दो tests देखिए:

typescript
// Implementation test — brittle, refactors पर break होता है
it("calls setState with new count", () => {
  const setStateSpy = vi.spyOn(React, "useState");
  render(<Counter />);
  fireEvent.click(screen.getByText("+"));
  expect(setStateSpy).toHaveBeenCalledWith(expect.any(Function));
});
 
// Behavior test — resilient, user जो देखता है वो test करता है
it("increments the displayed count when plus button is clicked", async () => {
  const user = userEvent.setup();
  render(<Counter />);
  expect(screen.getByText("Count: 0")).toBeInTheDocument();
 
  await user.click(screen.getByRole("button", { name: "+" }));
 
  expect(screen.getByText("Count: 1")).toBeInTheDocument();
});

पहला test break होता है अगर आप useState से useReducer पर switch करें, भले ही component exactly same काम करता है। दूसरा test सिर्फ तब break होता है जब component का behavior actually बदलता है। इसे परवाह नहीं count internally कैसे manage होता है — सिर्फ ये कि "+" click करने पर number बढ़ता है।

Litmus test simple है: क्या आप implementation refactor कर सकते हैं बिना test बदले? अगर हां, आप behavior test कर रहे हैं। अगर नहीं, आप implementation test कर रहे हैं।

Testing Trophy#

Kent C. Dodds ने traditional testing pyramid के alternative के रूप में "Testing Trophy" propose किया:

    ╭─────────╮
    │  E2E    │   कम — महंगे, धीमे, उच्च confidence
    ├─────────┤
    │         │
    │ Integr. │   सबसे ज़्यादा — अच्छा confidence-to-cost ratio
    │         │
    ├─────────┤
    │  Unit   │   कुछ — तेज़, focused, कम-cost
    ├─────────┤
    │ Static  │   हमेशा — TypeScript, ESLint
    ╰─────────╯

Traditional pyramid unit tests नीचे (ढेर सारे) और integration tests बीच में (कम) रखता है। Trophy इसे उलट देता है: integration tests sweet spot हैं। यही कारण:

  • Static analysis (TypeScript, ESLint) typos, गलत types, और simple logical errors free में पकड़ती है। कुछ run भी नहीं करना।
  • Unit tests complex pure logic के लिए बढ़िया हैं लेकिन ये नहीं बताते कि pieces साथ काम करते हैं।
  • Integration tests verify करते हैं कि components, hooks, और contexts साथ काम करते हैं। Per test written सबसे ज़्यादा confidence देते हैं।
  • End-to-end tests पूरे system verify करते हैं लेकिन धीमे, flaky, और maintain करने में महंगे हैं। Critical paths के लिए कुछ चाहिए लेकिन सैकड़ों नहीं।

मैं practice में ये distribution follow करता हूं: TypeScript मेरी ज़्यादातर type errors पकड़ता है, मैं complex utilities और algorithms के लिए unit tests लिखता हूं, features और user flows के लिए integration tests, और critical path (sign up, purchase, core workflow) के लिए मुट्ठी भर E2E tests।

क्या Confidence देता है vs क्या Time Waste करता है#

Tests आपको ship करने का confidence देने के लिए exist करते हैं। ये confidence नहीं कि हर line of code run होती है — confidence कि application users के लिए काम करता है। ये अलग चीज़ें हैं।

High confidence, high value:

  • Checkout flow का integration test — form validation, API calls, state updates, और success/error UI cover करता है।
  • Edge cases वाले price calculation function का unit test — floating point, rounding, discounts, zero/negative values।
  • Test कि protected routes unauthenticated users redirect करते हैं।

Low confidence, time wasters:

  • Static marketing page का snapshot test — हर copy change पर break होता है, कुछ meaningful नहीं पकड़ता।
  • Unit test कि component child को prop pass करता है — React खुद test कर रहे हैं, अपना code नहीं।
  • Test कि useState call हुआ — framework test कर रहे हैं, behavior नहीं।
  • Configuration file की 100% coverage — ये static data है, TypeScript पहले से इसकी shape validate करता है।

Test लिखने से पहले पूछने वाला सवाल: "अगर ये test exist नहीं करता, कौन सा bug production में slip कर सकता है?" अगर जवाब है "कोई भी नहीं जो TypeScript नहीं पकड़ लेगा" या "कोई भी नहीं जो किसी को notice होगा," test शायद लिखने लायक नहीं है।

Design Feedback के रूप में Testing#

Test करना मुश्किल code आमतौर पर खराब design वाला code है। अगर एक function test करने के लिए पांच चीज़ें mock करनी पड़ती हैं, उस function में बहुत ज़्यादा dependencies हैं। अगर elaborate context providers set up किए बिना component render नहीं कर सकते, component अपने environment से बहुत ज़्यादा coupled है।

Tests आपके code के user हैं। अगर आपके tests आपकी API use करने में struggle करते हैं, दूसरे developers भी करेंगे। जब आप test setup से लड़ रहे हों, इसे test किए जा रहे code को refactor करने का signal मानें, ज़्यादा mocks जोड़ने का नहीं।

typescript
// Test करना मुश्किल — function बहुत ज़्यादा करता है
async function processOrder(orderId: string) {
  const order = await db.orders.findById(orderId);
  const user = await db.users.findById(order.userId);
  const inventory = await checkInventory(order.items);
  if (!inventory.available) {
    await sendEmail(user.email, "out-of-stock", { items: inventory.unavailable });
    return { success: false, reason: "out-of-stock" };
  }
  const payment = await chargeCard(user.paymentMethod, order.total);
  if (!payment.success) {
    await sendEmail(user.email, "payment-failed", { error: payment.error });
    return { success: false, reason: "payment-failed" };
  }
  await db.orders.update(orderId, { status: "confirmed" });
  await sendEmail(user.email, "order-confirmed", { orderId });
  return { success: true };
}
 
// Test करना आसान — concerns अलग किए गए
function determineOrderAction(
  inventory: InventoryResult,
  payment: PaymentResult
): OrderAction {
  if (!inventory.available) return { type: "out-of-stock", items: inventory.unavailable };
  if (!payment.success) return { type: "payment-failed", error: payment.error };
  return { type: "confirmed" };
}

दूसरा version pure function है। Database, payment provider, या email service mock किए बिना inventory और payment results का हर combination test कर सकते हैं। Orchestration logic (data fetch करना, emails भेजना) एक thin layer में रहती है जो integration level पर test होती है।

ये testing की असली value है: bugs लिखने के बाद पकड़ना नहीं, बल्कि खराब designs commit होने से पहले रोकना। Tests लिखने का discipline आपको छोटे functions, clearer interfaces, और ज़्यादा modular architecture की तरफ push करता है। Tests side effect हैं। Design improvement main event है।

ऐसे Tests लिखें जिन पर भरोसा हो#

Test suite के साथ सबसे बुरी चीज़ ये नहीं कि इसमें gaps हैं। ये है कि लोग इस पर भरोसा करना बंद कर दें। कुछ flaky tests वाला test suite जो CI पर randomly fail होता है, team को red builds ignore करना सिखाता है। एक बार ऐसा होने पर, test suite useless से भी बदतर है — ये actively false security provide करता है।

अगर test intermittently fail होता है, fix करें या delete करें। अगर test slow है, speed up करें या अलग slow-test suite में move करें। अगर test हर unrelated change पर break होता है, implementation की बजाय behavior test करने के लिए rewrite करें।

Goal ऐसा test suite है जहां हर failure का मतलब कुछ real broken है। जब developers tests पर भरोसा करते हैं, हर commit से पहले run करते हैं। जब भरोसा नहीं करते, --no-verify से bypass करते हैं और उंगलियां cross करके deploy करते हैं।

ऐसा test suite बनाएं जिस पर आप अपना weekend दांव पर लगा सकें। इससे कम maintain करने लायक नहीं।

संबंधित पोस्ट