# Testing Guide This document provides guidelines for writing tests in Open Notebook. Testing is critical to maintaining code quality and preventing regressions. ## Testing Philosophy ### What to Test Focus on testing the things that matter most: - **Business Logic** - Core domain models and their operations - **API Contracts** - HTTP endpoint behavior and error handling - **Critical Workflows** - End-to-end flows that users depend on - **Data Persistence** - Database operations and data integrity - **Error Conditions** - How the system handles failures gracefully ### What NOT to Test Don't waste time testing framework code: - Framework functionality (FastAPI, React, etc.) - Third-party library implementation - Simple getters/setters without logic - View/presentation layer rendering (unless it contains logic) ## Test Structure We use **pytest** with async support for all Python tests: ```python import pytest from httpx import AsyncClient from open_notebook.domain.notebook import Notebook @pytest.mark.asyncio async def test_create_notebook(): """Test notebook creation.""" notebook = Notebook(name="Test Notebook", description="Test description") await notebook.save() assert notebook.id is not None assert notebook.name == "Test Notebook" assert notebook.created is not None @pytest.mark.asyncio async def test_api_create_notebook(): """Test notebook creation via API.""" async with AsyncClient(app=app, base_url="http://test") as client: response = await client.post( "/api/notebooks", json={"name": "Test Notebook", "description": "Test description"} ) assert response.status_code == 200 data = response.json() assert data["name"] == "Test Notebook" ``` ## Test Categories ### 1. Unit Tests Test individual functions and methods in isolation: ```python @pytest.mark.asyncio async def test_notebook_validation(): """Test that notebook name validation works.""" with pytest.raises(InvalidInputError): Notebook(name="", description="test") @pytest.mark.asyncio async def test_notebook_archive(): """Test notebook archiving.""" notebook = Notebook(name="Test", description="") notebook.archive() assert notebook.archived is True ``` **Location**: `tests/unit/` ### 2. Integration Tests Test component interactions and database operations: ```python @pytest.mark.asyncio async def test_create_notebook_with_sources(): """Test creating a notebook and adding sources.""" notebook = await create_notebook(name="Research", description="") source = await add_source(notebook_id=notebook.id, url="https://example.com") retrieved = await get_notebook_with_sources(notebook.id) assert len(retrieved.sources) == 1 assert retrieved.sources[0].id == source.id ``` **Location**: `tests/integration/` ### 3. API Tests Test HTTP endpoints and error responses: ```python @pytest.mark.asyncio async def test_get_notebooks_endpoint(): """Test GET /notebooks endpoint.""" async with AsyncClient(app=app, base_url="http://test") as client: response = await client.get("/api/notebooks") assert response.status_code == 200 data = response.json() assert isinstance(data, list) @pytest.mark.asyncio async def test_create_notebook_validation(): """Test that invalid input is rejected.""" async with AsyncClient(app=app, base_url="http://test") as client: response = await client.post( "/api/notebooks", json={"name": "", "description": ""} ) assert response.status_code == 400 ``` **Location**: `tests/api/` ### 4. Database Tests Test data persistence and query correctness: ```python @pytest.mark.asyncio async def test_save_and_retrieve_notebook(): """Test saving and retrieving a notebook from database.""" notebook = Notebook(name="Test", description="desc") await notebook.save() retrieved = await Notebook.get(notebook.id) assert retrieved.name == "Test" assert retrieved.description == "desc" @pytest.mark.asyncio async def test_query_by_criteria(): """Test querying notebooks by criteria.""" await create_notebook("Active", "") await create_notebook("Archived", "") active = await repo_query( "SELECT * FROM notebook WHERE archived = false" ) assert len(active) >= 1 ``` **Location**: `tests/database/` ## Running Tests ### Run All Tests ```bash uv run pytest ``` ### Run Specific Test File ```bash uv run pytest tests/test_notebooks.py ``` ### Run Specific Test Function ```bash uv run pytest tests/test_notebooks.py::test_create_notebook ``` ### Run with Coverage Report ```bash uv run pytest --cov=open_notebook ``` ### Run Only Unit Tests ```bash uv run pytest tests/unit/ ``` ### Run Only Integration Tests ```bash uv run pytest tests/integration/ ``` ### Run Tests in Verbose Mode ```bash uv run pytest -v ``` ### Run Tests with Output ```bash uv run pytest -s ``` ## Test Fixtures Use pytest fixtures for common setup and teardown: ```python import pytest @pytest.fixture async def test_notebook(): """Create a test notebook.""" notebook = Notebook(name="Test Notebook", description="Test description") await notebook.save() yield notebook await notebook.delete() @pytest.fixture async def api_client(): """Create an API test client.""" async with AsyncClient(app=app, base_url="http://test") as client: yield client @pytest.fixture async def test_notebook_with_sources(test_notebook): """Create a test notebook with sample sources.""" source1 = Source(notebook_id=test_notebook.id, url="https://example.com") source2 = Source(notebook_id=test_notebook.id, url="https://example.org") await source1.save() await source2.save() test_notebook.sources = [source1, source2] yield test_notebook # Cleanup await source1.delete() await source2.delete() ``` ## Best Practices ### 1. Write Descriptive Test Names ```python # Good - clearly describes what is being tested async def test_create_notebook_with_valid_name_succeeds(): ... # Bad - vague about what's being tested async def test_notebook(): ... ``` ### 2. Use Docstrings ```python @pytest.mark.asyncio async def test_vector_search_returns_sorted_results(): """Test that vector search results are sorted by relevance score.""" # Implementation ``` ### 3. Test Edge Cases ```python @pytest.mark.asyncio async def test_search_with_empty_query(): """Test that empty query raises error.""" with pytest.raises(InvalidInputError): await vector_search("") @pytest.mark.asyncio async def test_search_with_very_long_query(): """Test that very long query is handled.""" long_query = "x" * 10000 results = await vector_search(long_query) assert isinstance(results, list) @pytest.mark.asyncio async def test_search_with_special_characters(): """Test that special characters are handled.""" results = await vector_search("@#$%^&*()") assert isinstance(results, list) ``` ### 4. Use Assertions Effectively ```python # Good - specific assertions assert notebook.name == "Test" assert len(notebook.sources) == 3 assert notebook.created is not None # Less good - too broad assert notebook is not None assert notebook # ambiguous what's being tested ``` ### 5. Test Both Success and Failure Cases ```python @pytest.mark.asyncio async def test_create_notebook_success(): """Test successful notebook creation.""" notebook = await create_notebook(name="Research", description="AI") assert notebook.id is not None assert notebook.name == "Research" @pytest.mark.asyncio async def test_create_notebook_empty_name_fails(): """Test that empty name raises error.""" with pytest.raises(InvalidInputError): await create_notebook(name="", description="") @pytest.mark.asyncio async def test_create_notebook_duplicate_fails(): """Test that duplicate names are handled.""" await create_notebook(name="Research", description="") with pytest.raises(DuplicateError): await create_notebook(name="Research", description="") ``` ### 6. Keep Tests Independent ```python # Good - test is self-contained @pytest.mark.asyncio async def test_archive_notebook(): notebook = Notebook(name="Test", description="") await notebook.save() await notebook.archive() assert notebook.archived is True # Bad - depends on another test's state @pytest.mark.asyncio async def test_archive_existing_notebook(): # Assumes test_create_notebook ran first await notebook.archive() # notebook undefined ``` ### 7. Use Fixtures for Reusable Setup ```python # Instead of repeating setup: @pytest.fixture async def client_with_auth(api_client, mock_auth): """Client with authentication set up.""" api_client.headers.update({"Authorization": f"Bearer {mock_auth.token}"}) yield api_client @pytest.mark.asyncio async def test_protected_endpoint(client_with_auth): """Test protected endpoint.""" response = await client_with_auth.get("/api/protected") assert response.status_code == 200 ``` ## Coverage Goals - Aim for 70%+ overall coverage - 90%+ coverage for critical business logic - Don't obsess over 100% - focus on meaningful tests - Use `--cov` flag to check coverage: `uv run pytest --cov=open_notebook` ## Async Test Patterns ### Testing Async Functions ```python @pytest.mark.asyncio async def test_async_operation(): """Test async function.""" result = await some_async_function() assert result is not None ``` ### Testing Concurrent Operations ```python @pytest.mark.asyncio async def test_concurrent_notebook_creation(): """Test creating multiple notebooks concurrently.""" tasks = [ create_notebook(f"Notebook {i}", "") for i in range(10) ] notebooks = await asyncio.gather(*tasks) assert len(notebooks) == 10 assert all(n.id for n in notebooks) ``` ## Common Testing Errors ### Error: "event loop is closed" Solution: Use the async fixture properly: ```python @pytest.fixture async def notebook(): # Use async fixture notebook = Notebook(name="Test", description="") await notebook.save() yield notebook await notebook.delete() ``` ### Error: "object is not awaitable" Solution: Make sure you're using await: ```python # Wrong result = create_notebook("Test", "") # Right result = await create_notebook("Test", "") ``` --- **See also:** - [Code Standards](code-standards.md) - Code formatting and style - [Contributing Guide](contributing.md) - Overall contribution workflow