re-uploading work
This commit is contained in:
723
docs/packs/PACK_TESTING.md
Normal file
723
docs/packs/PACK_TESTING.md
Normal file
@@ -0,0 +1,723 @@
|
||||
# Pack Testing Framework
|
||||
|
||||
**Complete guide to testing Attune packs programmatically**
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The Pack Testing Framework enables automatic validation of packs during installation and development. Tests are defined in `pack.yaml` and executed by the worker service or CLI tool.
|
||||
|
||||
**Benefits:**
|
||||
- ✅ Fail-fast pack installation (catch issues before deployment)
|
||||
- ✅ Validate dependencies in target environment
|
||||
- ✅ Audit trail of test results
|
||||
- ✅ Quality assurance for pack ecosystem
|
||||
- ✅ CI/CD integration ready
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Add Testing Configuration to pack.yaml
|
||||
|
||||
```yaml
|
||||
testing:
|
||||
enabled: true
|
||||
|
||||
discovery:
|
||||
method: "directory"
|
||||
path: "tests"
|
||||
|
||||
runners:
|
||||
shell:
|
||||
type: "script"
|
||||
entry_point: "tests/run_tests.sh"
|
||||
timeout: 60
|
||||
result_format: "simple"
|
||||
|
||||
python:
|
||||
type: "unittest"
|
||||
entry_point: "tests/test_actions.py"
|
||||
timeout: 120
|
||||
result_format: "simple"
|
||||
|
||||
min_pass_rate: 1.0
|
||||
on_failure: "block"
|
||||
```
|
||||
|
||||
### 2. Create Test Files
|
||||
|
||||
**Shell Test Runner** (`tests/run_tests.sh`):
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
PASSED=0
|
||||
FAILED=0
|
||||
TOTAL=0
|
||||
|
||||
# Run your tests here
|
||||
./actions/my_action.sh --test
|
||||
if [ $? -eq 0 ]; then
|
||||
PASSED=$((PASSED + 1))
|
||||
else
|
||||
FAILED=$((FAILED + 1))
|
||||
fi
|
||||
TOTAL=$((TOTAL + 1))
|
||||
|
||||
# Output results (required format)
|
||||
echo "Total Tests: $TOTAL"
|
||||
echo "Passed: $PASSED"
|
||||
echo "Failed: $FAILED"
|
||||
|
||||
exit $FAILED
|
||||
```
|
||||
|
||||
**Python Test Runner** (`tests/test_actions.py`):
|
||||
```python
|
||||
import unittest
|
||||
from actions import my_action
|
||||
|
||||
class TestMyAction(unittest.TestCase):
|
||||
def test_basic_execution(self):
|
||||
result = my_action.run({"param": "value"})
|
||||
self.assertEqual(result["status"], "success")
|
||||
|
||||
def test_error_handling(self):
|
||||
with self.assertRaises(ValueError):
|
||||
my_action.run({"invalid": "params"})
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
```
|
||||
|
||||
### 3. Run Tests
|
||||
|
||||
```bash
|
||||
# Test a pack before installation
|
||||
attune pack test ./packs/my_pack
|
||||
|
||||
# Test an installed pack
|
||||
attune pack test my_pack
|
||||
|
||||
# Verbose output
|
||||
attune pack test my_pack --verbose
|
||||
|
||||
# JSON output for CI/CD
|
||||
attune pack test my_pack --output json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Configuration
|
||||
|
||||
### Pack.yaml Testing Section
|
||||
|
||||
```yaml
|
||||
testing:
|
||||
# Enable/disable testing
|
||||
enabled: true
|
||||
|
||||
# Test discovery configuration
|
||||
discovery:
|
||||
method: "directory" # or "manifest", "executable"
|
||||
path: "tests" # relative to pack root
|
||||
|
||||
# Test runners by runtime type
|
||||
runners:
|
||||
shell:
|
||||
type: "script"
|
||||
entry_point: "tests/run_tests.sh"
|
||||
timeout: 60 # seconds
|
||||
result_format: "simple"
|
||||
|
||||
python:
|
||||
type: "unittest" # or "pytest"
|
||||
entry_point: "tests/test_actions.py"
|
||||
timeout: 120
|
||||
result_format: "simple"
|
||||
|
||||
node:
|
||||
type: "jest"
|
||||
entry_point: "tests/actions.test.js"
|
||||
timeout: 90
|
||||
result_format: "json"
|
||||
|
||||
# Test result expectations
|
||||
result_path: "tests/results/"
|
||||
min_pass_rate: 1.0 # 100% tests must pass
|
||||
on_failure: "block" # or "warn"
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
#### `enabled` (boolean)
|
||||
- `true`: Tests will be executed
|
||||
- `false`: Tests will be skipped
|
||||
|
||||
#### `discovery.method` (string)
|
||||
- `"directory"`: Discover tests in specified directory (recommended)
|
||||
- `"manifest"`: List tests explicitly in pack.yaml
|
||||
- `"executable"`: Run a single test discovery command
|
||||
|
||||
#### `runners.<name>.type` (string)
|
||||
- `"script"`: Shell script execution
|
||||
- `"unittest"`: Python unittest framework
|
||||
- `"pytest"`: Python pytest framework
|
||||
- `"jest"`: JavaScript Jest framework
|
||||
|
||||
#### `runners.<name>.result_format` (string)
|
||||
- `"simple"`: Parse "Total Tests: X, Passed: Y, Failed: Z" format
|
||||
- `"json"`: Parse structured JSON output
|
||||
- `"junit-xml"`: Parse JUnit XML format (pytest, Jest)
|
||||
- `"tap"`: Parse Test Anything Protocol format
|
||||
|
||||
#### `on_failure` (string)
|
||||
- `"block"`: Prevent pack installation if tests fail
|
||||
- `"warn"`: Allow installation but show warning
|
||||
|
||||
---
|
||||
|
||||
## Test Output Formats
|
||||
|
||||
### Simple Format (Default)
|
||||
|
||||
Your test runner must output these lines:
|
||||
|
||||
```
|
||||
Total Tests: 36
|
||||
Passed: 35
|
||||
Failed: 1
|
||||
Skipped: 0
|
||||
```
|
||||
|
||||
The executor will:
|
||||
1. Parse these counts from stdout/stderr
|
||||
2. Use exit code to determine success/failure
|
||||
3. Exit code 0 = success, non-zero = failure
|
||||
|
||||
### JSON Format (Advanced)
|
||||
|
||||
Output structured JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"total": 36,
|
||||
"passed": 35,
|
||||
"failed": 1,
|
||||
"skipped": 0,
|
||||
"duration_ms": 12345,
|
||||
"tests": [
|
||||
{
|
||||
"name": "test_basic_execution",
|
||||
"status": "passed",
|
||||
"duration_ms": 123,
|
||||
"output": "..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### JUnit XML Format (Future)
|
||||
|
||||
For pytest and Jest, use built-in JUnit reporters:
|
||||
|
||||
```bash
|
||||
# pytest
|
||||
pytest --junit-xml=results.xml
|
||||
|
||||
# Jest
|
||||
jest --reporters=jest-junit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CLI Commands
|
||||
|
||||
### Test a Pack
|
||||
|
||||
```bash
|
||||
# Basic usage
|
||||
attune pack test <pack>
|
||||
|
||||
# Local pack directory
|
||||
attune pack test ./packs/my_pack
|
||||
|
||||
# Installed pack
|
||||
attune pack test my_pack
|
||||
|
||||
# From pack root directory
|
||||
cd packs/my_pack
|
||||
attune pack test .
|
||||
```
|
||||
|
||||
### Output Formats
|
||||
|
||||
```bash
|
||||
# Human-readable table (default)
|
||||
attune pack test my_pack
|
||||
|
||||
# Verbose with test case details
|
||||
attune pack test my_pack --verbose
|
||||
|
||||
# Detailed with stdout/stderr
|
||||
attune pack test my_pack --detailed
|
||||
|
||||
# JSON for scripting
|
||||
attune pack test my_pack --output json
|
||||
|
||||
# YAML output
|
||||
attune pack test my_pack --output yaml
|
||||
```
|
||||
|
||||
### Exit Codes
|
||||
|
||||
- `0`: All tests passed
|
||||
- `1`: One or more tests failed
|
||||
- `2`: Test execution error (timeout, missing config, etc.)
|
||||
|
||||
Perfect for CI/CD pipelines:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
if attune pack test my_pack; then
|
||||
echo "✅ Tests passed, deploying..."
|
||||
attune pack install ./packs/my_pack
|
||||
else
|
||||
echo "❌ Tests failed, aborting deployment"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Core Pack (Complete)
|
||||
|
||||
See `packs/core/` for a complete example:
|
||||
|
||||
- **Configuration**: `packs/core/pack.yaml` (testing section)
|
||||
- **Shell Tests**: `packs/core/tests/run_tests.sh` (36 tests)
|
||||
- **Python Tests**: `packs/core/tests/test_actions.py` (38 tests)
|
||||
- **Documentation**: `packs/core/tests/README.md`
|
||||
|
||||
Test execution:
|
||||
|
||||
```bash
|
||||
$ attune pack test packs/core
|
||||
|
||||
🧪 Testing Pack: core v1.0.0
|
||||
|
||||
Test Results:
|
||||
─────────────────────────────────────────────
|
||||
Total Tests: 2
|
||||
✓ Passed: 2
|
||||
✗ Failed: 0
|
||||
○ Skipped: 0
|
||||
Pass Rate: 100.0%
|
||||
Duration: 25542ms
|
||||
─────────────────────────────────────────────
|
||||
|
||||
✓ ✅ All tests passed: 2/2
|
||||
```
|
||||
|
||||
### Example 2: Python Pack with pytest
|
||||
|
||||
```yaml
|
||||
# pack.yaml
|
||||
testing:
|
||||
enabled: true
|
||||
runners:
|
||||
python:
|
||||
type: "pytest"
|
||||
entry_point: "tests/"
|
||||
timeout: 180
|
||||
result_format: "simple"
|
||||
```
|
||||
|
||||
```python
|
||||
# tests/test_mypack.py
|
||||
import pytest
|
||||
from actions.my_action import execute
|
||||
|
||||
def test_success():
|
||||
result = execute({"input": "value"})
|
||||
assert result["status"] == "success"
|
||||
|
||||
def test_validation():
|
||||
with pytest.raises(ValueError):
|
||||
execute({"invalid": None})
|
||||
|
||||
@pytest.mark.skip(reason="Not implemented yet")
|
||||
def test_future_feature():
|
||||
pass
|
||||
```
|
||||
|
||||
### Example 3: Shell Script Tests
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# tests/run_tests.sh
|
||||
|
||||
set -e
|
||||
|
||||
TOTAL=0
|
||||
PASSED=0
|
||||
FAILED=0
|
||||
|
||||
test_action() {
|
||||
local name="$1"
|
||||
local command="$2"
|
||||
local expected_exit="$3"
|
||||
|
||||
TOTAL=$((TOTAL + 1))
|
||||
echo -n "Testing $name... "
|
||||
|
||||
if eval "$command"; then
|
||||
actual_exit=$?
|
||||
else
|
||||
actual_exit=$?
|
||||
fi
|
||||
|
||||
if [ "$actual_exit" -eq "$expected_exit" ]; then
|
||||
echo "PASS"
|
||||
PASSED=$((PASSED + 1))
|
||||
else
|
||||
echo "FAIL (exit: $actual_exit, expected: $expected_exit)"
|
||||
FAILED=$((FAILED + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# Run tests
|
||||
test_action "basic_echo" "./actions/echo.sh 'Hello'" 0
|
||||
test_action "invalid_param" "./actions/echo.sh" 1
|
||||
test_action "http_request" "./actions/http.py --url=https://httpbin.org/get" 0
|
||||
|
||||
# Output results
|
||||
echo ""
|
||||
echo "Total Tests: $TOTAL"
|
||||
echo "Passed: $PASSED"
|
||||
echo "Failed: $FAILED"
|
||||
|
||||
exit $FAILED
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Always Include Tests
|
||||
|
||||
Every pack should have tests. Minimum recommended:
|
||||
- Test each action's success path
|
||||
- Test error handling (invalid inputs)
|
||||
- Test dependencies are available
|
||||
|
||||
### 2. Use Descriptive Test Names
|
||||
|
||||
```python
|
||||
# Good
|
||||
def test_http_request_returns_json_on_success(self):
|
||||
pass
|
||||
|
||||
# Bad
|
||||
def test1(self):
|
||||
pass
|
||||
```
|
||||
|
||||
### 3. Test Exit Codes
|
||||
|
||||
Ensure your tests return proper exit codes:
|
||||
- `0` = success
|
||||
- Non-zero = failure
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# tests/run_tests.sh
|
||||
|
||||
# Run tests
|
||||
python -m unittest discover -s tests
|
||||
|
||||
# Capture exit code
|
||||
TEST_EXIT=$?
|
||||
|
||||
# Output required format
|
||||
echo "Total Tests: 10"
|
||||
echo "Passed: 9"
|
||||
echo "Failed: 1"
|
||||
|
||||
# Exit with test result
|
||||
exit $TEST_EXIT
|
||||
```
|
||||
|
||||
### 4. Test Dependencies
|
||||
|
||||
Validate required libraries are available:
|
||||
|
||||
```python
|
||||
def test_dependencies(self):
|
||||
"""Test required libraries are installed"""
|
||||
try:
|
||||
import requests
|
||||
import croniter
|
||||
except ImportError as e:
|
||||
self.fail(f"Missing dependency: {e}")
|
||||
```
|
||||
|
||||
### 5. Use Timeouts
|
||||
|
||||
Set realistic timeouts for test execution:
|
||||
|
||||
```yaml
|
||||
runners:
|
||||
python:
|
||||
timeout: 120 # 2 minutes max
|
||||
```
|
||||
|
||||
### 6. Mock External Services
|
||||
|
||||
Don't rely on external services in tests:
|
||||
|
||||
```python
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
@patch('requests.get')
|
||||
def test_http_request(self, mock_get):
|
||||
mock_get.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {"status": "ok"}
|
||||
)
|
||||
result = my_action.execute()
|
||||
self.assertEqual(result["status"], "success")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Tests Fail with "Entry point not found"
|
||||
|
||||
**Problem**: Test file doesn't exist or path is wrong
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Check file exists
|
||||
ls -la packs/my_pack/tests/
|
||||
|
||||
# Verify path in pack.yaml is relative to pack root
|
||||
entry_point: "tests/run_tests.sh" # ✓ Correct
|
||||
entry_point: "run_tests.sh" # ✗ Wrong
|
||||
```
|
||||
|
||||
### Tests Timeout
|
||||
|
||||
**Problem**: Tests take too long
|
||||
|
||||
**Solutions**:
|
||||
1. Increase timeout in pack.yaml
|
||||
2. Optimize slow tests
|
||||
3. Mock external dependencies
|
||||
4. Split into separate test suites
|
||||
|
||||
```yaml
|
||||
runners:
|
||||
quick:
|
||||
timeout: 30
|
||||
integration:
|
||||
timeout: 300 # Longer for integration tests
|
||||
```
|
||||
|
||||
### Parse Errors
|
||||
|
||||
**Problem**: Test output format not recognized
|
||||
|
||||
**Solution**: Ensure output includes required lines:
|
||||
|
||||
```bash
|
||||
# Required output format
|
||||
echo "Total Tests: $TOTAL"
|
||||
echo "Passed: $PASSED"
|
||||
echo "Failed: $FAILED"
|
||||
```
|
||||
|
||||
### Exit Code 127 (Command not found)
|
||||
|
||||
**Problem**: Test runner executable not found
|
||||
|
||||
**Solutions**:
|
||||
1. Make test script executable: `chmod +x tests/run_tests.sh`
|
||||
2. Use full interpreter path: `/bin/bash tests/run_tests.sh`
|
||||
3. Check shebang line: `#!/bin/bash`
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Components
|
||||
|
||||
```
|
||||
CLI (attune pack test)
|
||||
↓
|
||||
Worker Test Executor
|
||||
↓
|
||||
Runtime Manager (shell, python, node)
|
||||
↓
|
||||
Test Runners (unittest, pytest, jest)
|
||||
↓
|
||||
Output Parser (simple, json, junit, tap)
|
||||
↓
|
||||
Test Results (structured data)
|
||||
↓
|
||||
Database (pack_test_execution table)
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
pack.yaml (testing config)
|
||||
↓
|
||||
TestConfig (parsed)
|
||||
↓
|
||||
TestExecutor.execute_pack_tests()
|
||||
├─ execute_test_suite(shell)
|
||||
│ └─ parse_simple_output()
|
||||
└─ execute_test_suite(python)
|
||||
└─ parse_simple_output()
|
||||
↓
|
||||
PackTestResult (aggregated)
|
||||
↓
|
||||
CLI display / JSON output / Database storage
|
||||
```
|
||||
|
||||
### Database Schema
|
||||
|
||||
Tests are stored in `pack_test_execution` table:
|
||||
|
||||
```sql
|
||||
CREATE TABLE attune.pack_test_execution (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
pack_id BIGINT NOT NULL REFERENCES attune.pack(id),
|
||||
pack_version TEXT NOT NULL,
|
||||
execution_time TIMESTAMPTZ NOT NULL,
|
||||
trigger_reason TEXT NOT NULL,
|
||||
total_tests INT NOT NULL,
|
||||
passed INT NOT NULL,
|
||||
failed INT NOT NULL,
|
||||
skipped INT NOT NULL,
|
||||
pass_rate DOUBLE PRECISION NOT NULL,
|
||||
duration_ms BIGINT NOT NULL,
|
||||
result JSONB NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API (Future)
|
||||
|
||||
### Test Execution Endpoint
|
||||
|
||||
```http
|
||||
POST /api/v1/packs/{pack_ref}/test
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 123,
|
||||
"packRef": "core",
|
||||
"packVersion": "1.0.0",
|
||||
"totalTests": 74,
|
||||
"passed": 74,
|
||||
"failed": 0,
|
||||
"passRate": 1.0,
|
||||
"durationMs": 25000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Test History Endpoint
|
||||
|
||||
```http
|
||||
GET /api/v1/packs/{pack_ref}/tests?limit=10
|
||||
```
|
||||
|
||||
### Latest Test Result
|
||||
|
||||
```http
|
||||
GET /api/v1/packs/{pack_ref}/tests/latest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
```yaml
|
||||
name: Test Pack
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Install Attune CLI
|
||||
run: |
|
||||
curl -L https://get.attune.io | sh
|
||||
export PATH="$HOME/.attune/bin:$PATH"
|
||||
|
||||
- name: Test Pack
|
||||
run: |
|
||||
attune pack test ./packs/my_pack --output json > results.json
|
||||
|
||||
- name: Upload Results
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: test-results
|
||||
path: results.json
|
||||
```
|
||||
|
||||
### GitLab CI
|
||||
|
||||
```yaml
|
||||
test-pack:
|
||||
stage: test
|
||||
script:
|
||||
- attune pack test ./packs/my_pack
|
||||
artifacts:
|
||||
reports:
|
||||
junit: test-results.xml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- **Design Document**: `docs/pack-testing-framework.md`
|
||||
- **Core Pack Tests**: `packs/core/tests/README.md`
|
||||
- **Database Schema**: `migrations/012_add_pack_test_results.sql`
|
||||
- **API Documentation**: `docs/api-packs.md`
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
- **2026-01-22**: Initial implementation (Phases 1 & 2)
|
||||
- Worker test executor
|
||||
- CLI pack test command
|
||||
- Simple output parser
|
||||
- Core pack validation (76 tests)
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
- GitHub Issues: https://github.com/attune-io/attune/issues
|
||||
- Documentation: https://docs.attune.io/packs/testing
|
||||
- Community: https://community.attune.io
|
||||
Reference in New Issue
Block a user