Skip to main content

Testing Guide

The T1 Component Library uses Jest and React Testing Library for comprehensive component testing. This guide covers the testing setup, patterns, and best practices.

Testing Setup

Jest Configuration

The test configuration is defined in jest.config.js:1:
const config = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
  testMatch: ['**/__tests__/**/*.test.ts?(x)', '**/*.test.ts?(x)'],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/app/$1',
    '\\.(css|less|scss|sass)$': 'identity-obj-proxy',
  },
  transform: {
    '^.+\\.(ts|tsx)$': ['ts-jest', {
      tsconfig: {
        jsx: 'react-jsx',
        esModuleInterop: true,
        allowSyntheticDefaultImports: true,
      },
    }],
  },
};

Coverage Requirements

The project enforces 80% coverage threshold across all metrics (jest.config.js:33):
coverageThreshold: {
  global: {
    branches: 80,
    functions: 80,
    lines: 80,
    statements: 80,
  },
}

Test Setup File

The jest.setup.ts:1 file configures the testing environment:
import '@testing-library/jest-dom';

// Mock matchMedia for theme tests
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: jest.fn().mockImplementation((query) => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: jest.fn(),
    removeListener: jest.fn(),
    addEventListener: jest.fn(),
    removeEventListener: jest.fn(),
    dispatchEvent: jest.fn(),
  })),
});

// Mock createPortal for Modal tests
jest.mock('react-dom', () => ({
  ...jest.requireActual('react-dom'),
  createPortal: (node: React.ReactNode) => node,
}));

// Mock next/navigation
jest.mock('next/navigation', () => ({
  usePathname: () => '/',
  useRouter: () => ({
    push: jest.fn(),
    replace: jest.fn(),
    prefetch: jest.fn(),
  }),
}));

Running Tests

The package.json defines three test commands (package.json:10):
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  }
}

Run all tests

npm test

Run tests in watch mode

npm run test:watch

Run tests with coverage report

npm run test:coverage

Test Structure

Organizing Tests

Tests are organized in __tests__/ directories with descriptive describe blocks:
// __tests__/components/Button.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from '../../components/Button';

describe('Button Component', () => {
  describe('Renderizado', () => {
    it('renderiza correctamente con texto', () => {
      render(<Button>Click me</Button>);
      expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
    });
  });

  describe('Props - Variantes', () => {
    // Variant tests...
  });

  describe('Interacciones', () => {
    // Interaction tests...
  });
});

Testing Patterns

Testing Rendering

From Button.test.tsx:7:
describe('Renderizado', () => {
  it('renderiza correctamente con texto', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
  });

  it('renderiza con displayName correcto', () => {
    expect(Button.displayName).toBe('Button');
  });

  it('renderiza children correctamente', () => {
    render(<Button><span data-testid="child">Child Element</span></Button>);
    expect(screen.getByTestId('child')).toBeInTheDocument();
  });
});

Testing Variants

From Button.test.tsx:23:
describe('Props - Variantes', () => {
  it('aplica estilo primary por defecto', () => {
    render(<Button>Primary Button</Button>);
    const button = screen.getByRole('button');
    expect(button).toHaveClass('bg-primary');
  });

  it('aplica estilo secondary correctamente', () => {
    render(<Button variant="secondary">Secondary</Button>);
    const button = screen.getByRole('button');
    expect(button).toHaveClass('bg-secondary');
  });

  it('aplica estilo destructive correctamente', () => {
    render(<Button variant="destructive">Delete</Button>);
    const button = screen.getByRole('button');
    expect(button).toHaveClass('bg-destructive');
  });
});

Testing Sizes

From Button.test.tsx:61:
describe('Props - Tamaños', () => {
  it('aplica tamaño md por defecto', () => {
    render(<Button>Medium</Button>);
    const button = screen.getByRole('button');
    expect(button).toHaveClass('px-4', 'py-2', 'text-base');
  });

  it('aplica tamaño sm correctamente', () => {
    render(<Button size="sm">Small</Button>);
    const button = screen.getByRole('button');
    expect(button).toHaveClass('px-3', 'py-1.5', 'text-sm');
  });
});

Testing Interactions

From Button.test.tsx:81:
describe('Interacciones', () => {
  it('ejecuta onClick cuando se hace click', async () => {
    const handleClick = jest.fn();
    render(<Button onClick={handleClick}>Click me</Button>);
    
    await userEvent.click(screen.getByRole('button'));
    
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it('no ejecuta onClick cuando está disabled', async () => {
    const handleClick = jest.fn();
    render(<Button onClick={handleClick} disabled>Disabled</Button>);
    
    await userEvent.click(screen.getByRole('button'));
    
    expect(handleClick).not.toHaveBeenCalled();
  });
});

Testing Loading States

From Button.test.tsx:110:
describe('Estado Loading', () => {
  it('muestra spinner cuando isLoading es true', () => {
    render(<Button isLoading>Loading</Button>);
    const button = screen.getByRole('button');
    const spinner = button.querySelector('svg.animate-spin');
    expect(spinner).toBeInTheDocument();
  });

  it('está deshabilitado cuando isLoading es true', () => {
    render(<Button isLoading>Loading</Button>);
    expect(screen.getByRole('button')).toBeDisabled();
  });

  it('no muestra iconos cuando isLoading es true', () => {
    render(
      <Button 
        isLoading 
        leftIcon={<span data-testid="left-icon">L</span>}
        rightIcon={<span data-testid="right-icon">R</span>}
      >
        Loading
      </Button>
    );
    expect(screen.queryByTestId('left-icon')).not.toBeInTheDocument();
    expect(screen.queryByTestId('right-icon')).not.toBeInTheDocument();
  });
});

Testing Icons

From Button.test.tsx:138:
describe('Iconos', () => {
  it('renderiza leftIcon correctamente', () => {
    render(
      <Button leftIcon={<span data-testid="left-icon"></span>}>
        With Left Icon
      </Button>
    );
    expect(screen.getByTestId('left-icon')).toBeInTheDocument();
  });

  it('renderiza ambos iconos correctamente', () => {
    render(
      <Button 
        leftIcon={<span data-testid="left-icon"></span>}
        rightIcon={<span data-testid="right-icon"></span>}
      >
        Both Icons
      </Button>
    );
    expect(screen.getByTestId('left-icon')).toBeInTheDocument();
    expect(screen.getByTestId('right-icon')).toBeInTheDocument();
  });
});

Testing Ref Forwarding

From Button.test.tsx:187:
describe('Ref Forwarding', () => {
  it('pasa ref correctamente al elemento button', () => {
    const ref = React.createRef<HTMLButtonElement>();
    render(<Button ref={ref}>Button with ref</Button>);
    expect(ref.current).toBeInstanceOf(HTMLButtonElement);
  });
});

Testing Custom Classes

From Button.test.tsx:180:
describe('Custom className', () => {
  it('aplica className adicional correctamente', () => {
    render(<Button className="custom-class">Custom</Button>);
    expect(screen.getByRole('button')).toHaveClass('custom-class');
  });
});

Testing Composite Components

From Card.test.tsx:249:
describe('Card Composición Completa', () => {
  it('renderiza todos los subcomponentes juntos correctamente', () => {
    render(
      <Card data-testid="card">
        <CardImage src="/test.jpg" alt="Test" data-testid="card-image" />
        <CardHeader data-testid="card-header">Title</CardHeader>
        <CardBody data-testid="card-body">Body content</CardBody>
        <CardFooter data-testid="card-footer">Footer actions</CardFooter>
      </Card>
    );

    expect(screen.getByTestId('card')).toBeInTheDocument();
    expect(screen.getByTestId('card-image')).toBeInTheDocument();
    expect(screen.getByTestId('card-header')).toHaveTextContent('Title');
    expect(screen.getByTestId('card-body')).toHaveTextContent('Body content');
    expect(screen.getByTestId('card-footer')).toHaveTextContent('Footer actions');
  });
});

Best Practices

1. Use Semantic Queries

Prefer role-based queries over test IDs:
// Good
const button = screen.getByRole('button', { name: /submit/i });

// Acceptable for non-semantic elements
const icon = screen.getByTestId('icon');

2. Test User Behavior

Focus on how users interact with components:
it('submits form on button click', async () => {
  const onSubmit = jest.fn();
  render(<Form onSubmit={onSubmit} />);
  
  await userEvent.type(screen.getByLabelText(/email/i), '[email protected]');
  await userEvent.click(screen.getByRole('button', { name: /submit/i }));
  
  expect(onSubmit).toHaveBeenCalledWith({ email: '[email protected]' });
});

3. Test Accessibility

it('has proper aria labels', () => {
  render(<Button aria-label="Close dialog">×</Button>);
  expect(screen.getByLabelText('Close dialog')).toBeInTheDocument();
});

4. Test Edge Cases

it('handles empty children gracefully', () => {
  render(<Card />);
  expect(screen.getByRole('article')).toBeEmptyDOMElement();
});

it('handles very long text', () => {
  const longText = 'a'.repeat(1000);
  render(<Button>{longText}</Button>);
  expect(screen.getByRole('button')).toHaveTextContent(longText);
});

5. Organize Tests Logically

Group related tests in describe blocks:
describe('Button Component', () => {
  describe('Rendering', () => { /* ... */ });
  describe('Variants', () => { /* ... */ });
  describe('Sizes', () => { /* ... */ });
  describe('Interactions', () => { /* ... */ });
  describe('States', () => { /* ... */ });
});

Coverage Reports

View coverage reports after running:
npm run test:coverage
Coverage reports are generated in the coverage/ directory:
  • coverage/lcov-report/index.html - Visual HTML report
  • coverage/coverage-final.json - JSON report

Common Testing Utilities

Custom Render Function

// test-utils.tsx
import { render } from '@testing-library/react';
import { ThemeProvider } from '@/context/ThemeContext';

export function renderWithTheme(ui: React.ReactElement) {
  return render(
    <ThemeProvider>
      {ui}
    </ThemeProvider>
  );
}

Reusable Test Helpers

// test-helpers.ts
export function createMockEvent(overrides = {}) {
  return {
    preventDefault: jest.fn(),
    stopPropagation: jest.fn(),
    ...overrides,
  };
}

export async function waitForLoadingToFinish() {
  await waitFor(() => {
    expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
  });
}

Debugging Tests

Using screen.debug()

it('debug test', () => {
  render(<Button>Test</Button>);
  screen.debug(); // Prints DOM to console
});

Using logRoles

import { logRoles } from '@testing-library/react';

it('shows available roles', () => {
  const { container } = render(<Button>Test</Button>);
  logRoles(container);
});

Example: Complete Component Test

import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from '@/components/Button';

describe('Button Component', () => {
  describe('Rendering', () => {
    it('renders with text', () => {
      render(<Button>Click me</Button>);
      expect(screen.getByRole('button')).toHaveTextContent('Click me');
    });
  });

  describe('Variants', () => {
    it('applies primary variant by default', () => {
      render(<Button>Primary</Button>);
      expect(screen.getByRole('button')).toHaveClass('bg-primary');
    });
  });

  describe('Interactions', () => {
    it('calls onClick handler', async () => {
      const handleClick = jest.fn();
      render(<Button onClick={handleClick}>Click</Button>);
      
      await userEvent.click(screen.getByRole('button'));
      
      expect(handleClick).toHaveBeenCalledTimes(1);
    });
  });

  describe('Accessibility', () => {
    it('is keyboard accessible', async () => {
      const handleClick = jest.fn();
      render(<Button onClick={handleClick}>Submit</Button>);
      
      const button = screen.getByRole('button');
      button.focus();
      await userEvent.keyboard('{Enter}');
      
      expect(handleClick).toHaveBeenCalled();
    });
  });
});

Build docs developers (and LLMs) love