Ink&Horizon
HomeBlogTutorialsLanguages
Ink&Horizon— where knowledge meets the horizon —Learn to build exceptional software. Tutorials, guides, and references for developers — from first brushstroke to masterwork.

Learn

  • Blog
  • Tutorials
  • Languages

Company

  • About Us
  • Contact Us
  • Privacy Policy

Account

  • Sign In
  • Register
  • Profile
Ink & Horizon

© 2026 InkAndHorizon. All rights reserved.

Privacy PolicyTerms of Service
Back to Blog
QA

Unit Testing in 2026: Vitest 3, Testing Library & TDD Patterns

Write tests that catch bugs, not tests that test implementation details — with Vitest 3 and React Testing Library

2026-01-08 22 min read
ContentsWhy Most Tests Are WorthlessVitest 3: The Modern Test RunnerReact Testing Library: User-Centric TestingMocking Patterns: APIs, Modules & TimersTesting Hook and Custom LogicKey Takeaways

Why Most Tests Are Worthless

The biggest testing mistake: testing implementation details. Tests that verify "the button has className 'btn-primary'" or "the component called setState 3 times" break on every refactor without catching any real bugs. They increase maintenance cost without increasing confidence.

Good tests verify BEHAVIOR from the user's perspective: "when I click submit, the success message appears." These tests survive refactors because you can completely rewrite the internal implementation and the test still passes — if the behavior is correct.

The testing philosophy: "Write tests. Not too many. Mostly integration." — Guillermo Rauch (Vercel CEO). Focus on integration tests that verify real user flows, supplement with unit tests for complex logic, and use E2E tests sparingly for critical paths.

Key Takeaways

Test behavior, NOT implementation details.
If a test breaks on refactor without a behavior change — it's a bad test.
Integration tests give more confidence per test than unit tests.
Testing Library enforces user-centric testing: query by role, text, label.
The Testing Trophy: static analysis > unit > integration > E2E.

Vitest 3: The Modern Test Runner

Vitest 3 is the standard test runner for modern JavaScript projects. It is Jest-compatible (same API: describe, it, expect) but 10-20x faster because it uses Vite's instant HMR and ESM support. No more waiting 30 seconds for tests to start.

Vitest provides: instant watch mode (only re-runs affected tests), in-source testing (tests alongside your code), browser mode (run tests in a real browser), TypeScript support (zero config), and code coverage (v8 or Istanbul).

Snippet
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,           // No need to import describe, it, expect
    environment: 'jsdom',    // DOM environment for React
    setupFiles: './src/test/setup.ts',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 75,
      },
    },
    css: true,               // Process CSS imports
  },
});

// src/test/setup.ts
import '@testing-library/jest-dom/vitest';

// Basic test example
import { describe, it, expect } from 'vitest';
import { formatCurrency, calculateDiscount } from './utils';

describe('formatCurrency', () => {
  it('formats USD amount with two decimals', () => {
    expect(formatCurrency(99.9, 'USD')).toBe('$99.90');
  });

  it('handles zero correctly', () => {
    expect(formatCurrency(0, 'USD')).toBe('$0.00');
  });

  it('formats INR with proper symbol', () => {
    expect(formatCurrency(1500, 'INR')).toBe('₹1,500.00');
  });
});

describe('calculateDiscount', () => {
  it('applies percentage discount', () => {
    expect(calculateDiscount(100, { type: 'percentage', value: 20 })).toBe(80);
  });

  it('never goes below zero', () => {
    expect(calculateDiscount(10, { type: 'fixed', value: 50 })).toBe(0);
  });
});

Key Takeaways

Vitest 3: Jest-compatible API, 10-20x faster, zero-config TypeScript.
globals: true eliminates repetitive imports of describe/it/expect.
environment: "jsdom" provides DOM APIs for React component tests.
Coverage thresholds enforce minimum test coverage in CI.
Watch mode re-runs only affected tests — instant feedback loop.

React Testing Library: User-Centric Testing

React Testing Library (RTL) enforces good testing practices by design. Its API forces you to find elements the way users do: by visible text, labels, roles, and placeholders — not by CSS class names, data-testids, or component internals.

The priority order for queries: getByRole > getByLabelText > getByText > getByPlaceholderText > getByTestId. If you find yourself reaching for getByTestId frequently, it usually means your HTML lacks proper accessibility attributes.

RTL's core philosophy: "The more your tests resemble the way your software is used, the more confidence they can give you."

Snippet
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';

describe('LoginForm', () => {
  it('submits login credentials and shows success', async () => {
    const user = userEvent.setup();
    const mockLogin = vi.fn().mockResolvedValue({ success: true });
    
    render(<LoginForm onLogin={mockLogin} />);
    
    // Query by accessible role and label — like a real user
    await user.type(screen.getByLabelText(/email/i), 'ash@inkandhorizon.com');
    await user.type(screen.getByLabelText(/password/i), 'securePassword123');
    await user.click(screen.getByRole('button', { name: /sign in/i }));
    
    // Assert behavior, not implementation
    await waitFor(() => {
      expect(screen.getByText(/welcome back/i)).toBeInTheDocument();
    });
    
    expect(mockLogin).toHaveBeenCalledWith({
      email: 'ash@inkandhorizon.com',
      password: 'securePassword123',
    });
  });

  it('shows validation error for invalid email', async () => {
    const user = userEvent.setup();
    render(<LoginForm onLogin={vi.fn()} />);
    
    await user.type(screen.getByLabelText(/email/i), 'not-an-email');
    await user.click(screen.getByRole('button', { name: /sign in/i }));
    
    expect(screen.getByText(/please enter a valid email/i)).toBeInTheDocument();
  });

  it('disables submit button while loading', async () => {
    const user = userEvent.setup();
    const slowLogin = vi.fn(() => new Promise(r => setTimeout(r, 1000)));
    
    render(<LoginForm onLogin={slowLogin} />);
    
    await user.type(screen.getByLabelText(/email/i), 'ash@ih.com');
    await user.type(screen.getByLabelText(/password/i), 'pass123');
    await user.click(screen.getByRole('button', { name: /sign in/i }));
    
    expect(screen.getByRole('button', { name: /signing in/i })).toBeDisabled();
  });
});

Key Takeaways

userEvent.setup() is preferred over fireEvent — simulates real user interactions.
getByRole/getByLabelText: find elements like users do — by visible role and label.
waitFor: wait for async state updates (API calls, timeouts).
vi.fn().mockResolvedValue: mock async functions with controlled return values.
Never test CSS classes or internal state — test visible behavior only.

Mocking Patterns: APIs, Modules & Timers

Mocking isolates the unit under test from external dependencies. The three essential patterns: mock API calls (prevent network requests in tests), mock modules (replace heavy imports), and mock timers (control setTimeout/setInterval).

MSW (Mock Service Worker) is the recommended approach for API mocking — it intercepts network requests at the service worker level, providing realistic behavior without modifying application code.

Snippet
// Mock API calls with MSW (Mock Service Worker)
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';

const server = setupServer(
  http.get('/api/users/me', () => {
    return HttpResponse.json({
      id: 'user_123',
      name: 'Ashutosh',
      role: 'admin',
    });
  }),
  http.post('/api/posts', async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json({ id: 'post_456', ...body }, { status: 201 });
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

// Mock timers
describe('auto-save', () => {
  it('saves draft every 30 seconds', () => {
    vi.useFakeTimers();
    const mockSave = vi.fn();
    
    render(<Editor onAutoSave={mockSave} />);
    
    // Fast-forward 30 seconds
    vi.advanceTimersByTime(30000);
    expect(mockSave).toHaveBeenCalledTimes(1);
    
    vi.advanceTimersByTime(30000);
    expect(mockSave).toHaveBeenCalledTimes(2);
    
    vi.useRealTimers();
  });
});

// Mock modules
vi.mock('./analytics', () => ({
  trackEvent: vi.fn(),
  trackPageView: vi.fn(),
}));

Testing Hook and Custom Logic

Custom React hooks need special testing because they can only be called inside components. Use renderHook from @testing-library/react to test hooks in isolation. For complex logic that does not depend on React, extract it into pure functions and test those directly.

The testing strategy: extract business logic into pure functions (easy to test), keep hooks thin (bridge between React lifecycle and business logic), test complex hooks with renderHook, and test simple hooks indirectly through component tests.

Snippet
import { renderHook, act, waitFor } from '@testing-library/react';
import { useDebounce } from './useDebounce';

describe('useDebounce', () => {
  it('debounces value updates', async () => {
    vi.useFakeTimers();
    
    const { result, rerender } = renderHook(
      ({ value }) => useDebounce(value, 300),
      { initialProps: { value: '' } }
    );
    
    // Initial value
    expect(result.current).toBe('');
    
    // Update value — debounced value should NOT change yet
    rerender({ value: 'hello' });
    expect(result.current).toBe(''); // Still empty
    
    // Fast-forward 300ms
    act(() => { vi.advanceTimersByTime(300); });
    expect(result.current).toBe('hello'); // Now updated
    
    vi.useRealTimers();
  });
});

// Pure function tests — no React needed
import { validateEmail, parseQueryString, slugify } from './utils';

describe('validateEmail', () => {
  it.each([
    ['ash@inkandhorizon.com', true],
    ['user@domain.co.in', true],
    ['invalid', false],
    ['@domain.com', false],
    ['user@', false],
  ])('validates %s → %s', (email, expected) => {
    expect(validateEmail(email)).toBe(expected);
  });
});

describe('slugify', () => {
  it.each([
    ['Hello World', 'hello-world'],
    ['React 19 Server Components', 'react-19-server-components'],
    ['TypeScript & JavaScript', 'typescript-javascript'],
  ])('slugifies "%s" → "%s"', (input, expected) => {
    expect(slugify(input)).toBe(expected);
  });
});

Key Takeaways

renderHook: test custom hooks without wrapping in a dummy component.
it.each: parameterized tests — test many inputs in one block.
Pure functions: extract from hooks for easy, fast testing.
act(): wrap state updates to ensure React processes them.
Extract logic → pure functions → unit test. Keep hooks thin.

Key Takeaways

Good tests verify BEHAVIOR, not implementation. Use React Testing Library to query elements like users do (by role, label, text). Use Vitest 3 for instant feedback with Jest-compatible APIs. Use MSW for realistic API mocking.

For interviews: explain the Testing Trophy (static > unit > integration > E2E), demonstrate Testing Library queries, discuss when to mock vs when to use real dependencies, and show how parameterized tests (it.each) reduce test code duplication.

Key Takeaways

Test behavior, not implementation. If a test breaks on refactor — it's bad.
Vitest 3: 10-20x faster than Jest, zero-config TypeScript, ESM native.
Testing Library: query by role > label > text > testid.
MSW: intercept network requests at service worker level — realistic mocking.
it.each: parameterized tests for multiple input/output combinations.
Pure functions > hooks for testability. Extract logic, keep hooks thin.
AS
Article Author
Ashutosh
Lead Developer

Related Knowledge

Tutorial

JavaScript Fundamentals

5m read
Tutorial

TypeScript Deep Generics

5m read
Article

Understanding Closures in JavaScript: The Complete 2026 Guide

22 min read
Article

React 19 Server Components: The Definitive 2026 Guide

28 min read
Article

Next.js 15 App Router Masterclass: Everything You Need to Know

25 min read