Skip to content

Latest commit

 

History

History
434 lines (316 loc) · 9.81 KB

File metadata and controls

434 lines (316 loc) · 9.81 KB

Unit Testing Guide

Unit tests verify individual functions, classes, and components in isolation from external dependencies.

Overview

  • Framework (Frontend): Vitest + React Testing Library
  • Framework (Backend): pytest + pytest-mock
  • Location: tests/unit/ directory
  • Mocking: Mock all external dependencies (DB, APIs, file system)
  • Coverage: Must maintain ≥95% coverage

Writing Backend Unit Tests (Python)

Basic Structure

import pytest
from unittest.mock import Mock, AsyncMock
from services.user_service import UserService

pytestmark = pytest.mark.unit  # Mark as unit test


class TestUserService:
    """Test suite for UserService class."""

    @pytest.fixture
    def mock_db(self):
        """Create a mock database service."""
        db = Mock()
        db.get = Mock(return_value={"id": "test-123"})
        db.save = Mock(return_value={"rev": "test-rev"})
        return db

    @pytest.fixture
    def user_service(self, mock_db):
        """Create a UserService instance with mock database."""
        return UserService(mock_db)

    def test_get_user_existing(self, user_service, mock_db):
        """Test getting an existing user."""
        user = user_service.get_user("test-123")

        mock_db.get.assert_called_once_with("users", "test-123")
        assert user.id == "test-123"

    def test_get_user_not_found(self, user_service, mock_db):
        """Test handling of user not found."""
        from fastapi import HTTPException
        mock_db.get.side_effect = HTTPException(status_code=404)

        user = user_service.get_user("new-user")

        assert user.id == "new-user"
        mock_db.save.assert_called_once()  # Creates new user

Using Test Factories

from tests.factories.user_factory import UserFactory

def test_user_creation():
    """Test user creation with factory data."""
    user_data = UserFactory.build()

    assert "uid" in user_data
    assert "email" in user_data
    assert user_data["email_verified"] == True

def test_multiple_users():
    """Create multiple test users."""
    users = UserFactory.build_batch(5)

    assert len(users) == 5
    assert all(u["email"] for u in users)

Testing Async Functions

import pytest

pytestmark = pytest.mark.asyncio  # Enable async support


async def test_async_function():
    """Test an async function."""
    result = await my_async_function()
    assert result == expected_value


async def test_with_async_mock(mocker):
    """Test with async mocked dependency."""
    mock_service = mocker.AsyncMock()
    mock_service.fetch_data.return_value = {"data": "test"}

    result = await function_using_service(mock_service)

    mock_service.fetch_data.assert_called_once()
    assert result["data"] == "test"

Testing Models (Pydantic)

import pytest
from pydantic import ValidationError
from models.user import User, BasicInfo

def test_user_creation_minimal():
    """Test creating a User with minimal required fields."""
    user = User(_id="test-123")

    assert user.id == "test-123"
    assert user.rev is None

def test_user_validation_fails_without_id():
    """Test that User requires an ID."""
    with pytest.raises(ValidationError):
        User()

def test_user_from_dict():
    """Test creating User from dictionary."""
    data = {
        "_id": "test-123",
        "basicInfo": {"displayName": "Test User"}
    }
    user = User(**data)

    assert user.id == "test-123"
    assert user.basic_info.display_name == "Test User"

Writing Frontend Unit Tests (JavaScript/React)

Basic Component Test

import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ActionCard from './ActionCard';
import { Settings as SettingsIcon } from '@mui/icons-material';

describe('ActionCard', () => {
  const defaultProps = {
    icon: <SettingsIcon />,
    title: 'Test Action',
    description: 'Test description',
    buttonText: 'Click Me',
    onClick: vi.fn(),
  };

  it('renders with required props', () => {
    render(<ActionCard {...defaultProps} />);

    expect(screen.getByText('Test Action')).toBeInTheDocument();
    expect(screen.getByText('Test description')).toBeInTheDocument();
  });

  it('calls onClick when button is clicked', async () => {
    const user = userEvent.setup();
    const onClick = vi.fn();

    render(<ActionCard {...defaultProps} onClick={onClick} />);

    await user.click(screen.getByRole('button', { name: 'Click Me' }));

    expect(onClick).toHaveBeenCalledTimes(1);
  });
});

Testing with Context

import { render } from '@testing-library/react';
import { AuthContext } from '../contexts/AuthContext';

const renderWithAuth = (component, authValue) => {
  return render(
    <AuthContext.Provider value={authValue}>
      {component}
    </AuthContext.Provider>
  );
};

it('shows logout button when authenticated', () => {
  renderWithAuth(<MyComponent />, { user: { id: '123' } });

  expect(screen.getByText('Logout')).toBeInTheDocument();
});

Testing Hooks

import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

it('increments counter', () => {
  const { result } = renderHook(() => useCounter());

  act(() => {
    result.current.increment();
  });

  expect(result.current.count).toBe(1);
});

Mocking API Calls

import { vi } from 'vitest';
import axios from 'axios';

vi.mock('axios');

it('fetches user data', async () => {
  axios.get.mockResolvedValue({
    data: { id: '123', name: 'Test User' }
  });

  render(<UserProfile userId="123" />);

  await screen.findByText('Test User');

  expect(axios.get).toHaveBeenCalledWith('/api/users/123');
});

Running Unit Tests

Frontend Tests

cd webapp/packages/webui

# Run tests
pnpm test

# Run tests in watch mode
pnpm test -- --watch

# Run tests with coverage
pnpm test:coverage

# Run tests with UI
pnpm test:ui

# Run specific test file
pnpm test ActionCard.test.jsx

Backend Tests

cd webapp/packages/api/user-service

# Run all unit tests
python -m pytest tests/unit -v

# Run specific test file
python -m pytest tests/unit/test_user_service.py -v

# Run specific test
python -m pytest tests/unit/test_user_service.py::TestUserService::test_get_user -v

# Run with coverage
python -m pytest tests/unit --cov=. --cov-report=html

# Run in watch mode (requires pytest-watch)
ptw tests/unit

Best Practices

1. Test One Thing Per Test

# Good
def test_user_creation_sets_email():
    user = create_user(email="test@example.com")
    assert user.email == "test@example.com"

def test_user_creation_sets_timestamp():
    user = create_user()
    assert user.created_at is not None

# Bad
def test_user_creation():
    user = create_user(email="test@example.com")
    assert user.email == "test@example.com"
    assert user.created_at is not None
    assert user.id is not None

2. Use Descriptive Names

# Good
def test_get_user_raises_exception_when_database_unavailable():
    pass

# Bad
def test_get_user():
    pass

3. Follow AAA Pattern (Arrange, Act, Assert)

def test_add_usage_deducts_from_remaining():
    # Arrange
    user = User(_id="test-123")
    user.usage_info.spend_remaining = 100.0
    mock_db.get.return_value = user.model_dump()

    # Act
    updated_user = user_service.add_usage("test-123", 25.0)

    # Assert
    assert updated_user.usage_info.spend_remaining == 75.0

4. Mock External Dependencies

# Good - mocked database
def test_save_user(user_service, mock_db):
    user = User(_id="test-123")
    user_service.save_user(user)

    mock_db.save.assert_called_once()

# Bad - real database (integration test)
def test_save_user():
    user = User(_id="test-123")
    db = CouchDB(url="http://localhost:5984")  # Real connection!
    user_service = UserService(db)
    user_service.save_user(user)

5. Test Edge Cases

def test_divide_by_zero():
    with pytest.raises(ZeroDivisionError):
        divide(10, 0)

def test_empty_list():
    result = process_items([])
    assert result == []

def test_null_input():
    result = process_user(None)
    assert result is None

6. Use Parametrize for Similar Tests

@pytest.mark.parametrize("input,expected", [
    ("hello", "Hello"),
    ("WORLD", "World"),
    ("", ""),
    ("123", "123"),
])
def test_capitalize(input, expected):
    assert capitalize(input) == expected

Common Patterns

Testing Exceptions

def test_invalid_input_raises_value_error():
    with pytest.raises(ValueError, match="Invalid input"):
        process_data("invalid")

Testing HTTP Errors

def test_not_found_returns_404():
    from fastapi import HTTPException

    with pytest.raises(HTTPException) as exc_info:
        get_user("nonexistent")

    assert exc_info.value.status_code == 404

Testing Private Methods

def test_private_method():
    service = MyService()
    # Access private method for testing
    result = service._private_method("test")
    assert result == expected

Troubleshooting

Tests Hanging

  • Check for missing await on async functions
  • Verify mocks are properly configured
  • Look for infinite loops or blocking calls

Import Errors

  • Verify PYTHONPATH includes project root
  • Check for circular imports
  • Ensure __init__.py files exist

Flaky Tests

  • Remove time-based assertions
  • Ensure test isolation (no shared state)
  • Check for race conditions in async code

Coverage Not Increasing

  • Verify test markers are correct
  • Check .coveragerc exclusions
  • Run coverage report to see uncovered lines

Next Steps