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:
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-eventStruktur Test File
Konvensi penamaan test files:
ComponentName.test.jsatauComponentName.spec.js- Letakkan di folder
__tests__atau sejajar dengan component
src/
├── components/
│ ├── TaskItem/
│ │ ├── TaskItem.jsx
│ │ ├── TaskItem.css
│ │ └── TaskItem.test.jsxTesting TaskItem Component
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
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
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-hooksimport { 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
npm test -- --coverageAtau tambahkan script di package.json:
{
"scripts": {
"test": "react-scripts test",
"test:coverage": "npm test -- --coverage --watchAll=false"
}
}Best Practices
- Test user behavior - Bukan implementation details
- Use accessible queries - Prefer
getByRole,getByLabelText - Avoid testing library internals - Test props/output, bukan state
- Write descriptive test names - "should do X when Y happens"
- One assertion per test - Keep tests focused
- Use userEvent - Lebih realistis dari fireEvent
- Clean up - Reset mocks dengan beforeEach
- 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=falseTesting 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
- Buat tests untuk Home component
- Test integration antara TaskForm dan TaskItem
- Test error handling di custom hooks
- Achieve 80%+ code coverage
- Test routing dengan MemoryRouter
