# Testing Guide This document covers testing strategies and best practices for Fission Python functions. ## Table of Contents 1. [Test Types](#test-types) 2. [Dependencies](#dependencies) 3. [Unit Testing](#unit-testing) 4. [Integration Testing](#integration-testing) 5. [Test Database](#test-database) 6. [Mocking](#mocking) 7. [Fixtures](#fixtures) 8. [Coverage](#coverage) 9. [Running Tests](#running-tests) 10. [CI/CD Integration](#cicd-integration) ## Test Types ### Unit Tests Test individual functions in isolation, mocking external dependencies: - Database calls - HTTP requests - File I/O - External services **Goal**: Verify business logic correctness without infrastructure. ### Integration Tests Test the function with real (or test) dependencies: - Actual database queries - End-to-end request/response flow - Real configuration loading **Goal**: Verify integration points work correctly. ## Dependencies Install test dependencies: ```bash pip install -r test/requirements.txt # Or for dev (includes both runtime and test deps): pip install -r dev-requirements.txt ``` Required packages: - `pytest` - Test framework - `pytest-mock` - Mocking utilities (provides `mocker` fixture) - `requests` - For integration tests making HTTP calls ## Unit Testing ### Example Test Structure ```python # test/test_my_function.py import pytest from unittest.mock import patch, MagicMock from src.my_function import create_item from exceptions import ValidationError def test_create_item_success(): """Test successful item creation.""" # Arrange mock_conn = MagicMock() mock_cursor = MagicMock() mock_conn.cursor.return_value = mock_cursor mock_cursor.fetchone.return_value = ("item-id", "Item Name", "active") # Mock init_db_connection to return our mock with patch("src.my_function.init_db_connection", return_value=mock_conn): # Create a mock Flask request with patch("src.my_function.request") as mock_request: mock_request.get_json.return_value = { "name": "Test Item", "status": "active" } mock_request.view_args = {} # Act result = create_item({}, {}) # Assert assert result["id"] == "item-id" assert result["name"] == "Test Item" mock_cursor.execute.assert_called_once() mock_conn.commit.assert_called_once() def test_create_item_validation_error(): """Test validation of missing required fields.""" with patch("src.my_function.request") as mock_request: mock_request.get_json.return_value = {"name": ""} # Empty name with pytest.raises(ValidationError) as exc_info: create_item({}, {}) assert "validation" in str(exc_info.value.error_msg).lower() ``` ### Mocking Helpers Use `patch` to replace dependencies: ```python # Mock helpers.get_secret @patch("src.my_function.helpers.get_secret") def test_with_mocked_secret(mock_get_secret): mock_get_secret.return_value = "localhost" # Test code... # Mock entire module @patch("src.my_function.helpers.init_db_connection") def test_with_mocked_db(mock_init_db): mock_conn = MagicMock() mock_init_db.return_value = mock_conn # Test code... ``` ### Mocking Flask Request ```python from flask import Request def test_with_flask_request(): with patch("src.my_function.request") as mock_request: mock_request.get_json.return_value = {"key": "value"} mock_request.args.getlist.return_value = [] mock_request.headers.get.return_value = "user-123" # Test code... ``` ## Integration Testing ### Test Database Setup Use a separate test database: ```bash # Create test database createdb fission_test # Or with Docker: docker run -d -p 5433:5432 -e POSTGRES_PASSWORD=test postgres:15 ``` Set environment variables for test database: ```bash export PG_HOST=localhost export PG_PORT=5433 export PG_DB=fission_test export PG_USER=postgres export PG_PASS=test ``` ### pytest Fixtures for Database ```python # conftest.py (placed in test/ directory) import pytest import psycopg2 from helpers import init_db_connection @pytest.fixture(scope="session") def db_connection(): """Create a database connection for the entire test session.""" conn = init_db_connection() yield conn conn.close() @pytest.fixture(scope="function") def db_cursor(db_connection): """Create a cursor for each test, with transaction rollback.""" conn = db_connection cursor = conn.cursor() # Start a transaction that will be rolled back conn.rollback() yield cursor # Rollback after each test to keep DB clean conn.rollback() ``` ### Example Integration Test ```python # test/test_integration.py def test_create_and_retrieve_item_integration(db_connection): """Test full CRUD cycle with real database.""" from src.models import ItemCreateRequest from src.functions import create_item, get_item # Insert test data cursor = db_connection.cursor() cursor.execute("DELETE FROM items WHERE name = 'Integration Test'") db_connection.commit() # Create item via function with patch("src.functions.request") as mock_request: mock_request.get_json.return_value = { "name": "Integration Test", "description": "Test item" } mock_request.view_args = {} result = create_item({}, {}) item_id = result["id"] assert result["name"] == "Integration Test" # Retrieve same item with patch("src.functions.request") as mock_request: with patch("src.functions.request.view_args", {"id": item_id}): result = get_item({"path": f"/items/{item_id}"}, {}) assert result["id"] == item_id # Cleanup cursor.execute("DELETE FROM items WHERE id = %s", (item_id,)) db_connection.commit() ``` ## Test Database Migrations Apply migrations before integration tests: ```python # conftest.py import subprocess def apply_migrations(): """Apply all SQL migrations to test database.""" import os migrates_dir = os.path.join(os.path.dirname(__file__), "..", "migrates") for file in sorted(os.listdir(migrates_dir)): if file.endswith(".sql"): path = os.path.join(migrates_dir, file) subprocess.run( ["psql", "-d", "fission_test", "-f", path], check=True ) @pytest.fixture(scope="session", autouse=True) def setup_database(): """Run migrations before any tests.""" apply_migrations() yield # Optionally drop and recreate after tests ``` ## Mocking ### Built-in unittest.mock ```python from unittest.mock import patch, MagicMock, mock_open # Simple patch with patch("module.function") as mock_func: mock_func.return_value = "mocked" # call code that uses module.function # Assert called with specific args mock_func.assert_called_once_with("arg1", "arg2") # Mock context manager with patch("builtins.open", mock_open(read_data="file content")) as mock_file: # code that opens file mock_file.assert_called_with("path/to/file", "r") ``` ### pytest-mock Fixture Simpler syntax using `mocker` fixture: ```python def test_with_mocker(mocker): mock_func = mocker.patch("src.function.helper") mock_func.return_value = {"key": "value"} # test code... ``` ## Fixtures Create reusable fixtures in `conftest.py`: ```python # test/conftest.py import pytest @pytest.fixture def sample_item_data(): """Provide sample item data for tests.""" return { "name": "Test Item", "description": "A test item", "status": "active" } @pytest.fixture def mock_db_connection(mocker): """Provide a mocked database connection.""" mock_conn = mocker.MagicMock() mock_cursor = mocker.MagicMock() mock_conn.cursor.return_value = mock_cursor mock_cursor.fetchone.return_value = None return mock_conn ``` Fixtures are automatically available to all tests in the directory. ## Coverage Measure test coverage with pytest-cov: ```bash # Install pip install pytest-cov # Run with coverage pytest --cov=src # HTML report pytest --cov=src --cov-report=html open htmlcov/index.html # Show missing lines pytest --cov=src --cov-report=term-missing ``` Aim for high coverage of business logic (80%+). Don't worry about 100% coverage of trivial getters/setters. ### Excluding Files Add to `pytest.ini`: ```ini [pytest] addopts = --cov=src --cov-exclude=src/vault.py ``` Or use `.coveragerc`: ```ini [run] omit = src/vault.py ``` ## Running Tests ### Basic Commands ```bash # Run all tests pytest # Verbose pytest -v # Run specific test file pytest test/test_my_function.py # Run specific test function pytest test/test_my_function.py::test_create_item_success # Run with markers pytest -m "integration" # if using @pytest.mark.integration # Stop on first failure pytest -x # Show print statements pytest -s ``` ### Environment Setup Create `test/.env` or set environment variables before tests: ```bash # For integration tests export PG_HOST=localhost export PG_PORT=5432 export PG_DB=fission_test ``` Or use a pytest fixture to load from `.env`: ```python # conftest.py import os from dotenv import load_dotenv @pytest.fixture(scope="session", autouse=True) def load_env(): env_path = os.path.join(os.path.dirname(__file__), ".env") load_dotenv(env_path) ``` ### Markers Mark tests as unit/integration/slow: ```python import pytest @pytest.mark.unit def test_quick_unit(): pass @pytest.mark.integration def test_full_workflow(): pass @pytest.mark.slow def test_long_running(): pass ``` Run only unit tests: ```bash pytest -m "unit" ``` Skip tests: ```bash pytest -m "not slow" ``` ## CI/CD Integration ### GitHub Actions Example ```yaml # .github/workflows/test.yaml name: Tests on: [push, pull_request] jobs: test: runs-on: ubuntu-latest services: postgres: image: postgres:15 env: POSTGRES_PASSWORD: test options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 ports: - 5432:5432 steps: - uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.11' - name: Install dependencies run: | pip install -r dev-requirements.txt - name: Setup database run: | createdb -h localhost -U postgres fission_test psql -h localhost -U postgres fission_test -f migrates/001_schema.sql env: PGPASSWORD: test - name: Run tests run: | pytest --cov=src --cov-report=xml env: PG_HOST: localhost PG_PORT: 5432 PG_DB: fission_test PG_USER: postgres PG_PASS: test - name: Upload coverage uses: codecov/codecov-action@v3 ``` ## Best Practices 1. **One assertion per test** - Keep tests focused 2. **Use descriptive names** - `test_create_item_validation_error_for_missing_name` 3. **Arrange-Act-Assert** - Structure tests clearly 4. **Mock external dependencies** - Don't rely on network or external services 5. **Test error cases** - Don't just test happy paths 6. **Use fixtures** - Reuse setup/teardown code 7. **Keep tests independent** - No shared state between tests 8. **Test edge cases** - Empty inputs, null values, boundary conditions 9. **Don't test libraries** - Don't write tests for Flask/Pydantic themselves 10. **Clean up resources** - Use fixtures to ensure cleanup ## Common Patterns ### Testing Exceptions ```python def test_raises_not_found(): with pytest.raises(NotFoundError) as exc: get_item("nonexistent-id") assert exc.value.http_status == 404 ``` ### Parametrized Tests ```python import pytest @pytest.mark.parametrize("input,expected", [ ("true", True), ("false", False), ("", None), (None, None), ]) def test_str_to_bool(input, expected): from helpers import str_to_bool assert str_to_bool(input) == expected ``` ### Temporary Files/Directories ```python def test_with_temp_file(tmp_path): # tmp_path is a pathlib.Path to a temporary directory file = tmp_path / "test.txt" file.write_text("content") assert file.read_text() == "content" ``` ## Troubleshooting ### Tests Fail with Database Errors - Check test database is running: `pg_isready -h localhost -p 5432` - Verify migrations applied: `psql -l | grep fission_test` - Check environment variables: `echo $PG_HOST` ### Mock Not Working - Ensure you're patching the **correct import location** (where it's used, not where it's defined) ```python # Wrong: patching where it's defined @patch("helpers.get_secret") # Right: patching where it's used in your function module @patch("src.my_function.helpers.get_secret") ``` ### Import Errors Ensure PYTHONPATH includes project root: ```bash export PYTHONPATH=/path/to/project:$PYTHONPATH ``` Or use pytest's `pythonpath` option in pytest.ini: ```ini [pytest] pythonpath = . ``` ## Further Reading - [pytest documentation](https://docs.pytest.org/) - [pytest-mock documentation](https://github.com/pytest-dev/pytest-mock) - [Python unittest.mock](https://docs.python.org/3/library/unittest.mock.html) - [Testing Flask Applications](https://flask.palletsprojects.com/en/2.1.x/testing/)