Ders İçeriği
Neden Test Yazmalıyız?
Test yazma, yazılım geliştirmenin en önemli aşamalarından biridir. React uygulamalarında test yazmanın birçok avantajı vardır. İlk olarak, kodunuzun doğru çalıştığından emin olabilirsiniz. Test yazarak, bileşenlerinizin beklenen şekilde davrandığını ve kullanıcı etkileşimlerine doğru tepki verdiğini doğrulayabilirsiniz.
İkinci olarak, refactoring işlemlerini güvenle yapabilirsiniz. Kodunuzu değiştirirken, mevcut testler size kodun hala beklendiği gibi çalıştığını garanti eder. Bu, özellikle büyük projelerde çok değerlidir çünkü bir değişikliğin beklenmedik yan etkilerini hemen fark edebilirsiniz.
Üçüncü olarak, testler dokümantasyon görevi görür. İyi yazılmış testler, kodunuzun nasıl kullanılması gerektiğini ve ne yapması gerektiğini açıkça gösterir. Yeni takım üyelerinin projeyi anlaması için testler çok değerli bir kaynak olabilir.
Son olarak, test yazma süreci sizi daha iyi kod yazmaya zorlar. Test edilebilir kod yazmak için kodunuzu daha modüler ve gevşek bağlı hale getirmeniz gerekir. Bu da genel olarak kod kalitesini artırır.
React ekosisteminde test yazma için çeşitli araçlar bulunur. En popüler olanları Jest (test runner ve assertion library), React Testing Library (React bileşenlerini test etmek için), Enzyme (alternatif testing utility), Cypress (end-to-end testing) ve MSW (Mock Service Worker - API mocking) dir.
Jest ile Unit Testing
Jest, Facebook tarafından geliştirilen ve React uygulamalarında varsayılan olarak gelen bir test framework'üdür. Jest, test runner, assertion library ve mocking capabilities'i tek bir pakette sunar.
Temel Jest Kullanımı
Jest'in temel syntax'ı oldukça basittir. describe
blokları test gruplarını, test
veya it
fonksiyonları ise bireysel testleri tanımlar.
// math.js
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
export function multiply(a, b) {
return a * b;
}
export function divide(a, b) {
if (b === 0) {
throw new Error('Division by zero is not allowed');
}
return a / b;
}
// math.test.js
import { add, subtract, multiply, divide } from './math';
describe('Math functions', () => {
describe('add', () => {
test('should add two positive numbers', () => {
expect(add(2, 3)).toBe(5);
});
test('should add positive and negative numbers', () => {
expect(add(5, -3)).toBe(2);
});
test('should add two negative numbers', () => {
expect(add(-2, -3)).toBe(-5);
});
test('should handle zero', () => {
expect(add(0, 5)).toBe(5);
expect(add(5, 0)).toBe(5);
expect(add(0, 0)).toBe(0);
});
});
describe('subtract', () => {
test('should subtract two numbers', () => {
expect(subtract(5, 3)).toBe(2);
});
test('should handle negative results', () => {
expect(subtract(3, 5)).toBe(-2);
});
});
describe('multiply', () => {
test('should multiply two numbers', () => {
expect(multiply(3, 4)).toBe(12);
});
test('should handle zero multiplication', () => {
expect(multiply(5, 0)).toBe(0);
expect(multiply(0, 5)).toBe(0);
});
});
describe('divide', () => {
test('should divide two numbers', () => {
expect(divide(10, 2)).toBe(5);
});
test('should handle decimal results', () => {
expect(divide(10, 3)).toBeCloseTo(3.333, 2);
});
test('should throw error when dividing by zero', () => {
expect(() => divide(10, 0)).toThrow('Division by zero is not allowed');
});
});
});
Jest Matchers
Jest, çeşitli matcher fonksiyonları sunar. Bu matcher'lar test assertion'larını yazmak için kullanılır:
// Equality matchers
expect(2 + 2).toBe(4); // Strict equality (===)
expect({ name: 'John' }).toEqual({ name: 'John' }); // Deep equality
// Truthiness matchers
expect(true).toBeTruthy();
expect(false).toBeFalsy();
expect(null).toBeNull();
expect(undefined).toBeUndefined();
expect('Hello').toBeDefined();
// Number matchers
expect(2 + 2).toBeGreaterThan(3);
expect(2 + 2).toBeGreaterThanOrEqual(4);
expect(2 + 2).toBeLessThan(5);
expect(2 + 2).toBeLessThanOrEqual(4);
expect(0.1 + 0.2).toBeCloseTo(0.3);
// String matchers
expect('Hello World').toMatch(/World/);
expect('Hello World').toMatch('World');
expect('Hello World').toContain('World');
// Array matchers
expect(['apple', 'banana', 'orange']).toContain('banana');
expect(['apple', 'banana']).toHaveLength(2);
// Exception matchers
expect(() => {
throw new Error('Something went wrong');
}).toThrow();
expect(() => {
throw new Error('Something went wrong');
}).toThrow('Something went wrong');
// Promise matchers
expect(Promise.resolve('success')).resolves.toBe('success');
expect(Promise.reject('error')).rejects.toBe('error');
Async Testing
Asenkron kodları test etmek için Jest çeşitli yöntemler sunar:
// utils/api.js
export async function fetchUser(id) {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error('User not found');
}
return response.json();
}
export function fetchUserCallback(id, callback) {
setTimeout(() => {
if (id === 1) {
callback(null, { id: 1, name: 'John Doe' });
} else {
callback(new Error('User not found'), null);
}
}, 100);
}
// utils/api.test.js
import { fetchUser, fetchUserCallback } from './api';
// Mock fetch
global.fetch = jest.fn();
describe('API functions', () => {
beforeEach(() => {
fetch.mockClear();
});
describe('fetchUser', () => {
test('should fetch user successfully', async () => {
const mockUser = { id: 1, name: 'John Doe' };
fetch.mockResolvedValueOnce({
ok: true,
json: async () => mockUser,
});
const user = await fetchUser(1);
expect(user).toEqual(mockUser);
expect(fetch).toHaveBeenCalledWith('/api/users/1');
});
test('should throw error when user not found', async () => {
fetch.mockResolvedValueOnce({
ok: false,
});
await expect(fetchUser(999)).rejects.toThrow('User not found');
});
// Alternative syntax using resolves/rejects
test('should fetch user with resolves matcher', () => {
const mockUser = { id: 1, name: 'John Doe' };
fetch.mockResolvedValueOnce({
ok: true,
json: async () => mockUser,
});
return expect(fetchUser(1)).resolves.toEqual(mockUser);
});
});
describe('fetchUserCallback', () => {
test('should call callback with user data', (done) => {
fetchUserCallback(1, (error, user) => {
expect(error).toBeNull();
expect(user).toEqual({ id: 1, name: 'John Doe' });
done();
});
});
test('should call callback with error', (done) => {
fetchUserCallback(999, (error, user) => {
expect(error).toBeInstanceOf(Error);
expect(error.message).toBe('User not found');
expect(user).toBeNull();
done();
});
});
});
});
Mocking
Jest'in güçlü mocking özellikleri, bağımlılıkları izole etmenize ve kontrollü test ortamları oluşturmanıza olanak tanır:
// services/emailService.js
export class EmailService {
async sendEmail(to, subject, body) {
// External email service call
const response = await fetch('/api/send-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to, subject, body })
});
if (!response.ok) {
throw new Error('Failed to send email');
}
return response.json();
}
}
// services/userService.js
import { EmailService } from './emailService';
export class UserService {
constructor(emailService = new EmailService()) {
this.emailService = emailService;
}
async registerUser(userData) {
// Validate user data
if (!userData.email || !userData.name) {
throw new Error('Email and name are required');
}
// Save user to database (mocked)
const user = {
id: Date.now(),
...userData,
createdAt: new Date().toISOString()
};
// Send welcome email
try {
await this.emailService.sendEmail(
user.email,
'Welcome!',
`Hello ${user.name}, welcome to our platform!`
);
} catch (error) {
console.error('Failed to send welcome email:', error);
// Don't fail registration if email fails
}
return user;
}
}
// services/userService.test.js
import { UserService } from './userService';
import { EmailService } from './emailService';
// Mock the entire EmailService module
jest.mock('./emailService');
describe('UserService', () => {
let userService;
let mockEmailService;
beforeEach(() => {
// Create a mock instance
mockEmailService = new EmailService();
mockEmailService.sendEmail = jest.fn();
userService = new UserService(mockEmailService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('registerUser', () => {
test('should register user successfully', async () => {
const userData = { name: 'John Doe', email: 'john@example.com' };
mockEmailService.sendEmail.mockResolvedValue({ success: true });
const user = await userService.registerUser(userData);
expect(user).toMatchObject({
name: 'John Doe',
email: 'john@example.com'
});
expect(user.id).toBeDefined();
expect(user.createdAt).toBeDefined();
expect(mockEmailService.sendEmail).toHaveBeenCalledWith(
'john@example.com',
'Welcome!',
'Hello John Doe, welcome to our platform!'
);
});
test('should throw error for invalid user data', async () => {
const invalidUserData = { name: 'John Doe' }; // Missing email
await expect(userService.registerUser(invalidUserData))
.rejects.toThrow('Email and name are required');
expect(mockEmailService.sendEmail).not.toHaveBeenCalled();
});
test('should register user even if email fails', async () => {
const userData = { name: 'John Doe', email: 'john@example.com' };
mockEmailService.sendEmail.mockRejectedValue(new Error('Email service down'));
// Spy on console.error to verify error logging
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
const user = await userService.registerUser(userData);
expect(user).toMatchObject({
name: 'John Doe',
email: 'john@example.com'
});
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to send welcome email:',
expect.any(Error)
);
consoleSpy.mockRestore();
});
});
});
// Manual mocking example
describe('UserService with manual mocks', () => {
test('should work with manual mock', async () => {
const mockEmailService = {
sendEmail: jest.fn().mockResolvedValue({ success: true })
};
const userService = new UserService(mockEmailService);
const userData = { name: 'Jane Doe', email: 'jane@example.com' };
const user = await userService.registerUser(userData);
expect(user.name).toBe('Jane Doe');
expect(mockEmailService.sendEmail).toHaveBeenCalledTimes(1);
});
});
React Testing Library
React Testing Library, React bileşenlerini test etmek için tasarlanmış bir kütüphanedir. "The more your tests resemble the way your software is used, the more confidence they can give you" prensibine dayanır.
Temel Bileşen Testleri
React Testing Library ile basit bileşen testleri yazmak:
// components/Button.js
import React from 'react';
function Button({
children,
onClick,
disabled = false,
variant = 'primary',
loading = false,
...props
}) {
const baseClass = 'btn';
const variantClass = `btn-${variant}`;
const disabledClass = disabled ? 'btn-disabled' : '';
const loadingClass = loading ? 'btn-loading' : '';
const className = [baseClass, variantClass, disabledClass, loadingClass]
.filter(Boolean)
.join(' ');
return (
<button
className={className}
onClick={onClick}
disabled={disabled || loading}
{...props}
>
{loading ? 'Loading...' : children}
</button>
);
}
export default Button;
// components/Button.test.js
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Button from './Button';
describe('Button', () => {
test('renders button with text', () => {
render(<Button>Click me</Button>);
const button = screen.getByRole('button', { name: /click me/i });
expect(button).toBeInTheDocument();
});
test('calls onClick when clicked', async () => {
const handleClick = jest.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick}>Click me</Button>);
const button = screen.getByRole('button', { name: /click me/i });
await user.click(button);
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('is disabled when disabled prop is true', () => {
render(<Button disabled>Click me</Button>);
const button = screen.getByRole('button', { name: /click me/i });
expect(button).toBeDisabled();
});
test('does not call onClick when disabled', async () => {
const handleClick = jest.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick} disabled>Click me</Button>);
const button = screen.getByRole('button', { name: /click me/i });
await user.click(button);
expect(handleClick).not.toHaveBeenCalled();
});
test('shows loading text when loading', () => {
render(<Button loading>Click me</Button>);
const button = screen.getByRole('button', { name: /loading/i });
expect(button).toBeInTheDocument();
expect(button).toBeDisabled();
});
test('applies correct CSS classes', () => {
const { rerender } = render(<Button variant="secondary">Click me</Button>);
let button = screen.getByRole('button');
expect(button).toHaveClass('btn', 'btn-secondary');
rerender(<Button variant="primary" disabled>Click me</Button>);
button = screen.getByRole('button');
expect(button).toHaveClass('btn', 'btn-primary', 'btn-disabled');
});
});
Form Bileşeni Testleri
Daha karmaşık form bileşenlerini test etmek:
// components/ContactForm.js
import React, { useState } from 'react';
function ContactForm({ onSubmit, loading = false }) {
const [formData, setFormData] = useState({
name: '',
email: '',
message: ''
});
const [errors, setErrors] = useState({});
const validateForm = () => {
const newErrors = {};
if (!formData.name.trim()) {
newErrors.name = 'Name is required';
}
if (!formData.email.trim()) {
newErrors.email = 'Email is required';
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
newErrors.email = 'Email is invalid';
}
if (!formData.message.trim()) {
newErrors.message = 'Message is required';
} else if (formData.message.length < 10) {
newErrors.message = 'Message must be at least 10 characters';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e) => {
e.preventDefault();
if (validateForm()) {
onSubmit(formData);
}
};
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
// Clear error when user starts typing
if (errors[name]) {
setErrors(prev => ({
...prev,
[name]: ''
}));
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="name">Name</label>
<input
id="name"
name="name"
type="text"
value={formData.name}
onChange={handleChange}
aria-invalid={errors.name ? 'true' : 'false'}
aria-describedby={errors.name ? 'name-error' : undefined}
/>
{errors.name && (
<div id="name-error" role="alert">
{errors.name}
</div>
)}
</div>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
aria-invalid={errors.email ? 'true' : 'false'}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<div id="email-error" role="alert">
{errors.email}
</div>
)}
</div>
<div>
<label htmlFor="message">Message</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
aria-invalid={errors.message ? 'true' : 'false'}
aria-describedby={errors.message ? 'message-error' : undefined}
/>
{errors.message && (
<div id="message-error" role="alert">
{errors.message}
</div>
)}
</div>
<button type="submit" disabled={loading}>
{loading ? 'Sending...' : 'Send Message'}
</button>
</form>
);
}
export default ContactForm;
// components/ContactForm.test.js
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ContactForm from './ContactForm';
describe('ContactForm', () => {
const mockOnSubmit = jest.fn();
beforeEach(() => {
mockOnSubmit.mockClear();
});
test('renders all form fields', () => {
render(<ContactForm onSubmit={mockOnSubmit} />);
expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/message/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /send message/i })).toBeInTheDocument();
});
test('submits form with valid data', async () => {
const user = userEvent.setup();
render(<ContactForm onSubmit={mockOnSubmit} />);
const nameInput = screen.getByLabelText(/name/i);
const emailInput = screen.getByLabelText(/email/i);
const messageInput = screen.getByLabelText(/message/i);
const submitButton = screen.getByRole('button', { name: /send message/i });
await user.type(nameInput, 'John Doe');
await user.type(emailInput, 'john@example.com');
await user.type(messageInput, 'This is a test message that is long enough.');
await user.click(submitButton);
expect(mockOnSubmit).toHaveBeenCalledWith({
name: 'John Doe',
email: 'john@example.com',
message: 'This is a test message that is long enough.'
});
});
test('shows validation errors for empty fields', async () => {
const user = userEvent.setup();
render(<ContactForm onSubmit={mockOnSubmit} />);
const submitButton = screen.getByRole('button', { name: /send message/i });
await user.click(submitButton);
expect(screen.getByText('Name is required')).toBeInTheDocument();
expect(screen.getByText('Email is required')).toBeInTheDocument();
expect(screen.getByText('Message is required')).toBeInTheDocument();
expect(mockOnSubmit).not.toHaveBeenCalled();
});
test('shows validation error for invalid email', async () => {
const user = userEvent.setup();
render(<ContactForm onSubmit={mockOnSubmit} />);
const emailInput = screen.getByLabelText(/email/i);
const submitButton = screen.getByRole('button', { name: /send message/i });
await user.type(emailInput, 'invalid-email');
await user.click(submitButton);
expect(screen.getByText('Email is invalid')).toBeInTheDocument();
expect(mockOnSubmit).not.toHaveBeenCalled();
});
test('shows validation error for short message', async () => {
const user = userEvent.setup();
render(<ContactForm onSubmit={mockOnSubmit} />);
const messageInput = screen.getByLabelText(/message/i);
const submitButton = screen.getByRole('button', { name: /send message/i });
await user.type(messageInput, 'Short');
await user.click(submitButton);
expect(screen.getByText('Message must be at least 10 characters')).toBeInTheDocument();
expect(mockOnSubmit).not.toHaveBeenCalled();
});
test('clears error when user starts typing', async () => {
const user = userEvent.setup();
render(<ContactForm onSubmit={mockOnSubmit} />);
const nameInput = screen.getByLabelText(/name/i);
const submitButton = screen.getByRole('button', { name: /send message/i });
// Trigger validation error
await user.click(submitButton);
expect(screen.getByText('Name is required')).toBeInTheDocument();
// Start typing to clear error
await user.type(nameInput, 'J');
await waitFor(() => {
expect(screen.queryByText('Name is required')).not.toBeInTheDocument();
});
});
test('disables submit button when loading', () => {
render(<ContactForm onSubmit={mockOnSubmit} loading={true} />);
const submitButton = screen.getByRole('button', { name: /sending/i });
expect(submitButton).toBeDisabled();
});
test('has proper accessibility attributes', async () => {
const user = userEvent.setup();
render(<ContactForm onSubmit={mockOnSubmit} />);
const submitButton = screen.getByRole('button', { name: /send message/i });
await user.click(submitButton);
const nameInput = screen.getByLabelText(/name/i);
const nameError = screen.getByText('Name is required');
expect(nameInput).toHaveAttribute('aria-invalid', 'true');
expect(nameInput).toHaveAttribute('aria-describedby', 'name-error');
expect(nameError).toHaveAttribute('role', 'alert');
});
});
Hook Testleri
Custom hook'ları test etmek için @testing-library/react-hooks
kullanabilirsiniz:
// hooks/useCounter.js
import { useState, useCallback } from 'react';
export function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = useCallback(() => {
setCount(prev => prev + 1);
}, []);
const decrement = useCallback(() => {
setCount(prev => prev - 1);
}, []);
const reset = useCallback(() => {
setCount(initialValue);
}, [initialValue]);
const setValue = useCallback((value) => {
setCount(value);
}, []);
return {
count,
increment,
decrement,
reset,
setValue
};
}
// hooks/useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter', () => {
test('should initialize with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
test('should initialize with custom value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test('should increment count', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('should decrement count', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
test('should reset to initial value', () => {
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);
});
test('should set specific value', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.setValue(42);
});
expect(result.current.count).toBe(42);
});
test('should update initial value when prop changes', () => {
const { result, rerender } = renderHook(
({ initialValue }) => useCounter(initialValue),
{ initialProps: { initialValue: 0 } }
);
expect(result.current.count).toBe(0);
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
rerender({ initialValue: 10 });
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(10);
});
});
React'te test yazma, uygulamanızın kalitesini ve güvenilirliğini artıran kritik bir süreçtir. Jest ile unit testler yazarak fonksiyonlarınızın doğru çalıştığından emin olabilir, React Testing Library ile bileşenlerinizin kullanıcı perspektifinden doğru davrandığını doğrulayabilirsiniz. İyi test coverage'a sahip olmak, kodunuzu refactor ederken güven verir ve hataları erken aşamada yakalamanıza yardımcı olur.