Merge pull request #446 from christianlouis/copilot/optimize-test-timeouts
fix(ci): split test pipeline into quick/integration stages, fix hanging test
This commit is contained in:
+82
-29
@@ -109,13 +109,81 @@ jobs:
|
||||
run: pip-audit -r requirements-dev.txt --desc on
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# Stage 2b: Tests & Type Checking (run in parallel after lint passes)
|
||||
# Stage 2b: Quick Tests (unit + basic integration — fast fail gate)
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test:
|
||||
name: Tests
|
||||
test-quick:
|
||||
name: Quick Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, html-lint, dependency-scan] # Wait for lint, HTML a11y lint, and dependency scan before running tests
|
||||
timeout-minutes: 15
|
||||
needs: [lint, html-lint, dependency-scan]
|
||||
services:
|
||||
redis:
|
||||
image: redis:7
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
- name: Run Quick Tests
|
||||
run: >
|
||||
pytest tests/ -v
|
||||
--timeout=120
|
||||
--cov=app --cov-report=xml --cov-report=term
|
||||
--junitxml=junit.xml -o junit_family=legacy
|
||||
-m "not e2e and not requires_docker and not requires_external and not slow"
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: ${{ !cancelled() }}
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./coverage.xml
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Upload test results to Codecov
|
||||
if: ${{ !cancelled() }}
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./junit.xml
|
||||
report_type: test_results
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Upload test artifacts
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-results-quick
|
||||
path: |
|
||||
junit.xml
|
||||
coverage.xml
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# Stage 2c: Integration Tests (Docker containers, external services)
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test-integration:
|
||||
name: Integration Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
needs: [test-quick] # Only run after quick tests pass (fail early)
|
||||
services:
|
||||
redis:
|
||||
image: redis:7
|
||||
@@ -150,34 +218,19 @@ jobs:
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
- name: Run Tests
|
||||
run: pytest tests/ -v --cov=app --cov-report=xml --cov-report=term --junitxml=junit.xml -o junit_family=legacy -m "not e2e"
|
||||
- name: Run Integration Tests
|
||||
run: >
|
||||
pytest tests/ -v
|
||||
--timeout=300
|
||||
--junitxml=junit-integration.xml -o junit_family=legacy
|
||||
-m "(requires_docker or requires_external or slow) and not e2e"
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: ${{ !cancelled() }}
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./coverage.xml
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Upload test results to Codecov
|
||||
if: ${{ !cancelled() }}
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./junit.xml
|
||||
report_type: test_results
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Upload test artifacts
|
||||
- name: Upload integration test results
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-results
|
||||
path: |
|
||||
junit.xml
|
||||
coverage.xml
|
||||
name: test-results-integration
|
||||
path: junit-integration.xml
|
||||
|
||||
mypy:
|
||||
name: Mypy
|
||||
@@ -207,7 +260,7 @@ jobs:
|
||||
build:
|
||||
name: Build & Push Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test, lint, html-lint, mypy, dependency-scan]
|
||||
needs: [test-quick, test-integration, lint, html-lint, mypy, dependency-scan]
|
||||
if: github.event_name == 'push'
|
||||
|
||||
steps:
|
||||
|
||||
+43
-12
@@ -11,21 +11,45 @@ The CI workflow (`.github/workflows/tests.yaml`) runs automatically on every pus
|
||||
| Job | Tool | Purpose | Enforced |
|
||||
|--------|--------|---------------------------------------------|----------|
|
||||
| `lint` | Ruff | Fast Python linter (replaces Flake8, Black, isort, Bandit) | ✅ |
|
||||
| `html-lint` | djLint | HTML template accessibility linter | ✅ |
|
||||
| `dependency-scan` | pip-audit | Dependency vulnerability scanning against OSV/PyPA advisories | ✅ |
|
||||
| `test` | pytest | Unit/integration tests with coverage | ✅ |
|
||||
| `test-quick` | pytest | Unit + basic integration tests with coverage (~2 min) | ✅ |
|
||||
| `test-integration` | pytest | Docker container and external service tests (~5 min) | ✅ |
|
||||
| `mypy` | mypy | Static type checking | ✅ |
|
||||
|
||||
`lint` and `dependency-scan` start **in parallel** at the beginning of the pipeline — neither depends on the other. `test` and `mypy` run only after both have passed.
|
||||
### Pipeline Flow
|
||||
|
||||
```
|
||||
Stage 1 (parallel): lint, html-lint, dependency-scan
|
||||
│
|
||||
Stage 2 (parallel): test-quick + mypy
|
||||
│
|
||||
Stage 3: test-integration (only after test-quick passes)
|
||||
│
|
||||
Stage 4: build (only after all above pass)
|
||||
```
|
||||
|
||||
The pipeline follows a **fail-early** strategy: fast linters and quick tests run first to catch regressions early. Heavier integration tests only run after the quick tests pass, saving CI time when basic issues are present.
|
||||
|
||||
> **Note:** DocuElevate uses Ruff, a modern all-in-one Python linter that consolidates the functionality of Flake8, Black, isort, and Bandit. This streamlined approach reduces CI complexity while maintaining code quality and security standards.
|
||||
|
||||
### Tests
|
||||
### Quick Tests (`test-quick`)
|
||||
|
||||
- Runs pytest with coverage reporting (XML + terminal).
|
||||
- Excludes E2E tests that require Docker-in-Docker (`-m "not e2e"`).
|
||||
- Uses Redis and RabbitMQ service containers.
|
||||
- Uploads coverage and JUnit XML results to Codecov.
|
||||
- Uploads `junit.xml` and `coverage.xml` as workflow artifacts (always, even on failure).
|
||||
- **Timeout:** 15 minutes (job), 120 seconds (per test via `pytest-timeout`)
|
||||
- Runs the majority of tests (~2,790 unit + basic integration tests)
|
||||
- Excludes tests marked `e2e`, `requires_docker`, `requires_external`, or `slow`
|
||||
- Uses a Redis service container for tests that need it
|
||||
- Collects coverage and uploads to Codecov
|
||||
- Uploads `junit.xml` and `coverage.xml` as workflow artifacts
|
||||
|
||||
### Integration Tests (`test-integration`)
|
||||
|
||||
- **Timeout:** 20 minutes (job), 300 seconds (per test via `pytest-timeout`)
|
||||
- Runs tests marked `requires_docker`, `requires_external`, or `slow` (excluding `e2e`)
|
||||
- Uses Redis and RabbitMQ service containers
|
||||
- Docker daemon available for testcontainers (WebDAV, OAuth mock server, etc.)
|
||||
- Only runs after quick tests pass (fail-early gate)
|
||||
- Uploads `junit-integration.xml` as a workflow artifact
|
||||
|
||||
### Ruff Lint & Format
|
||||
|
||||
@@ -48,8 +72,9 @@ The CI workflow (`.github/workflows/tests.yaml`) runs automatically on every pus
|
||||
The following artifacts are uploaded after every run:
|
||||
|
||||
| Artifact | Contents | Condition |
|
||||
|------------------|--------------------------------------|----------------------|
|
||||
| `test-results` | `junit.xml`, `coverage.xml` | Always (unless cancelled) |
|
||||
|-------------------------------|--------------------------------------|----------------------|
|
||||
| `test-results-quick` | `junit.xml`, `coverage.xml` | Always (unless cancelled) |
|
||||
| `test-results-integration` | `junit-integration.xml` | Always (unless cancelled) |
|
||||
|
||||
## Running Linters Locally
|
||||
|
||||
@@ -68,8 +93,14 @@ ruff format --check app/ tests/
|
||||
# Run type checking
|
||||
mypy app/
|
||||
|
||||
# Run tests
|
||||
pytest tests/ -v --cov=app --cov-report=term -m "not e2e"
|
||||
# Run quick tests (same as CI quick stage)
|
||||
pytest tests/ -v --timeout=120 --cov=app --cov-report=term -m "not e2e and not requires_docker and not requires_external and not slow"
|
||||
|
||||
# Run integration tests (requires Docker)
|
||||
pytest tests/ -v --timeout=300 -m "(requires_docker or requires_external or slow) and not e2e"
|
||||
|
||||
# Run all tests except e2e
|
||||
pytest tests/ -v --timeout=120 -m "not e2e"
|
||||
```
|
||||
|
||||
Or use pre-commit hooks to run checks automatically on each commit (recommended):
|
||||
|
||||
@@ -147,6 +147,7 @@ addopts = [
|
||||
"--cov-report=term-missing",
|
||||
"--cov-report=html",
|
||||
"--cov-branch",
|
||||
"--timeout=120",
|
||||
]
|
||||
markers = [
|
||||
"unit: Unit tests for individual functions/methods",
|
||||
|
||||
@@ -6,6 +6,7 @@ pytest>=8.0.0
|
||||
pytest-cov>=4.1.0
|
||||
pytest-asyncio>=0.23.0
|
||||
pytest-mock>=3.12.0
|
||||
pytest-timeout>=2.3.0 # Per-test timeout enforcement to prevent CI hangs
|
||||
httpx>=0.26.0 # For async test client
|
||||
testcontainers>=3.7.1 # For integration tests with real containers
|
||||
fpdf2>=2.8.0 # For generating test PDF documents in integration tests
|
||||
|
||||
@@ -361,12 +361,28 @@ docker volume prune -f
|
||||
|
||||
### GitHub Actions - Current Configuration
|
||||
|
||||
The DocuElevate CI workflow (`.github/workflows/tests.yaml`) **excludes E2E tests** by default because they require Docker-in-Docker (testcontainers), which requires additional configuration in GitHub Actions.
|
||||
The DocuElevate CI pipeline (`.github/workflows/ci.yml`) uses a **two-stage test strategy** for fast feedback:
|
||||
|
||||
1. **Quick Tests** (`test-quick`) — Runs ~2,790 unit and basic integration tests in ~2 minutes. Excludes tests marked `e2e`, `requires_docker`, `requires_external`, or `slow`. This stage gates the integration tests.
|
||||
|
||||
2. **Integration Tests** (`test-integration`) — Runs ~28 Docker-based and external service tests. Only starts after quick tests pass. Uses testcontainers for WebDAV, OAuth, etc.
|
||||
|
||||
Both stages use `pytest-timeout` to prevent individual tests from hanging:
|
||||
- Quick tests: 120 seconds per test, 15-minute job timeout
|
||||
- Integration tests: 300 seconds per test, 20-minute job timeout
|
||||
|
||||
```yaml
|
||||
- name: Run Tests
|
||||
# Exclude E2E tests - they require Docker-in-Docker (testcontainers)
|
||||
run: pytest tests/ -v --cov=app -m "not e2e"
|
||||
# Quick tests (Stage 2b)
|
||||
- name: Run Quick Tests
|
||||
run: >
|
||||
pytest tests/ -v --timeout=120
|
||||
-m "not e2e and not requires_docker and not requires_external and not slow"
|
||||
|
||||
# Integration tests (Stage 2c) — only after quick tests pass
|
||||
- name: Run Integration Tests
|
||||
run: >
|
||||
pytest tests/ -v --timeout=300
|
||||
-m "(requires_docker or requires_external or slow) and not e2e"
|
||||
```
|
||||
|
||||
**To run E2E tests locally:**
|
||||
|
||||
@@ -580,10 +580,36 @@ class TestSendToAllDestinations:
|
||||
# Error should be recorded in results
|
||||
assert "dropbox_error" in result.result["tasks"]
|
||||
|
||||
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_email")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_sftp")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_ftp")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_webdav")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_google_drive")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_paperless")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_nextcloud")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_dropbox")
|
||||
@patch("app.tasks.send_to_all.log_task_progress")
|
||||
@patch("app.tasks.send_to_all.SessionLocal")
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
def test_fallback_file_id_lookup(self, mock_settings, mock_session_local, mock_log, tmp_path):
|
||||
def test_fallback_file_id_lookup(
|
||||
self,
|
||||
mock_settings,
|
||||
mock_session_local,
|
||||
mock_log,
|
||||
mock_dropbox,
|
||||
mock_nextcloud,
|
||||
mock_paperless,
|
||||
mock_google,
|
||||
mock_webdav,
|
||||
mock_ftp,
|
||||
mock_sftp,
|
||||
mock_email,
|
||||
mock_onedrive,
|
||||
mock_s3,
|
||||
tmp_path,
|
||||
):
|
||||
"""Test file_id lookup fallback when not provided."""
|
||||
from app.models import FileRecord
|
||||
|
||||
@@ -592,6 +618,18 @@ class TestSendToAllDestinations:
|
||||
|
||||
mock_settings.workdir = str(tmp_path)
|
||||
|
||||
# Disable all upload services — this test only checks the DB lookup fallback
|
||||
mock_dropbox.return_value = False
|
||||
mock_nextcloud.return_value = False
|
||||
mock_paperless.return_value = False
|
||||
mock_google.return_value = False
|
||||
mock_webdav.return_value = False
|
||||
mock_ftp.return_value = False
|
||||
mock_sftp.return_value = False
|
||||
mock_email.return_value = False
|
||||
mock_onedrive.return_value = False
|
||||
mock_s3.return_value = False
|
||||
|
||||
# Mock database session
|
||||
mock_db = MagicMock()
|
||||
mock_query = MagicMock()
|
||||
|
||||
Reference in New Issue
Block a user