Testing Strategy and Implementation ๐Ÿงช

Build reliable reptile care software with comprehensive testing. A guide to testing philosophy, tools, and best practices in CrittrHavens.

Close-up of a programmer pointing at a colorful code script on a laptop in an office setting

Testing Philosophy

Quality through automated verification.

Testing Principles

Core Values:

  • Test Early, Test Often: Catch bugs before they multiply
  • Automate Everything: Manual testing doesn't scale
  • Test Behavior, Not Implementation: Focus on what, not how
  • Fast Feedback: Tests should run quickly
  • Clear Failures: Know exactly what broke

Testing Pyramid

Balanced Test Suite:

         /\
        /E2E\        5% - End-to-end tests
       /------\
      /Integr.\     15% - Integration tests
     /----------\
    /   Unit     \  80% - Unit tests
   /--------------\

Coverage Goals

Target Metrics:

  • Overall Coverage: 80% minimum
  • Critical Paths: 100% coverage
  • Business Logic: 90% coverage
  • UI Components: 70% coverage
  • Utilities: 95% coverage

Unit Testing with Vitest

Fast, modern testing for JavaScript.

Vitest Setup

Configuration:

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: './src/test/setup.ts',
    coverage: {
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'src/test/',
      ],
    },
  },
});

Test Setup File:

// src/test/setup.ts
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';

// Cleanup after each test
afterEach(() => {
  cleanup();
});

// Mock environment variables
process.env.VITE_SUPABASE_URL = 'http://localhost:54321';
process.env.VITE_SUPABASE_ANON_KEY = 'test-key';

Writing Unit Tests

Basic Test Structure:

import { describe, it, expect, vi } from 'vitest';
import { calculateFeedingSchedule } from '@/lib/feeding';

describe('calculateFeedingSchedule', () => {
  it('should calculate weekly feeding for juveniles', () => {
    const schedule = calculateFeedingSchedule({
      species: 'Ball Python',
      age: 6, // months
      weight: 200, // grams
    });
    
    expect(schedule.frequency).toBe('weekly');
    expect(schedule.preySize).toBe('small rat');
  });
  
  it('should calculate bi-weekly feeding for adults', () => {
    const schedule = calculateFeedingSchedule({
      species: 'Ball Python',
      age: 36, // months
      weight: 1500, // grams
    });
    
    expect(schedule.frequency).toBe('bi-weekly');
    expect(schedule.preySize).toBe('medium rat');
  });
  
  it('should throw error for unknown species', () => {
    expect(() => 
      calculateFeedingSchedule({
        species: 'Unknown',
        age: 12,
        weight: 500,
      })
    ).toThrow('Unknown species');
  });
});

Testing Hooks

Custom Hook Tests:

import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useCrittrs } from '@/hooks/useCrittrs';

describe('useCrittrs', () => {
  const createWrapper = () => {
    const queryClient = new QueryClient({
      defaultOptions: {
        queries: { retry: false },
      },
    });
    
    return ({ children }) => (
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    );
  };
  
  it('should fetch crittrs for haven', async () => {
    const { result } = renderHook(
      () => useCrittrs('haven-123'),
      { wrapper: createWrapper() }
    );
    
    await waitFor(() => {
      expect(result.current.isSuccess).toBe(true);
    });
    
    expect(result.current.data).toHaveLength(3);
    expect(result.current.data[0]).toHaveProperty('name');
  });
});

Component Testing

Testing React components with React Testing Library.

Component Test Setup

Basic Component Test:

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

describe('CrittrCard', () => {
  const mockCrittr = {
    id: '123',
    name: 'Sunny',
    species: 'Ball Python',
    morph: 'Banana',
    imageUrl: '/sunny.jpg',
  };
  
  it('should render crittr information', () => {
    render(<CrittrCard {...mockCrittr} />);
    
    expect(screen.getByText('Sunny')).toBeInTheDocument();
    expect(screen.getByText('Ball Python')).toBeInTheDocument();
    expect(screen.getByText('Banana')).toBeInTheDocument();
  });
  
  it('should handle click events', async () => {
    const handleClick = vi.fn();
    const user = userEvent.setup();
    
    render(<CrittrCard {...mockCrittr} onClick={handleClick} />);
    
    await user.click(screen.getByText('Sunny'));
    
    expect(handleClick).toHaveBeenCalledWith('123');
  });
  
  it('should show loading skeleton', () => {
    render(<CrittrCard isLoading />);
    
    expect(screen.getByTestId('crittr-skeleton')).toBeInTheDocument();
  });
});

Testing Forms

Form Component Test:

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

describe('CrittrForm', () => {
  it('should validate required fields', async () => {
    const user = userEvent.setup();
    render(<CrittrForm />);
    
    // Submit without filling fields
    await user.click(screen.getByRole('button', { name: /save/i }));
    
    // Check for error messages
    await waitFor(() => {
      expect(screen.getByText('Name is required')).toBeInTheDocument();
      expect(screen.getByText('Species is required')).toBeInTheDocument();
    });
  });
  
  it('should submit valid form', async () => {
    const handleSubmit = vi.fn();
    const user = userEvent.setup();
    
    render(<CrittrForm onSubmit={handleSubmit} />);
    
    // Fill form fields
    await user.type(screen.getByLabelText('Name'), 'Sunny');
    await user.selectOptions(
      screen.getByLabelText('Species'),
      'Ball Python'
    );
    
    // Submit form
    await user.click(screen.getByRole('button', { name: /save/i }));
    
    await waitFor(() => {
      expect(handleSubmit).toHaveBeenCalledWith({
        name: 'Sunny',
        species: 'Ball Python',
      });
    });
  });
});

Testing Async Components

Async Behavior:

import { render, screen, waitFor } from '@testing-library/react';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { CrittrList } from '@/components/CrittrList';

// Mock API
const server = setupServer(
  rest.get('/api/crittrs', (req, res, ctx) => {
    return res(
      ctx.json([
        { id: '1', name: 'Sunny' },
        { id: '2', name: 'Luna' },
      ])
    );
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

describe('CrittrList', () => {
  it('should load and display crittrs', async () => {
    render(<CrittrList />);
    
    // Check loading state
    expect(screen.getByText('Loading...')).toBeInTheDocument();
    
    // Wait for data
    await waitFor(() => {
      expect(screen.getByText('Sunny')).toBeInTheDocument();
      expect(screen.getByText('Luna')).toBeInTheDocument();
    });
  });
  
  it('should handle API errors', async () => {
    server.use(
      rest.get('/api/crittrs', (req, res, ctx) => {
        return res(ctx.status(500));
      })
    );
    
    render(<CrittrList />);
    
    await waitFor(() => {
      expect(screen.getByText('Failed to load crittrs')).toBeInTheDocument();
    });
  });
});

Integration Testing

Testing feature workflows.

Database Integration Tests

Testing with Real Database:

import { createClient } from '@supabase/supabase-js';
import { CrittrService } from '@/services/CrittrService';

describe('CrittrService Integration', () => {
  let supabase;
  let service;
  
  beforeAll(() => {
    supabase = createClient(
      process.env.TEST_SUPABASE_URL,
      process.env.TEST_SUPABASE_KEY
    );
    service = new CrittrService(supabase);
  });
  
  afterEach(async () => {
    // Clean up test data
    await supabase.from('crittrs').delete().eq('name', 'Test Crittr');
  });
  
  it('should create and retrieve crittr', async () => {
    // Create
    const created = await service.createCrittr({
      name: 'Test Crittr',
      species: 'Ball Python',
      haven_id: 'test-haven',
    });
    
    expect(created.id).toBeDefined();
    
    // Retrieve
    const retrieved = await service.getCrittr(created.id);
    
    expect(retrieved.name).toBe('Test Crittr');
    expect(retrieved.species).toBe('Ball Python');
  });
  
  it('should update crittr', async () => {
    const crittr = await service.createCrittr({
      name: 'Test Crittr',
      species: 'Ball Python',
    });
    
    const updated = await service.updateCrittr(crittr.id, {
      morph: 'Pastel',
    });
    
    expect(updated.morph).toBe('Pastel');
  });
});

API Integration Tests

Testing API Endpoints:

import request from 'supertest';
import { app } from '@/server';

describe('API Integration', () => {
  describe('POST /api/crittrs', () => {
    it('should create new crittr', async () => {
      const response = await request(app)
        .post('/api/crittrs')
        .send({
          name: 'Sunny',
          species: 'Ball Python',
        })
        .expect(201);
      
      expect(response.body).toHaveProperty('id');
      expect(response.body.name).toBe('Sunny');
    });
    
    it('should validate input', async () => {
      const response = await request(app)
        .post('/api/crittrs')
        .send({ name: '' }) // Invalid
        .expect(400);
      
      expect(response.body.errors).toContain('Name is required');
    });
  });
});

E2E Testing with Playwright

Testing complete user journeys.

Playwright Setup

Configuration:

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  
  use: {
    baseURL: 'http://localhost:8080',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
    { name: 'mobile', use: { ...devices['iPhone 13'] } },
  ],
});

E2E Test Examples

User Journey Test:

import { test, expect } from '@playwright/test';

test.describe('Crittr Management', () => {
  test.beforeEach(async ({ page }) => {
    // Login
    await page.goto('/login');
    await page.fill('[name="email"]', 'test@example.com');
    await page.fill('[name="password"]', 'password');
    await page.click('button[type="submit"]');
    
    await expect(page).toHaveURL('/dashboard');
  });
  
  test('should add new crittr', async ({ page }) => {
    // Navigate to add crittr
    await page.click('text=Add Crittr');
    
    // Fill form
    await page.fill('[name="name"]', 'Sunny');
    await page.selectOption('[name="species"]', 'Ball Python');
    await page.fill('[name="morph"]', 'Banana');
    
    // Upload photo
    await page.setInputFiles('[type="file"]', 'test-assets/snake.jpg');
    
    // Submit
    await page.click('text=Save Crittr');
    
    // Verify success
    await expect(page).toHaveURL(/\/crittrs\/\w+/);
    await expect(page.locator('h1')).toHaveText('Sunny');
  });
  
  test('should log feeding', async ({ page }) => {
    // Go to crittr page
    await page.goto('/crittrs/123');
    
    // Open feeding form
    await page.click('text=Log Feeding');
    
    // Fill details
    await page.selectOption('[name="prey_type"]', 'rat');
    await page.selectOption('[name="prey_size"]', 'small');
    await page.check('[name="accepted"]');
    
    // Submit
    await page.click('text=Log Feeding');
    
    // Verify in list
    await expect(page.locator('.feeding-log').first()).toContainText('Small rat');
  });
});

Mobile E2E Tests

Mobile-Specific Tests:

test.describe('Mobile Experience', () => {
  test.use({ ...devices['iPhone 13'] });
  
  test('should navigate with bottom nav', async ({ page }) => {
    await page.goto('/');
    
    // Use bottom navigation
    await page.click('[aria-label="Tasks"]');
    await expect(page).toHaveURL('/tasks');
    
    await page.click('[aria-label="Inventory"]');
    await expect(page).toHaveURL('/inventory');
  });
  
  test('should handle swipe gestures', async ({ page }) => {
    await page.goto('/crittrs');
    
    // Swipe to delete
    const crittrCard = page.locator('.crittr-card').first();
    await crittrCard.swipe('left');
    
    // Confirm delete
    await page.click('text=Delete');
    
    await expect(crittrCard).not.toBeVisible();
  });
});

Mocking Strategies

Isolating units under test.

Mocking Modules

Module Mocks:

// Mock Supabase client
vi.mock('@/integrations/supabase/client', () => ({
  supabase: {
    from: vi.fn(() => ({
      select: vi.fn(() => ({
        eq: vi.fn(() => Promise.resolve({
          data: [{ id: '1', name: 'Sunny' }],
          error: null,
        })),
      })),
    })),
    auth: {
      getSession: vi.fn(() => Promise.resolve({
        data: { session: { user: { id: 'user-123' } } },
      })),
    },
  },
}));

Mocking Hooks

Hook Mocks:

vi.mock('@/hooks/useAuth', () => ({
  useAuth: () => ({
    user: { id: 'user-123', email: 'test@example.com' },
    isLoading: false,
    isAuthenticated: true,
    signOut: vi.fn(),
  }),
}));

Mocking External Services

Service Mocks:

// Mock fetch
global.fetch = vi.fn(() =>
  Promise.resolve({
    ok: true,
    json: () => Promise.resolve({ data: 'mocked' }),
  })
);

// Mock timers
vi.useFakeTimers();

// Advance time
vi.advanceTimersByTime(1000);

// Mock date
vi.setSystemTime(new Date('2024-01-01'));

Test File Organization

Structuring test files.

File Structure

Test Organization:

src/
โ”œโ”€โ”€ components/
โ”‚   โ”œโ”€โ”€ CrittrCard.tsx
โ”‚   โ””โ”€โ”€ CrittrCard.test.tsx
โ”œโ”€โ”€ hooks/
โ”‚   โ”œโ”€โ”€ useCrittrs.ts
โ”‚   โ””โ”€โ”€ useCrittrs.test.ts
โ”œโ”€โ”€ lib/
โ”‚   โ”œโ”€โ”€ utils.ts
โ”‚   โ””โ”€โ”€ utils.test.ts
โ””โ”€โ”€ test/
    โ”œโ”€โ”€ setup.ts
    โ”œโ”€โ”€ mocks/
    โ””โ”€โ”€ fixtures/

e2e/
โ”œโ”€โ”€ auth.spec.ts
โ”œโ”€โ”€ crittr-management.spec.ts
โ””โ”€โ”€ helpers/

Test Naming

Naming Conventions:

// Describe blocks: Component/Function name
describe('CrittrCard', () => {
  // Context blocks: Specific scenarios
  describe('when loading', () => {
    // Test cases: Should statements
    it('should show skeleton', () => {});
  });
  
  describe('when data is loaded', () => {
    it('should display crittr name', () => {});
    it('should handle click events', () => {});
  });
});

Running and Debugging

Executing and troubleshooting tests.

Test Commands

NPM Scripts:

{
  "scripts": {
    "test": "vitest",
    "test:ui": "vitest --ui",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage",
    "test:watch": "vitest --watch",
    "test:debug": "vitest --inspect-brk --inspect",
    "test:e2e": "playwright test",
    "test:e2e:ui": "playwright test --ui",
    "test:e2e:debug": "playwright test --debug"
  }
}

Debugging Tests

Debug Techniques:

// Console debugging
it('should calculate correctly', () => {
  const result = calculate(2, 3);
  console.log('Result:', result); // Debug output
  expect(result).toBe(5);
});

// Debugger statement
it('should handle complex logic', () => {
  debugger; // Pause here in debug mode
  const result = complexFunction();
  expect(result).toBeDefined();
});

// Playwright debug
test('user flow', async ({ page }) => {
  await page.pause(); // Pause for manual inspection
  await page.click('button');
});

CI/CD Test Automation

Automated testing in pipelines.

GitHub Actions

Test Workflow:

# .github/workflows/test.yml
name: Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Run unit tests
        run: npm run test:run
      
      - name: Run E2E tests
        run: |
          npx playwright install
          npm run test:e2e
      
      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/coverage-final.json

Writing Effective Tests

Test quality best practices.

Good Test Principles

FIRST Principles:

  • Fast: Tests run quickly
  • Independent: No test depends on another
  • Repeatable: Same result every time
  • Self-validating: Pass or fail clearly
  • Timely: Written with or before code

Test Examples

Good Test:

it('should calculate adult feeding schedule for ball python over 3 years old', () => {
  const schedule = calculateFeedingSchedule({
    species: 'Ball Python',
    ageMonths: 48,
    weightGrams: 1500,
  });
  
  expect(schedule).toEqual({
    frequency: 'bi-weekly',
    preySize: 'medium rat',
    quantity: 1,
  });
});

Bad Test:

// Too vague
it('should work', () => {
  const result = doSomething();
  expect(result).toBeTruthy();
});

Test Data

Test Fixtures:

// fixtures/crittrs.ts
export const mockCrittrs = {
  juvenile: {
    id: '1',
    name: 'Baby',
    species: 'Ball Python',
    ageMonths: 6,
    weightGrams: 200,
  },
  adult: {
    id: '2',
    name: 'Adult',
    species: 'Ball Python',
    ageMonths: 48,
    weightGrams: 1500,
  },
};

// Use in tests
import { mockCrittrs } from '@/test/fixtures/crittrs';

it('should handle juvenile', () => {
  const result = process(mockCrittrs.juvenile);
  // ...
});

Common Testing Patterns

Reusable testing solutions.

Test Utilities

// test/utils.ts
export function renderWithProviders(ui, options = {}) {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: { retry: false },
    },
  });
  
  return render(
    <QueryClientProvider client={queryClient}>
      {ui}
    </QueryClientProvider>,
    options
  );
}

Snapshot Testing

it('should match snapshot', () => {
  const { container } = render(<CrittrCard {...mockCrittr} />);
  expect(container).toMatchSnapshot();
});

Accessibility Testing

it('should be accessible', async () => {
  const { container } = render(<CrittrForm />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

Next Steps

Continue improving test coverage.


Testing today prevents debugging tomorrow. Especially when tracking 50 reptiles. ๐ŸฆŽ