Testing y Optimización
Configuración de Testing
Jest Configuration
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapping: {
'^@/(.*)$': '<rootDir>/src/$1'
},
transform: {
'^.+\\.(js|jsx|ts|tsx)$': ['babel-jest', { presets: ['next/babel'] }]
},
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.d.ts',
'!src/**/*.stories.{js,jsx,ts,tsx}'
]
};
Setup File
// jest.setup.js
import '@testing-library/jest-dom';
import { TextEncoder, TextDecoder } from 'util';
global.TextEncoder = TextEncoder;
global.TextDecoder = TextDecoder;
// Mock Next.js router
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: jest.fn(),
replace: jest.fn(),
back: jest.fn()
}),
usePathname: () => '/test-path'
}));
Tests de Componentes
Test de Componente Básico
// __tests__/components/PropertyCard.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { PropertyCard } from '@/components/properties/PropertyCard';
const mockProperty = {
id: '1',
title: 'Casa de Prueba',
price: 1000000,
location: 'Bogotá',
images: ['test-image.jpg']
};
describe('PropertyCard', () => {
it('should render property information', () => {
render(<PropertyCard property={mockProperty} />);
expect(screen.getByText('Casa de Prueba')).toBeInTheDocument();
expect(screen.getByText('$1,000,000')).toBeInTheDocument();
expect(screen.getByText('Bogotá')).toBeInTheDocument();
});
it('should call onEdit when edit button is clicked', () => {
const onEdit = jest.fn();
render(<PropertyCard property={mockProperty} onEdit={onEdit} />);
fireEvent.click(screen.getByRole('button', { name: /editar/i }));
expect(onEdit).toHaveBeenCalledWith(mockProperty.id);
});
it('should display property image', () => {
render(<PropertyCard property={mockProperty} />);
const image = screen.getByRole('img');
expect(image).toHaveAttribute('alt', 'Casa de Prueba');
});
});
Test con Redux
// __tests__/components/UserProfile.test.tsx
import { render, screen } from '@testing-library/react';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import { UserProfile } from '@/components/UserProfile';
import authSlice from '@/store/slices/authSlice';
const createMockStore = (initialState: any) => {
return configureStore({
reducer: {
auth: authSlice
},
preloadedState: initialState
});
};
describe('UserProfile', () => {
it('should display user information when authenticated', () => {
const mockStore = createMockStore({
auth: {
user: { name: 'Juan Pérez', email: 'juan@test.com' },
isAuthenticated: true
}
});
render(
<Provider store={mockStore}>
<UserProfile />
</Provider>
);
expect(screen.getByText('Juan Pérez')).toBeInTheDocument();
expect(screen.getByText('juan@test.com')).toBeInTheDocument();
});
});
Tests de Hooks
Test de useApi Hook
// __tests__/hooks/useApi.test.ts
import { renderHook } from '@testing-library/react';
import { useApi } from '@/hooks/useApi';
import axios from 'axios';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
describe('useApi', () => {
beforeEach(() => {
mockedAxios.create.mockReturnValue(mockedAxios);
});
it('should make GET request with correct parameters', async () => {
const mockResponse = { data: { success: true, data: [] } };
mockedAxios.mockResolvedValue(mockResponse);
const { result } = renderHook(() => useApi());
await result.current.get_req('properties/all');
expect(mockedAxios).toHaveBeenCalledWith({
method: 'GET',
url: expect.stringContaining('properties/all'),
headers: expect.objectContaining({
'Content-Type': 'application/json'
})
});
});
});
Test de Custom Hook con Estado
// __tests__/hooks/useLocalStorage.test.ts
import { renderHook, act } from '@testing-library/react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
describe('useLocalStorage', () => {
beforeEach(() => {
localStorage.clear();
});
it('should initialize with default value', () => {
const { result } = renderHook(() =>
useLocalStorage('test-key', 'default-value')
);
expect(result.current[0]).toBe('default-value');
});
it('should update localStorage when value changes', () => {
const { result } = renderHook(() =>
useLocalStorage('test-key', 'initial')
);
act(() => {
result.current[1]('updated');
});
expect(localStorage.getItem('test-key')).toBe('"updated"');
expect(result.current[0]).toBe('updated');
});
});
Performance Optimization
Lazy Loading de Componentes
// Dynamic imports para code splitting
import dynamic from 'next/dynamic';
const ChatBot = dynamic(() => import('@/components/chatBot/ChatBot'), {
ssr: false,
loading: () => (
<Box p={4}>
<Skeleton height="400px" />
</Box>
)
});
const PropertyGallery = dynamic(
() => import('@/components/properties/PropertyGallery'),
{
ssr: false,
loading: () => <Spinner />
}
);
// Lazy loading con condición
const AdminPanel = dynamic(
() => import('@/components/admin/AdminPanel'),
{
ssr: false,
loading: () => <Skeleton height="200px" />
}
);
Memoización Estratégica
// React.memo para componentes puros
export const PropertyCard = React.memo<PropertyCardProps>(({
property,
onEdit,
onDelete
}) => {
return (
<Card>
<CardBody>
<Text>{property.title}</Text>
<Text>{property.price}</Text>
</CardBody>
<CardFooter>
<Button onClick={() => onEdit(property.id)}>Editar</Button>
<Button onClick={() => onDelete(property.id)}>Eliminar</Button>
</CardFooter>
</Card>
);
}, (prevProps, nextProps) => {
// Comparación personalizada
return (
prevProps.property.id === nextProps.property.id &&
prevProps.property.title === nextProps.property.title &&
prevProps.property.price === nextProps.property.price
);
});
// useMemo para cálculos costosos
const PropertyList = ({ properties }: { properties: Property[] }) => {
const totalValue = useMemo(() => {
return properties.reduce((acc, prop) => acc + prop.price, 0);
}, [properties]);
const sortedProperties = useMemo(() => {
return [...properties].sort((a, b) => b.price - a.price);
}, [properties]);
return (
<VStack>
<Text>Valor total: ${totalValue.toLocaleString()}</Text>
{sortedProperties.map(property => (
<PropertyCard key={property.id} property={property} />
))}
</VStack>
);
};
Optimización de Imágenes
// Usando Next.js Image con optimización
import Image from 'next/image';
const PropertyImage = ({ src, alt }: { src: string; alt: string }) => {
return (
<Image
src={src}
alt={alt}
width={300}
height={200}
priority={false}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..."
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
style={{
objectFit: 'cover',
borderRadius: '8px'
}}
/>
);
};
Code Splitting
Route-based Splitting
// app/admin/properties/page.tsx
import dynamic from 'next/dynamic';
const PropertiesContainer = dynamic(
() => import('@/containers/PropertiesContainer'),
{
loading: () => <PageSkeleton />,
ssr: true
}
);
export default function PropertiesPage() {
return <PropertiesContainer />;
}
Component-based Splitting
// Cargar componentes bajo demanda
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
const AdvancedFilters = dynamic(
() => import('@/components/AdvancedFilters'),
{ ssr: false }
);
return (
<VStack>
<Button onClick={() => setShowAdvancedFilters(true)}>
Mostrar Filtros Avanzados
</Button>
{showAdvancedFilters && (
<Suspense fallback={<Skeleton height="200px" />}>
<AdvancedFilters />
</Suspense>
)}
</VStack>
);
Bundle Analysis
Configuración de Análisis
// next.config.mjs
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
const nextConfig = {
// ... otras configuraciones
};
module.exports = withBundleAnalyzer(nextConfig);
Scripts de Análisis
{
"scripts": {
"analyze": "ANALYZE=true next build",
"analyze:server": "BUNDLE_ANALYZE=server next build",
"analyze:browser": "BUNDLE_ANALYZE=browser next build"
}
}
Web Vitals
Medición de Performance
// app/layout.tsx
import { Analytics } from '@vercel/analytics/react';
import { SpeedInsights } from '@vercel/speed-insights/next';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html>
<body>
{children}
<Analytics />
<SpeedInsights />
</body>
</html>
);
}
Custom Web Vitals
// utils/webVitals.ts
export function reportWebVitals(metric: any) {
switch (metric.name) {
case 'CLS':
console.log('Cumulative Layout Shift:', metric.value);
break;
case 'FID':
console.log('First Input Delay:', metric.value);
break;
case 'FCP':
console.log('First Contentful Paint:', metric.value);
break;
case 'LCP':
console.log('Largest Contentful Paint:', metric.value);
break;
case 'TTFB':
console.log('Time to First Byte:', metric.value);
break;
}
}
Error Monitoring
Error Boundary Avanzado
// components/ErrorBoundary.tsx
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
errorInfo?: React.ErrorInfo;
}
export class ErrorBoundary extends React.Component<
React.PropsWithChildren<{ fallback?: React.ComponentType<any> }>,
ErrorBoundaryState
> {
constructor(props: any) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
this.setState({ error, errorInfo });
// Enviar error a servicio de monitoreo
console.error('Error caught by boundary:', error, errorInfo);
// En producción, enviar a Sentry o similar
if (process.env.NODE_ENV === 'production') {
// Sentry.captureException(error, { extra: errorInfo });
}
}
render() {
if (this.state.hasError) {
const FallbackComponent = this.props.fallback || DefaultErrorFallback;
return <FallbackComponent error={this.state.error} />;
}
return this.props.children;
}
}
Scripts de Testing
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:ci": "jest --ci --coverage --watchAll=false",
"lighthouse": "lighthouse http://localhost:3000 --output html"
}
}