Testing

Advanced Testing with TDD in React and TypeScript

Complete guide to Test-Driven Development in React applications with TypeScript.

FC

Fernando Caravaca

FullStack Developer

October 20, 2024
18 min read
TDD cycle diagram

Advanced Testing with TDD in React and TypeScript

TDD Cycle The TDD cycle: Red, Green, Refactor - The foundation of test-driven development

Introduction

Test-Driven Development (TDD) is not just about writing tests, it's a methodology that fundamentally changes how we develop software. In React applications with TypeScript, TDD allows us to create robust, maintainable components with fewer bugs.

Why TDD? In my experience, projects without tests accumulate technical debt quickly. A small change can break critical functionality without you noticing until it's in production. With TDD, every line of code is backed by tests that act as a safety net.

The TDD Cycle: Red-Green-Refactor

1. Red - Write a failing test

First, write the test for functionality that doesn't exist yet. The test must fail because the implementation doesn't exist.

2. Green - Implement minimum code

Write only the necessary code to make the test pass. Don't worry about elegance, just make it work.

3. Refactor - Improve the code

Now that the test passes, improve the code while keeping the tests green. Remove duplication, improve names, optimize.

Testing Pyramid Testing pyramid: Majority of unit tests, some integration tests, few E2E

Environment Setup

Install dependencies

npm install -D vitest @testing-library/react @testing-library/jest-dom
npm install -D @testing-library/user-event @vitest/ui
npm install -D happy-dom jsdom
npm install -D @playwright/test

Configure Vitest

// vitest.config.ts
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',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'src/test/',
        '**/*.config.ts',
        '**/*.d.ts'
      ]
    }
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src')
    }
  }
})

Setup file

// src/test/setup.ts
import '@testing-library/jest-dom'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'

afterEach(() => {
  cleanup()
})

Unit Tests with Vitest

Example 1: Pure functions

Test first:

// src/lib/utils.test.ts
import { describe, it, expect } from 'vitest'
import { formatPrice, validateEmail } from './utils'

describe('formatPrice', () => {
  it('should format price in USD', () => {
    expect(formatPrice(1234.56, 'USD')).toBe('$1,234.56')
  })

  it('should format price in EUR', () => {
    expect(formatPrice(1234.56, 'EUR')).toBe('€1,234.56')
  })

  it('should handle zero', () => {
    expect(formatPrice(0, 'USD')).toBe('$0.00')
  })

  it('should handle negative values', () => {
    expect(formatPrice(-100, 'USD')).toBe('-$100.00')
  })
})

describe('validateEmail', () => {
  it('should validate correct email', () => {
    expect(validateEmail('test@example.com')).toBe(true)
  })

  it('should reject invalid email', () => {
    expect(validateEmail('invalid')).toBe(false)
    expect(validateEmail('test@')).toBe(false)
    expect(validateEmail('@example.com')).toBe(false)
  })

  it('should handle empty string', () => {
    expect(validateEmail('')).toBe(false)
  })
})

Implementation:

// src/lib/utils.ts

export function formatPrice(amount: number, currency: string): string {
  const formatter = new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: currency,
  })
  return formatter.format(amount)
}

export function validateEmail(email: string): boolean {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
  return emailRegex.test(email)
}

Example 2: Custom hooks

Test first:

// src/hooks/useCounter.test.ts
import { describe, it, expect } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useCounter } from './useCounter'

describe('useCounter', () => {
  it('should initialize with default value', () => {
    const { result } = renderHook(() => useCounter())
    expect(result.current.count).toBe(0)
  })

  it('should initialize with custom value', () => {
    const { result } = renderHook(() => useCounter(10))
    expect(result.current.count).toBe(10)
  })

  it('should increment counter', () => {
    const { result } = renderHook(() => useCounter(0))

    act(() => {
      result.current.increment()
    })

    expect(result.current.count).toBe(1)
  })

  it('should decrement counter', () => {
    const { result } = renderHook(() => useCounter(5))

    act(() => {
      result.current.decrement()
    })

    expect(result.current.count).toBe(4)
  })

  it('should reset counter', () => {
    const { result } = renderHook(() => useCounter(10))

    act(() => {
      result.current.increment()
      result.current.increment()
    })

    expect(result.current.count).toBe(12)

    act(() => {
      result.current.reset()
    })

    expect(result.current.count).toBe(10)
  })

  it('should not go below minimum value', () => {
    const { result } = renderHook(() => useCounter(0, { min: 0 }))

    act(() => {
      result.current.decrement()
    })

    expect(result.current.count).toBe(0)
  })
})

Implementation:

// src/hooks/useCounter.ts
import { useState, useCallback } from 'react'

interface UseCounterOptions {
  min?: number
  max?: number
}

export function useCounter(
  initialValue: number = 0,
  options: UseCounterOptions = {}
) {
  const [count, setCount] = useState(initialValue)
  const { min, max } = options

  const increment = useCallback(() => {
    setCount((prev) => {
      const next = prev + 1
      if (max !== undefined && next > max) return prev
      return next
    })
  }, [max])

  const decrement = useCallback(() => {
    setCount((prev) => {
      const next = prev - 1
      if (min !== undefined && next < min) return prev
      return next
    })
  }, [min])

  const reset = useCallback(() => {
    setCount(initialValue)
  }, [initialValue])

  return { count, increment, decrement, reset }
}

Component Tests with Testing Library

Example: Form component

Test first:

// src/components/LoginForm.test.tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LoginForm } from './LoginForm'

describe('LoginForm', () => {
  it('should render form fields', () => {
    render(<LoginForm onSubmit={vi.fn()} />)

    expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
    expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
    expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument()
  })

  it('should show validation errors for empty fields', async () => {
    const user = userEvent.setup()
    render(<LoginForm onSubmit={vi.fn()} />)

    const submitButton = screen.getByRole('button', { name: /login/i })
    await user.click(submitButton)

    expect(await screen.findByText(/email is required/i)).toBeInTheDocument()
    expect(await screen.findByText(/password is required/i)).toBeInTheDocument()
  })

  it('should show error for invalid email', async () => {
    const user = userEvent.setup()
    render(<LoginForm onSubmit={vi.fn()} />)

    const emailInput = screen.getByLabelText(/email/i)
    await user.type(emailInput, 'invalid-email')

    const submitButton = screen.getByRole('button', { name: /login/i })
    await user.click(submitButton)

    expect(await screen.findByText(/invalid email/i)).toBeInTheDocument()
  })

  it('should submit form with valid data', async () => {
    const user = userEvent.setup()
    const onSubmit = vi.fn()
    render(<LoginForm onSubmit={onSubmit} />)

    const emailInput = screen.getByLabelText(/email/i)
    const passwordInput = screen.getByLabelText(/password/i)

    await user.type(emailInput, 'test@example.com')
    await user.type(passwordInput, 'password123')

    const submitButton = screen.getByRole('button', { name: /login/i })
    await user.click(submitButton)

    expect(onSubmit).toHaveBeenCalledWith({
      email: 'test@example.com',
      password: 'password123'
    })
  })

  it('should disable submit button while loading', () => {
    render(<LoginForm onSubmit={vi.fn()} isLoading={true} />)

    const submitButton = screen.getByRole('button', { name: /login/i })
    expect(submitButton).toBeDisabled()
  })
})

Implementation:

// src/components/LoginForm.tsx
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'

const loginSchema = z.object({
  email: z.string().email('Invalid email'),
  password: z.string().min(6, 'Password must be at least 6 characters')
})

type LoginFormData = z.infer<typeof loginSchema>

interface LoginFormProps {
  onSubmit: (data: LoginFormData) => void
  isLoading?: boolean
}

export function LoginForm({ onSubmit, isLoading = false }: LoginFormProps) {
  const {
    register,
    handleSubmit,
    formState: { errors }
  } = useForm<LoginFormData>({
    resolver: zodResolver(loginSchema)
  })

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <div>
        <label htmlFor="email" className="block text-sm font-medium">
          Email
        </label>
        <input
          id="email"
          type="email"
          {...register('email')}
          className="mt-1 block w-full rounded-md border-gray-300"
        />
        {errors.email && (
          <p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="password" className="block text-sm font-medium">
          Password
        </label>
        <input
          id="password"
          type="password"
          {...register('password')}
          className="mt-1 block w-full rounded-md border-gray-300"
        />
        {errors.password && (
          <p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
        )}
      </div>

      <button
        type="submit"
        disabled={isLoading}
        className="w-full py-2 px-4 bg-blue-600 text-white rounded-md"
      >
        {isLoading ? 'Loading...' : 'Login'}
      </button>
    </form>
  )
}

Integration Tests

Example: Authentication flow

// src/features/auth/Auth.integration.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { AuthProvider, useAuth } from './AuthContext'
import { LoginPage } from './LoginPage'

const mockApi = {
  login: vi.fn()
}

function TestWrapper({ children }: { children: React.ReactNode }) {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: { retry: false },
      mutations: { retry: false }
    }
  })

  return (
    <QueryClientProvider client={queryClient}>
      <AuthProvider>
        {children}
      </AuthProvider>
    </QueryClientProvider>
  )
}

describe('Authentication Flow', () => {
  beforeEach(() => {
    mockApi.login.mockClear()
  })

  it('should complete full login flow', async () => {
    const user = userEvent.setup()

    mockApi.login.mockResolvedValue({
      token: 'fake-token',
      user: { id: '1', email: 'test@example.com' }
    })

    render(<LoginPage />, { wrapper: TestWrapper })

    // Fill form
    await user.type(screen.getByLabelText(/email/i), 'test@example.com')
    await user.type(screen.getByLabelText(/password/i), 'password123')

    // Submit
    await user.click(screen.getByRole('button', { name: /login/i }))

    // Wait for success
    await waitFor(() => {
      expect(mockApi.login).toHaveBeenCalledWith({
        email: 'test@example.com',
        password: 'password123'
      })
    })

    // Verify redirect or success message
    expect(await screen.findByText(/welcome/i)).toBeInTheDocument()
  })

  it('should show error message on failed login', async () => {
    const user = userEvent.setup()

    mockApi.login.mockRejectedValue(new Error('Invalid credentials'))

    render(<LoginPage />, { wrapper: TestWrapper })

    await user.type(screen.getByLabelText(/email/i), 'test@example.com')
    await user.type(screen.getByLabelText(/password/i), 'wrong-password')
    await user.click(screen.getByRole('button', { name: /login/i }))

    expect(await screen.findByText(/invalid credentials/i)).toBeInTheDocument()
  })
})

E2E Tests with Playwright

// e2e/auth.spec.ts
import { test, expect } from '@playwright/test'

test.describe('Authentication', () => {
  test('should login successfully', async ({ page }) => {
    await page.goto('http://localhost:3000/login')

    // Fill form
    await page.fill('input[name="email"]', 'test@example.com')
    await page.fill('input[name="password"]', 'password123')

    // Submit
    await page.click('button:has-text("Login")')

    // Wait for navigation
    await page.waitForURL('**/dashboard')

    // Verify logged in state
    await expect(page.locator('text=Welcome')).toBeVisible()
  })

  test('should show error for invalid credentials', async ({ page }) => {
    await page.goto('http://localhost:3000/login')

    await page.fill('input[name="email"]', 'test@example.com')
    await page.fill('input[name="password"]', 'wrong-password')
    await page.click('button:has-text("Login")')

    await expect(page.locator('text=Invalid credentials')).toBeVisible()
  })

  test('should logout successfully', async ({ page }) => {
    // Login first
    await page.goto('http://localhost:3000/login')
    await page.fill('input[name="email"]', 'test@example.com')
    await page.fill('input[name="password"]', 'password123')
    await page.click('button:has-text("Login")')
    await page.waitForURL('**/dashboard')

    // Logout
    await page.click('button:has-text("Logout")')

    // Verify redirected to login
    await page.waitForURL('**/login')
  })
})

Mocking Strategies

Module mocking

// src/services/api.test.ts
import { describe, it, expect, vi } from 'vitest'
import { fetchUser } from './api'

// Mock global fetch
global.fetch = vi.fn()

describe('fetchUser', () => {
  it('should fetch user data', async () => {
    const mockUser = { id: '1', name: 'John Doe' }

    ;(global.fetch as any).mockResolvedValueOnce({
      ok: true,
      json: async () => mockUser
    })

    const user = await fetchUser('1')

    expect(user).toEqual(mockUser)
    expect(global.fetch).toHaveBeenCalledWith('/api/users/1')
  })

  it('should throw error on failed request', async () => {
    ;(global.fetch as any).mockResolvedValueOnce({
      ok: false,
      status: 404
    })

    await expect(fetchUser('999')).rejects.toThrow('User not found')
  })
})

Mock Service Worker (MSW)

// src/mocks/handlers.ts
import { rest } from 'msw'

export const handlers = [
  rest.get('/api/products', (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json([
        { id: '1', name: 'Product 1', price: 100 },
        { id: '2', name: 'Product 2', price: 200 }
      ])
    )
  }),

  rest.post('/api/auth/login', (req, res, ctx) => {
    const { email, password } = req.body as any

    if (email === 'test@example.com' && password === 'password123') {
      return res(
        ctx.status(200),
        ctx.json({
          token: 'fake-jwt-token',
          user: { id: '1', email }
        })
      )
    }

    return res(
      ctx.status(401),
      ctx.json({ message: 'Invalid credentials' })
    )
  })
]

Test Coverage Report Code coverage report: Target 80%+ in critical projects

Best Practices

1. Descriptive tests

// ❌ WRONG
it('should work', () => {})

// ✅ CORRECT
it('should show error message when email is invalid', () => {})

2. Arrange-Act-Assert

it('should add item to cart', async () => {
  // Arrange
  const user = userEvent.setup()
  render(<ProductCard product={mockProduct} />)

  // Act
  await user.click(screen.getByRole('button', { name: /add to cart/i }))

  // Assert
  expect(screen.getByText(/added to cart/i)).toBeInTheDocument()
})

3. No brittle tests

// ❌ WRONG: Depends on implementation
expect(wrapper.find('.button').length).toBe(1)

// ✅ CORRECT: Depends on behavior
expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument()

4. Independent tests

// Each test should run independently
describe('UserList', () => {
  beforeEach(() => {
    // Reset state before each test
  })

  it('test 1', () => {})
  it('test 2', () => {})
})

Common Mistakes

❌ Testing implementation details

// ❌ WRONG
expect(component.state.count).toBe(5)

// ✅ CORRECT
expect(screen.getByText('Count: 5')).toBeInTheDocument()

❌ Tests that depend on execution order

// ❌ WRONG
let sharedState

it('test 1', () => {
  sharedState = 'value'
})

it('test 2', () => {
  expect(sharedState).toBe('value') // Brittle!
})

❌ Not cleaning up after tests

// ✅ CORRECT
afterEach(() => {
  cleanup()
  vi.clearAllMocks()
})

Conclusion

TDD in React with TypeScript is not just about writing tests, it's about designing better software. The Red-Green-Refactor cycle forces us to think about the API of our components before implementing them, resulting in more modular and maintainable code.

Key points:

  1. Write the test first (Red)
  2. Implement the minimum necessary (Green)
  3. Refactor with confidence (Refactor)
  4. Use Testing Library for user-centric tests
  5. Maintain high coverage but quality tests
  6. Mock only when necessary

Additional Resources


Do you practice TDD in your React projects? What challenges have you encountered? Share your experience on LinkedIn.

#React#TypeScript#TDD#Testing#Vitest

Did you like this article?

Share your thoughts on LinkedIn or contact me if you want to discuss these topics.

Fernando Caravaca - FullStack Developer