React Dasar

Testing React Components

Testing komponen React dengan Jest dan React Testing Library

Kenapa Testing Penting?

Testing membantu kita:

  • Memastikan komponen bekerja sesuai ekspektasi
  • Mencegah regresi saat melakukan perubahan
  • Dokumentasi hidup tentang bagaimana komponen seharusnya bekerja
  • Meningkatkan confidence saat refactoring

Testing Philosophy

React Testing Library mendorong testing yang fokus pada user behavior bukan implementation details. Test bagaimana user berinteraksi dengan aplikasi, bukan internal state.

Setup Testing Environment

Create React App sudah include Jest dan React Testing Library:

Install Testing Library (jika belum ada)
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event

Struktur Test File

Konvensi penamaan test files:

  • ComponentName.test.js atau ComponentName.spec.js
  • Letakkan di folder __tests__ atau sejajar dengan component
src/
├── components/
│   ├── TaskItem/
│   │   ├── TaskItem.jsx
│   │   ├── TaskItem.css
│   │   └── TaskItem.test.jsx

Testing TaskItem Component

src/components/TaskItem/TaskItem.test.jsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import TaskItem from './TaskItem';

describe('TaskItem Component', () => {
  const mockTask = {
    id: 1,
    title: 'Test Task',
    completed: false
  };

  const mockOnDelete = jest.fn();
  const mockOnToggleComplete = jest.fn();

  beforeEach(() => {
    // Reset mocks before each test
    jest.clearAllMocks();
  });

  test('renders task title', () => {
    render(
      <TaskItem
        task={mockTask}
        onDelete={mockOnDelete}
        onToggleComplete={mockOnToggleComplete}
      />
    );

    expect(screen.getByText('Test Task')).toBeInTheDocument();
  });

  test('renders unchecked checkbox for incomplete task', () => {
    render(
      <TaskItem
        task={mockTask}
        onDelete={mockOnDelete}
        onToggleComplete={mockOnToggleComplete}
      />
    );

    const checkbox = screen.getByRole('checkbox');
    expect(checkbox).not.toBeChecked();
  });

  test('renders checked checkbox for completed task', () => {
    const completedTask = { ...mockTask, completed: true };

    render(
      <TaskItem
        task={completedTask}
        onDelete={mockOnDelete}
        onToggleComplete={mockOnToggleComplete}
      />
    );

    const checkbox = screen.getByRole('checkbox');
    expect(checkbox).toBeChecked();
  });

  test('calls onToggleComplete when checkbox is clicked', () => {
    render(
      <TaskItem
        task={mockTask}
        onDelete={mockOnDelete}
        onToggleComplete={mockOnToggleComplete}
      />
    );

    const checkbox = screen.getByRole('checkbox');
    fireEvent.click(checkbox);

    expect(mockOnToggleComplete).toHaveBeenCalledTimes(1);
    expect(mockOnToggleComplete).toHaveBeenCalledWith(1);
  });

  test('calls onDelete when delete button is clicked', () => {
    render(
      <TaskItem
        task={mockTask}
        onDelete={mockOnDelete}
        onToggleComplete={mockOnToggleComplete}
      />
    );

    const deleteButton = screen.getByText('Delete');
    fireEvent.click(deleteButton);

    expect(mockOnDelete).toHaveBeenCalledTimes(1);
    expect(mockOnDelete).toHaveBeenCalledWith(1);
  });

  test('applies completed class when task is completed', () => {
    const completedTask = { ...mockTask, completed: true };

    render(
      <TaskItem
        task={completedTask}
        onDelete={mockOnDelete}
        onToggleComplete={mockOnToggleComplete}
      />
    );

    const taskItem = screen.getByText('Test Task').closest('.task-item');
    expect(taskItem).toHaveClass('completed');
  });
});

Testing TaskForm Component

src/components/TaskForm/TaskForm.test.jsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TaskForm from './TaskForm';

describe('TaskForm Component', () => {
  const mockOnAddTask = jest.fn();

  beforeEach(() => {
    jest.clearAllMocks();
  });

  test('renders form with input and button', () => {
    render(<TaskForm onAddTask={mockOnAddTask} />);

    expect(screen.getByPlaceholderText('Tambahkan tugas baru...')).toBeInTheDocument();
    expect(screen.getByText('Tambah')).toBeInTheDocument();
  });

  test('allows user to type in input', async () => {
    const user = userEvent.setup();
    render(<TaskForm onAddTask={mockOnAddTask} />);

    const input = screen.getByPlaceholderText('Tambahkan tugas baru...');

    await user.type(input, 'New Task');

    expect(input).toHaveValue('New Task');
  });

  test('calls onAddTask with new task when form is submitted', async () => {
    const user = userEvent.setup();
    render(<TaskForm onAddTask={mockOnAddTask} />);

    const input = screen.getByPlaceholderText('Tambahkan tugas baru...');
    const button = screen.getByText('Tambah');

    await user.type(input, 'New Task');
    await user.click(button);

    expect(mockOnAddTask).toHaveBeenCalledTimes(1);
    expect(mockOnAddTask).toHaveBeenCalledWith({
      id: expect.any(Number),
      title: 'New Task',
      completed: false
    });
  });

  test('clears input after form submission', async () => {
    const user = userEvent.setup();
    render(<TaskForm onAddTask={mockOnAddTask} />);

    const input = screen.getByPlaceholderText('Tambahkan tugas baru...');
    const button = screen.getByText('Tambah');

    await user.type(input, 'New Task');
    await user.click(button);

    expect(input).toHaveValue('');
  });

  test('does not call onAddTask when input is empty', async () => {
    const user = userEvent.setup();
    render(<TaskForm onAddTask={mockOnAddTask} />);

    const button = screen.getByText('Tambah');
    await user.click(button);

    expect(mockOnAddTask).not.toHaveBeenCalled();
  });

  test('does not call onAddTask when input contains only whitespace', async () => {
    const user = userEvent.setup();
    render(<TaskForm onAddTask={mockOnAddTask} />);

    const input = screen.getByPlaceholderText('Tambahkan tugas baru...');
    const button = screen.getByText('Tambah');

    await user.type(input, '   ');
    await user.click(button);

    expect(mockOnAddTask).not.toHaveBeenCalled();
  });
});

Query Methods

React Testing Library menyediakan berbagai query methods:

// Throw error jika tidak ditemukan
// Gunakan untuk element yang pasti ada
const button = screen.getByRole('button');
const input = screen.getByPlaceholderText('Enter name');
const text = screen.getByText('Hello');
// Return null jika tidak ditemukan
// Gunakan untuk assert element tidak ada
const button = screen.queryByRole('button');
expect(button).not.toBeInTheDocument();
// Return Promise, untuk element yang async
// Gunakan untuk element yang muncul setelah loading
const button = await screen.findByRole('button');

Testing dengan Context

src/context/TaskContext.test.jsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TaskProvider, useTasks } from './TaskContext';

// Helper component untuk test context
function TestComponent() {
  const { tasks, addTask, stats } = useTasks();

  return (
    <div>
      <p>Total: {stats.total}</p>
      <button onClick={() => addTask({ id: 1, title: 'Test', completed: false })}>
        Add Task
      </button>
      {tasks.map(task => (
        <div key={task.id}>{task.title}</div>
      ))}
    </div>
  );
}

describe('TaskContext', () => {
  test('provides tasks and stats', () => {
    render(
      <TaskProvider>
        <TestComponent />
      </TaskProvider>
    );

    expect(screen.getByText('Total: 0')).toBeInTheDocument();
  });

  test('allows adding tasks', async () => {
    const user = userEvent.setup();

    render(
      <TaskProvider>
        <TestComponent />
      </TaskProvider>
    );

    const button = screen.getByText('Add Task');
    await user.click(button);

    expect(screen.getByText('Test')).toBeInTheDocument();
    expect(screen.getByText('Total: 1')).toBeInTheDocument();
  });

  test('throws error when used outside provider', () => {
    // Suppress console.error untuk test ini
    const spy = jest.spyOn(console, 'error').mockImplementation(() => {});

    expect(() => {
      render(<TestComponent />);
    }).toThrow('useTasks must be used within a TaskProvider');

    spy.mockRestore();
  });
});

Testing Custom Hooks

npm install --save-dev @testing-library/react-hooks
src/hooks/useLocalStorage.test.js
import { renderHook, act } from '@testing-library/react-hooks';
import useLocalStorage from './useLocalStorage';

describe('useLocalStorage', () => {
  beforeEach(() => {
    localStorage.clear();
  });

  test('initializes with default value', () => {
    const { result } = renderHook(() =>
      useLocalStorage('test-key', 'default value')
    );

    expect(result.current[0]).toBe('default value');
  });

  test('loads existing value from localStorage', () => {
    localStorage.setItem('test-key', JSON.stringify('existing value'));

    const { result } = renderHook(() =>
      useLocalStorage('test-key', 'default value')
    );

    expect(result.current[0]).toBe('existing value');
  });

  test('updates localStorage when value changes', () => {
    const { result } = renderHook(() =>
      useLocalStorage('test-key', 'initial')
    );

    act(() => {
      result.current[1]('updated');
    });

    expect(result.current[0]).toBe('updated');
    expect(localStorage.getItem('test-key')).toBe(JSON.stringify('updated'));
  });

  test('handles complex objects', () => {
    const { result } = renderHook(() =>
      useLocalStorage('test-key', { count: 0 })
    );

    act(() => {
      result.current[1]({ count: 5 });
    });

    expect(result.current[0]).toEqual({ count: 5 });
  });
});

Mocking

Mocking Functions

// Mock function
const mockFn = jest.fn();

// Mock dengan return value
const mockFn = jest.fn().mockReturnValue(42);

// Mock dengan implementation
const mockFn = jest.fn((x) => x * 2);

// Assertions
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenCalledWith('arg');

Mocking Modules

// Mock entire module
jest.mock('./api');

// Mock specific module exports
jest.mock('./api', () => ({
  fetchTasks: jest.fn().mockResolvedValue([])
}));

// Mock localStorage
const localStorageMock = {
  getItem: jest.fn(),
  setItem: jest.fn(),
  clear: jest.fn()
};
global.localStorage = localStorageMock;

Testing Async Code

test('loads tasks from API', async () => {
  const mockTasks = [
    { id: 1, title: 'Task 1', completed: false }
  ];

  // Mock fetch
  global.fetch = jest.fn().mockResolvedValue({
    ok: true,
    json: async () => mockTasks
  });

  render(<TaskList />);

  // Wait for tasks to appear
  const task = await screen.findByText('Task 1');
  expect(task).toBeInTheDocument();
});

Coverage Report

Run tests with coverage
npm test -- --coverage

Atau tambahkan script di package.json:

{
  "scripts": {
    "test": "react-scripts test",
    "test:coverage": "npm test -- --coverage --watchAll=false"
  }
}

Best Practices

  1. Test user behavior - Bukan implementation details
  2. Use accessible queries - Prefer getByRole, getByLabelText
  3. Avoid testing library internals - Test props/output, bukan state
  4. Write descriptive test names - "should do X when Y happens"
  5. One assertion per test - Keep tests focused
  6. Use userEvent - Lebih realistis dari fireEvent
  7. Clean up - Reset mocks dengan beforeEach
  8. Mock external dependencies - API calls, localStorage, etc.

Common Testing Patterns

Testing Forms

test('validates email input', async () => {
  const user = userEvent.setup();
  render(<LoginForm />);

  const emailInput = screen.getByLabelText(/email/i);
  const submitButton = screen.getByRole('button', { name: /submit/i });

  await user.type(emailInput, 'invalid-email');
  await user.click(submitButton);

  expect(screen.getByText(/invalid email/i)).toBeInTheDocument();
});

Testing Conditional Rendering

test('shows error message when loading fails', async () => {
  global.fetch = jest.fn().mockRejectedValue(new Error('Failed'));

  render(<TaskList />);

  const error = await screen.findByText(/error/i);
  expect(error).toBeInTheDocument();
});

Testing Navigation

import { MemoryRouter } from 'react-router-dom';

test('navigates to about page', async () => {
  const user = userEvent.setup();

  render(
    <MemoryRouter>
      <App />
    </MemoryRouter>
  );

  const aboutLink = screen.getByText(/about/i);
  await user.click(aboutLink);

  expect(screen.getByText(/about task manager/i)).toBeInTheDocument();
});

Run Tests

# Run all tests
npm test

# Run tests in watch mode
npm test -- --watch

# Run specific test file
npm test TaskItem.test.jsx

# Run with coverage
npm test -- --coverage --watchAll=false

Testing Confidence

Dengan testing yang baik, Kalian bisa refactor dan menambah fitur dengan lebih confident. Testing adalah investasi yang worth it untuk project jangka panjang!

Latihan

  1. Buat tests untuk Home component
  2. Test integration antara TaskForm dan TaskItem
  3. Test error handling di custom hooks
  4. Achieve 80%+ code coverage
  5. Test routing dengan MemoryRouter