From 4ea21240012bcca4b7f1cc20383c4238f0783c83 Mon Sep 17 00:00:00 2001 From: yangdx Date: Tue, 18 Nov 2025 11:36:10 +0800 Subject: [PATCH] Add GitHub CI workflow and test markers for offline/integration tests - Add GitHub Actions workflow for CI - Mark integration tests requiring services - Add offline test markers for isolated tests - Skip integration tests by default - Configure pytest markers and collection --- .github/workflows/tests.yml | 54 ++++++++++++++++++++++ tests/conftest.py | 58 ++++++++++++++++++++++++ tests/test_aquery_data_endpoint.py | 13 ++++++ tests/test_graph_storage.py | 11 +++++ tests/test_lightrag_ollama_chat.py | 21 +++++++++ tests/test_postgres_retry_integration.py | 2 + tests/test_workspace_isolation.py | 17 +++++++ tests/test_write_json_optimization.py | 2 + 8 files changed, 178 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..1c0db0d8 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,54 @@ +name: Tests + +on: + push: + branches: [ main, dev ] + pull_request: + branches: [ main, dev ] + +jobs: + offline-tests: + name: Offline Tests + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ['3.10', '3.11', '3.12'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Cache pip packages + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt', '**/pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[api]" + pip install pytest pytest-asyncio + + - name: Run offline tests + run: | + # Run only tests marked as 'offline' (no external dependencies) + # Integration tests requiring databases/APIs are skipped by default + pytest tests/ -m offline -v --tb=short + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-py${{ matrix.python-version }} + path: | + .pytest_cache/ + test-results.xml + retention-days: 7 diff --git a/tests/conftest.py b/tests/conftest.py index 41db438d..09769fd6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,21 @@ This file provides command-line options and fixtures for test configuration. import pytest +def pytest_configure(config): + """Register custom markers for LightRAG tests.""" + config.addinivalue_line( + "markers", "offline: marks tests as offline (no external dependencies)" + ) + config.addinivalue_line( + "markers", + "integration: marks tests requiring external services (skipped by default)", + ) + config.addinivalue_line("markers", "requires_db: marks tests requiring database") + config.addinivalue_line( + "markers", "requires_api: marks tests requiring LightRAG API server" + ) + + def pytest_addoption(parser): """Add custom command-line options for LightRAG tests.""" @@ -32,6 +47,32 @@ def pytest_addoption(parser): help="Number of parallel workers for stress tests (default: 3)", ) + parser.addoption( + "--run-integration", + action="store_true", + default=False, + help="Run integration tests that require external services (database, API server, etc.)", + ) + + +def pytest_collection_modifyitems(config, items): + """Modify test collection to skip integration tests by default. + + Integration tests are skipped unless --run-integration flag is provided. + This allows running offline tests quickly without needing external services. + """ + if config.getoption("--run-integration"): + # If --run-integration is specified, run all tests + return + + skip_integration = pytest.mark.skip( + reason="Requires external services(DB/API), use --run-integration to run" + ) + + for item in items: + if "integration" in item.keywords: + item.add_marker(skip_integration) + @pytest.fixture(scope="session") def keep_test_artifacts(request): @@ -83,3 +124,20 @@ def parallel_workers(request): # Fall back to environment variable return int(os.getenv("LIGHTRAG_TEST_WORKERS", "3")) + + +@pytest.fixture(scope="session") +def run_integration_tests(request): + """ + Fixture to determine whether to run integration tests. + + Priority: CLI option > Environment variable > Default (False) + """ + import os + + # Check CLI option first + if request.config.getoption("--run-integration"): + return True + + # Fall back to environment variable + return os.getenv("LIGHTRAG_RUN_INTEGRATION", "false").lower() == "true" diff --git a/tests/test_aquery_data_endpoint.py b/tests/test_aquery_data_endpoint.py index 8845cb79..4866c779 100644 --- a/tests/test_aquery_data_endpoint.py +++ b/tests/test_aquery_data_endpoint.py @@ -9,6 +9,7 @@ Updated to handle the new data format where: - Includes backward compatibility with legacy format """ +import pytest import requests import time import json @@ -84,6 +85,8 @@ def parse_streaming_response( return references, response_chunks, errors +@pytest.mark.integration +@pytest.mark.requires_api def test_query_endpoint_references(): """Test /query endpoint references functionality""" @@ -187,6 +190,8 @@ def test_query_endpoint_references(): return True +@pytest.mark.integration +@pytest.mark.requires_api def test_query_stream_endpoint_references(): """Test /query/stream endpoint references functionality""" @@ -322,6 +327,8 @@ def test_query_stream_endpoint_references(): return True +@pytest.mark.integration +@pytest.mark.requires_api def test_references_consistency(): """Test references consistency across all endpoints""" @@ -472,6 +479,8 @@ def test_references_consistency(): return consistency_passed +@pytest.mark.integration +@pytest.mark.requires_api def test_aquery_data_endpoint(): """Test the /query/data endpoint""" @@ -654,6 +663,8 @@ def print_query_results(data: Dict[str, Any]): print("=" * 60) +@pytest.mark.integration +@pytest.mark.requires_api def compare_with_regular_query(): """Compare results between regular query and data query""" @@ -690,6 +701,8 @@ def compare_with_regular_query(): print(f" Regular query error: {str(e)}") +@pytest.mark.integration +@pytest.mark.requires_api def run_all_reference_tests(): """Run all reference-related tests""" diff --git a/tests/test_graph_storage.py b/tests/test_graph_storage.py index e4bfb6b1..64ed5dd5 100644 --- a/tests/test_graph_storage.py +++ b/tests/test_graph_storage.py @@ -18,6 +18,7 @@ import os import sys import importlib import numpy as np +import pytest from dotenv import load_dotenv from ascii_colors import ASCIIColors @@ -129,6 +130,8 @@ async def initialize_graph_storage(): return None +@pytest.mark.integration +@pytest.mark.requires_db async def test_graph_basic(storage): """ Test basic graph database operations: @@ -254,6 +257,8 @@ async def test_graph_basic(storage): return False +@pytest.mark.integration +@pytest.mark.requires_db async def test_graph_advanced(storage): """ Test advanced graph database operations: @@ -474,6 +479,8 @@ async def test_graph_advanced(storage): return False +@pytest.mark.integration +@pytest.mark.requires_db async def test_graph_batch_operations(storage): """ Test batch operations of the graph database: @@ -827,6 +834,8 @@ async def test_graph_batch_operations(storage): return False +@pytest.mark.integration +@pytest.mark.requires_db async def test_graph_special_characters(storage): """ Test the graph database's handling of special characters: @@ -981,6 +990,8 @@ async def test_graph_special_characters(storage): return False +@pytest.mark.integration +@pytest.mark.requires_db async def test_graph_undirected_property(storage): """ Specifically test the undirected graph property of the storage: diff --git a/tests/test_lightrag_ollama_chat.py b/tests/test_lightrag_ollama_chat.py index 80038928..fe1cc70d 100644 --- a/tests/test_lightrag_ollama_chat.py +++ b/tests/test_lightrag_ollama_chat.py @@ -9,6 +9,7 @@ This script tests the LightRAG's Ollama compatibility interface, including: All responses use the JSON Lines format, complying with the Ollama API specification. """ +import pytest import requests import json import argparse @@ -293,6 +294,8 @@ def run_test(func: Callable, name: str) -> None: raise +@pytest.mark.integration +@pytest.mark.requires_api def test_non_stream_chat() -> None: """Test non-streaming call to /api/chat endpoint""" url = get_base_url() @@ -317,6 +320,8 @@ def test_non_stream_chat() -> None: ) +@pytest.mark.integration +@pytest.mark.requires_api def test_stream_chat() -> None: """Test streaming call to /api/chat endpoint @@ -377,6 +382,8 @@ def test_stream_chat() -> None: print() +@pytest.mark.integration +@pytest.mark.requires_api def test_query_modes() -> None: """Test different query mode prefixes @@ -436,6 +443,8 @@ def create_error_test_data(error_type: str) -> Dict[str, Any]: return error_data.get(error_type, error_data["empty_messages"]) +@pytest.mark.integration +@pytest.mark.requires_api def test_stream_error_handling() -> None: """Test error handling for streaming responses @@ -482,6 +491,8 @@ def test_stream_error_handling() -> None: response.close() +@pytest.mark.integration +@pytest.mark.requires_api def test_error_handling() -> None: """Test error handling for non-streaming responses @@ -529,6 +540,8 @@ def test_error_handling() -> None: print_json_response(response.json(), "Error message") +@pytest.mark.integration +@pytest.mark.requires_api def test_non_stream_generate() -> None: """Test non-streaming call to /api/generate endpoint""" url = get_base_url("generate") @@ -548,6 +561,8 @@ def test_non_stream_generate() -> None: print(json.dumps(response_json, ensure_ascii=False, indent=2)) +@pytest.mark.integration +@pytest.mark.requires_api def test_stream_generate() -> None: """Test streaming call to /api/generate endpoint""" url = get_base_url("generate") @@ -588,6 +603,8 @@ def test_stream_generate() -> None: print() +@pytest.mark.integration +@pytest.mark.requires_api def test_generate_with_system() -> None: """Test generate with system prompt""" url = get_base_url("generate") @@ -616,6 +633,8 @@ def test_generate_with_system() -> None: ) +@pytest.mark.integration +@pytest.mark.requires_api def test_generate_error_handling() -> None: """Test error handling for generate endpoint""" url = get_base_url("generate") @@ -641,6 +660,8 @@ def test_generate_error_handling() -> None: print_json_response(response.json(), "Error message") +@pytest.mark.integration +@pytest.mark.requires_api def test_generate_concurrent() -> None: """Test concurrent generate requests""" import asyncio diff --git a/tests/test_postgres_retry_integration.py b/tests/test_postgres_retry_integration.py index 515f3072..2c7b3499 100644 --- a/tests/test_postgres_retry_integration.py +++ b/tests/test_postgres_retry_integration.py @@ -24,6 +24,8 @@ asyncpg = pytest.importorskip("asyncpg") load_dotenv(dotenv_path=".env", override=False) +@pytest.mark.integration +@pytest.mark.requires_db class TestPostgresRetryIntegration: """Integration tests for PostgreSQL retry mechanism with real database.""" diff --git a/tests/test_workspace_isolation.py b/tests/test_workspace_isolation.py index 0b991857..4318d5da 100644 --- a/tests/test_workspace_isolation.py +++ b/tests/test_workspace_isolation.py @@ -148,6 +148,7 @@ def _assert_no_timeline_overlap(timeline: List[Tuple[str, str]]) -> None: # ============================================================================= +@pytest.mark.offline @pytest.mark.asyncio async def test_pipeline_status_isolation(): """ @@ -202,6 +203,7 @@ async def test_pipeline_status_isolation(): # ============================================================================= +@pytest.mark.offline @pytest.mark.asyncio async def test_lock_mechanism(stress_test_mode, parallel_workers): """ @@ -271,6 +273,7 @@ async def test_lock_mechanism(stress_test_mode, parallel_workers): # ============================================================================= +@pytest.mark.offline @pytest.mark.asyncio async def test_backward_compatibility(): """ @@ -344,6 +347,7 @@ async def test_backward_compatibility(): # ============================================================================= +@pytest.mark.offline @pytest.mark.asyncio async def test_multi_workspace_concurrency(): """ @@ -427,6 +431,7 @@ async def test_multi_workspace_concurrency(): # ============================================================================= +@pytest.mark.offline @pytest.mark.asyncio async def test_namespace_lock_reentrance(): """ @@ -500,6 +505,7 @@ async def test_namespace_lock_reentrance(): # ============================================================================= +@pytest.mark.offline @pytest.mark.asyncio async def test_different_namespace_lock_isolation(): """ @@ -539,6 +545,7 @@ async def test_different_namespace_lock_isolation(): # ============================================================================= +@pytest.mark.offline @pytest.mark.asyncio async def test_error_handling(): """ @@ -589,6 +596,7 @@ async def test_error_handling(): # ============================================================================= +@pytest.mark.offline @pytest.mark.asyncio async def test_update_flags_workspace_isolation(): """ @@ -718,6 +726,7 @@ async def test_update_flags_workspace_isolation(): # ============================================================================= +@pytest.mark.offline @pytest.mark.asyncio async def test_empty_workspace_standardization(): """ @@ -771,6 +780,7 @@ async def test_empty_workspace_standardization(): # ============================================================================= +@pytest.mark.offline @pytest.mark.asyncio async def test_json_kv_storage_workspace_isolation(keep_test_artifacts): """ @@ -852,6 +862,9 @@ async def test_json_kv_storage_workspace_isolation(keep_test_artifacts): } ) print(" Written to storage1: entity1, entity2") + # Persist data to disk + await storage1.index_done_callback() + print(" Persisted storage1 data to disk") # Write to storage2 await storage2.upsert( @@ -867,6 +880,9 @@ async def test_json_kv_storage_workspace_isolation(keep_test_artifacts): } ) print(" Written to storage2: entity1, entity2") + # Persist data to disk + await storage2.index_done_callback() + print(" Persisted storage2 data to disk") # Test 10.3: Read data from each storage and verify isolation print("\nTest 10.3: Read data and verify isolation") @@ -944,6 +960,7 @@ async def test_json_kv_storage_workspace_isolation(keep_test_artifacts): # ============================================================================= +@pytest.mark.offline @pytest.mark.asyncio async def test_lightrag_end_to_end_workspace_isolation(keep_test_artifacts): """ diff --git a/tests/test_write_json_optimization.py b/tests/test_write_json_optimization.py index 0a92904f..32dcfb5e 100644 --- a/tests/test_write_json_optimization.py +++ b/tests/test_write_json_optimization.py @@ -11,9 +11,11 @@ This test verifies: import os import json import tempfile +import pytest from lightrag.utils import write_json, load_json, SanitizingJSONEncoder +@pytest.mark.offline class TestWriteJsonOptimization: """Test write_json optimization with two-stage approach"""