| # Property-Based Test: [Invariant Name] | |
| ## Purpose | |
| Validate invariant: [invariant statement] for [target module/function] | |
| **What this test validates:** | |
| - Invariant holds for all valid inputs (not just hand-picked examples) | |
| - Edge cases discovered through automatic test generation | |
| - Counterexample shrinking to minimal failing case | |
| **Target:** | |
| - Module: `backend/core/[module].py` | |
| - Function: `[function_name]` | |
| - Input type: `[strings, integers, lists, JSON, etc.]` | |
| ## Dependencies | |
| **Required Libraries:** | |
| ```bash | |
| pip install hypothesis==6.92.0 | |
| ``` | |
| **Target Module:** | |
| - `backend/core/[module].py` - [description of target] | |
| - `backend/api/[routes].py` - [description of target API] | |
| **Hypothesis Strategies:** | |
| - `hypothesis.strategies.text()` - String generation | |
| - `hypothesis.strategies.integers()` - Integer generation | |
| - `hypothesis.strategies.lists()` - List generation | |
| - `hypothesis.strategies.dictionaries()` - Dictionary generation | |
| - `hypothesis.strategies.builds()` - Custom object generation | |
| ## Invariant (Document Before Writing Test) | |
| **CRITICAL: Document invariant FIRST, then write test.** | |
| **Property:** [What must be true for all inputs] | |
| Example invariants: | |
| - "Workflow serialization is lossless for all step lists" | |
| - "Agent execution is idempotent for all agent IDs" | |
| - "JSON round-trip preserves data for all valid JSON objects" | |
| - "Episode segmentation produces contiguous time segments for all message lists" | |
| **Domain:** [Input space: strings, integers, lists, JSON objects, etc.] | |
| **Preconditions:** [Required conditions for invariant to hold] | |
| Example: | |
| - Input must be valid UTF-8 string | |
| - List must contain 0-100 items | |
| - JSON must conform to schema | |
| **Postconditions:** [What must be true after operation] | |
| Example: | |
| - Output list has same length as input | |
| - All IDs in output are valid UUIDs | |
| - Timestamps are monotonically increasing | |
| **Example Invariant Documentation:** | |
| ```python | |
| """ | |
| Invariant: Workflow serialization is lossless for all step lists. | |
| Property: | |
| - For any list of workflow steps, serializing and deserializing | |
| produces an equivalent workflow with the same steps. | |
| Domain: | |
| - Input: List of workflow steps (dict with 'action', 'params', 'order') | |
| - Size: 0-100 steps per workflow | |
| - Actions: 'create_agent', 'execute_workflow', 'present_canvas' | |
| Preconditions: | |
| - All steps have valid 'action' field | |
| - All steps have 'order' field (integer, 0-1000) | |
| - 'params' is a dict (can be empty) | |
| Postconditions: | |
| - Deserialized workflow has same number of steps as input | |
| - All steps are present in same order | |
| - All step fields are preserved (action, params, order) | |
| """ | |
| ``` | |
| ## Setup | |
| **Hypothesis settings configuration:** | |
| ```python | |
| from hypothesis import given, settings, strategies as st | |
| from tests.property_tests.conftest import DEFAULT_PROFILE, CI_PROFILE | |
| # Use CI profile for faster tests (50 examples, 5s deadline) | |
| # Use local profile for thorough testing (200 examples, 30s deadline) | |
| # Settings profiles defined in tests/property_tests/conftest.py: | |
| # - CI_PROFILE: max_examples=50, deadline=5s (fast for PR checks) | |
| # - DEFAULT_PROFILE: max_examples=200, deadline=30s (thorough for local) | |
| # Example: Use CI profile for fast tests | |
| @settings(CI_PROFILE) | |
| # Example: Use default profile for thorough tests | |
| @settings(DEFAULT_PROFILE) | |
| # Custom settings | |
| @settings( | |
| max_examples=100, | |
| deadline=timedelta(seconds=10), | |
| phases=[Phase.generate] # Skip reuse phase for faster tests | |
| ) | |
| ``` | |
| **Import strategies:** | |
| ```python | |
| from hypothesis import strategies as st | |
| # Common strategies | |
| st.text() # Random strings (unicode, min_size=0) | |
| st.integers(min_value=0, max_value=100) # Bounded integers | |
| st.lists(st.integers(), min_size=0, max_size=100) # Lists | |
| st.dictionaries(st.text(), st.integers()) # Dicts | |
| st.builds(MyModel, id=st.uuid4(), name=st.text()) # Custom objects | |
| ``` | |
| ## Test Procedure | |
| **Step 1: Define invariant (BEFORE writing test)** | |
| ```python | |
| # Document invariant in docstring | |
| def test_workflow_serialization_roundtrip(steps): | |
| """ | |
| Test that workflow serialization is lossless. | |
| Invariant: Serializing and deserializing a workflow produces | |
| an equivalent workflow with the same steps. | |
| Strategy: | |
| - Generate random workflow steps (0-100 steps) | |
| - Each step has: action, params, order | |
| - Actions from: create_agent, execute_workflow, present_canvas | |
| Expected: | |
| - Deserialized workflow has same steps as input | |
| - All fields preserved (action, params, order) | |
| - Steps in same order | |
| """ | |
| ``` | |
| **Step 2: Write test with @given decorator** | |
| ```python | |
| import pytest | |
| from hypothesis import given, settings, strategies as st | |
| from tests.property_tests.conftest import DEFAULT_PROFILE | |
| from backend.core.workflow_engine import WorkflowDefinition | |
| @pytest.mark.property | |
| @given(st.lists( | |
| st.fixed_dictionaries({ | |
| 'action': st.sampled_from(['create_agent', 'execute_workflow', 'present_canvas']), | |
| 'params': st.dictionaries(st.text(), st.text()), | |
| 'order': st.integers(min_value=0, max_value=1000) | |
| }), | |
| min_size=0, | |
| max_size=100 | |
| )) | |
| @settings(DEFAULT_PROFILE) | |
| def test_workflow_serialization_roundtrip(steps): | |
| """ | |
| Test that workflow serialization is lossless for all step lists. | |
| Invariant: For any list of workflow steps, serializing and deserializing | |
| produces an equivalent workflow with the same steps. | |
| Strategy: Generate 0-100 workflow steps with random actions, params, order. | |
| Expected: All steps preserved in same order. | |
| """ | |
| # Arrange: Create workflow from generated steps | |
| workflow = WorkflowDefinition(name="test", steps=steps) | |
| # Act: Serialize and deserialize | |
| serialized = workflow.serialize() | |
| deserialized = WorkflowDefinition.deserialize(serialized) | |
| # Assert: Invariant holds | |
| assert len(deserialized.steps) == len(steps), \ | |
| f"Step count mismatch: {len(deserialized.steps)} != {len(steps)}" | |
| for i, (original, recovered) in enumerate(zip(steps, deserialized.steps)): | |
| assert recovered['action'] == original['action'], \ | |
| f"Step {i}: action mismatch ({recovered['action']} != {original['action']})" | |
| assert recovered['order'] == original['order'], \ | |
| f"Step {i}: order mismatch ({recovered['order']} != {original['order']})" | |
| assert recovered['params'] == original['params'], \ | |
| f"Step {i}: params mismatch ({recovered['params']} != {original['params']})" | |
| ``` | |
| **Step 3: Run test and verify** | |
| ```bash | |
| # Run property test | |
| pytest backend/tests/property_tests/test_workflow_properties.py::test_workflow_serialization_roundtrip -v | |
| # Hypothesis will: | |
| # 1. Generate 100+ random examples (by default) | |
| # 2. Shrink counterexample to minimal case (if invariant violated) | |
| # 3. Print minimal failing input (for bug filing) | |
| # Example output on failure: | |
| # Falsifying example: | |
| # test_workflow_serialization_roundtrip( | |
| # steps=[ | |
| # {'action': 'create_agent', 'params': {}, 'order': 0}, | |
| # {'action': 'execute_workflow', 'params': {'name': ''}, 'order': 1} | |
| # ] | |
| # ) | |
| # Shrunk from 53 steps to 2 steps in 0.05s | |
| ``` | |
| **Step 4: Handle invariant violations** | |
| ```python | |
| # If test fails, Hypothesis provides minimal counterexample | |
| # Example: Steps with empty 'name' param cause serialization error | |
| # Fix bug or refine invariant | |
| # Option 1: Fix bug in serialization logic | |
| # Option 2: Add precondition: 'name' must be non-empty | |
| # Option 3: Update invariant to handle empty names correctly | |
| # Example: Add precondition to strategy | |
| @given(st.lists( | |
| st.fixed_dictionaries({ | |
| 'action': st.sampled_from(['create_agent', 'execute_workflow', 'present_canvas']), | |
| 'params': st.dictionaries( | |
| st.text(min_size=1, max_size=10), # Non-empty keys | |
| st.text(min_size=1) # Non-empty values (precondition) | |
| ), | |
| 'order': st.integers(min_value=0, max_value=1000) | |
| }), | |
| min_size=0, | |
| max_size=100 | |
| )) | |
| @settings(DEFAULT_PROFILE) | |
| def test_workflow_serialization_roundtrip_with_preconditions(steps): | |
| """ | |
| Test that workflow serialization is lossless for all step lists. | |
| Invariant: For any list of workflow steps with non-empty params, | |
| serializing and deserializing produces an equivalent workflow. | |
| Precondition: All param keys and values must be non-empty strings. | |
| """ | |
| # ... same test logic | |
| ``` | |
| ## Expected Behavior | |
| **Invariant holds (test passes):** | |
| - All generated examples satisfy invariant | |
| - Hypothesis runs 100-200 examples (depending on profile) | |
| - No counterexamples found | |
| - Test completes in <30s (per TQ-03) | |
| **Invariant violated (test fails):** | |
| - Hypothesis finds counterexample | |
| - Automatically shrinks to minimal failing case | |
| - Prints minimal input that violates invariant | |
| - Provides reproduction script | |
| **Example failure output:** | |
| ```python | |
| # ==================== FAILURES ==================== | |
| # ____________________ test_workflow_serialization_roundtrip ____________________ | |
| # | |
| # Falsifying example: | |
| # test_workflow_serialization_roundtrip( | |
| # steps=[ | |
| # {'action': 'execute_workflow', 'params': {'name': ''}, 'order': 0} | |
| # ] | |
| # ) | |
| # Shrunk from 47 steps to 1 step in 0.03s | |
| # | |
| # assert 1 == 0 | |
| # + where 1 = len([{'action': 'execute_workflow', 'params': {}, 'order': 0}]) | |
| # + and 0 = len([]) | |
| # | |
| # Step 0: params mismatch ({'name': ''} != {}) | |
| ``` | |
| **Hypothesis shrinking process:** | |
| 1. Find first failing example (may be complex: 53 steps) | |
| 2. Simplify example (remove steps, reduce values) | |
| 3. Find minimal counterexample (2 steps → 1 step) | |
| 4. Report minimal failing case for debugging | |
| ## Bug Filing | |
| **Automatic bug filing on invariant violation:** | |
| ```python | |
| from tests.bug_discovery.bug_filing_service import BugFilingService | |
| @pytest.mark.property | |
| @given(st.lists(st.integers(), min_size=0, max_size=100)) | |
| @settings(DEFAULT_PROFILE) | |
| def test_[invariant_name](inputs): | |
| """ | |
| Test that [invariant] holds for all [inputs]. | |
| Invariant: [statement] | |
| Strategy: [strategy description] | |
| Fails on: [known counterexample] | |
| """ | |
| try: | |
| # Test logic | |
| result = [function_under_test](inputs) | |
| assert [invariant_check](result), f"Invariant violated: {result}" | |
| except AssertionError as e: | |
| # File bug with counterexample | |
| BugFilingService.file_bug( | |
| test_name=f"test_{[invariant_name]}_violation", | |
| error_message=f"Invariant violation: {str(e)}", | |
| metadata={ | |
| "test_type": "property", | |
| "invariant": "[invariant_name]", | |
| "counterexample": str(inputs), | |
| "shrunk_input": str(inputs), # Hypothesis already shrunk | |
| "hypothesis_examples": 100, # Number of examples run | |
| "strategy": "st.lists(st.integers(), min_size=0, max_size=100)" | |
| }, | |
| expected_behavior=f"Invariant should hold: {[invariant_statement]}", | |
| actual_behavior=f"Invariant violated for input: {inputs}" | |
| ) | |
| raise # Re-raise to fail test | |
| ``` | |
| **Manual bug filing (if not automatic):** | |
| ```bash | |
| # Bug title: [Bug] Invariant violation: [Invariant Name] | |
| # Bug body: | |
| ## Bug Description | |
| Property-based test discovered invariant violation in [function_name]. | |
| ## Invariant | |
| **Statement:** [invariant statement] | |
| **Domain:** [input space] | |
| **Preconditions:** [required conditions] | |
| ## Counterexample | |
| ```python | |
| # Minimal failing input (shrunk by Hypothesis) | |
| inputs = [paste counterexample from test output] | |
| # Reproducer | |
| from backend.core.[module] import [function_name] | |
| result = [function_name](inputs) | |
| # Expected: [expected behavior] | |
| # Actual: [actual behavior] | |
| ``` | |
| ## Steps to Reproduce | |
| 1. Run property test: `pytest backend/tests/property_tests/test_[module]_properties.py::test_[invariant_name] -v` | |
| 2. Hypothesis finds counterexample after N examples | |
| 3. Counterexample shrunk to minimal case: [paste input] | |
| 4. Invariant violated: [description of violation] | |
| ## Shrinking Process | |
| - Original failing example: [N] steps/items | |
| - Shrunk to: [M] steps/items (minimal case) | |
| - Shrinking time: [seconds] | |
| ## Hypothesis Output | |
| ``` | |
| [paste Hypothesis output with counterexample] | |
| ``` | |
| ## Expected Behavior | |
| Invariant should hold: [invariant statement] | |
| For input: [counterexample], expected: [expected result] | |
| ## Actual Behavior | |
| Invariant violated: [description of violation] | |
| For input: [counterexample], actual: [actual result] | |
| ## Test Context | |
| - **Test:** `test_[invariant_name]` | |
| - **Hypothesis examples run:** [N] | |
| - **Strategy:** [Hypothesis strategy used] | |
| - **Settings:** [max_examples, deadline] | |
| - **Platform:** [output of `uname -a`] | |
| - **Python:** [output of `python --version`] | |
| ``` | |
| ## TQ Compliance | |
| **TQ-01 (Test Independence):** | |
| - Each test generates fresh inputs (Hypothesis @given decorator) | |
| - No shared state between property tests | |
| - Each invariant tested independently | |
| **TQ-02 (Pass Rate):** | |
| - Property tests have 100% pass rate (invariant violations = real bugs) | |
| - Same input always produces same output (deterministic target function) | |
| - No flaky tests (Hypothesis provides reproducible examples) | |
| **TQ-03 (Performance):** | |
| - Hypothesis settings enforce deadline (30s default) | |
| - CI profile: 50 examples, 5s deadline (fast for PR checks) | |
| - Default profile: 200 examples, 30s deadline (thorough for local) | |
| **TQ-04 (Determinism):** | |
| - Same input produces same output (deterministic target function required) | |
| - Hypothesis uses fixed random seed (reproducible examples) | |
| - Counterexamples are reproducible (same test run = same failure) | |
| **TQ-05 (Coverage Quality):** | |
| - Tests invariant (observable behavior), not implementation | |
| - Hypothesis explores input space systematically (edge cases discovered) | |
| - Property-based: tests general property, not specific examples | |
| ## pytest.ini Marker | |
| Add to `backend/pytest.ini`: | |
| ```ini | |
| [pytest] | |
| markers = | |
| property: Property-based tests (Hypothesis, slow, thorough) | |
| ``` | |
| Run only property tests: | |
| ```bash | |
| pytest backend/tests/property_tests/ -v -m property | |
| ``` | |
| Skip property tests in fast CI: | |
| ```bash | |
| pytest backend/tests/ -v -m "not property" | |
| ``` | |
| ## Invariant-First Thinking | |
| **Process:** | |
| 1. **Document invariant first** (before writing test) | |
| 2. Write test that validates invariant | |
| 3. Run test to discover counterexamples | |
| 4. Fix bugs or refine invariant (add preconditions) | |
| 5. Re-run test to verify fix | |
| **Why invariant-first?** | |
| - Forces clarity about what must be true | |
| - Prevents implementation-driven tests | |
| - Catches edge cases early | |
| - Makes tests maintainable (invariant is documentation) | |
| **Bad example (not invariant-first):** | |
| ```python | |
| # BAD: Test specific examples, no invariant documented | |
| def test_workflow_serialization(): | |
| workflow = Workflow(steps=[{'action': 'create_agent'}]) | |
| serialized = workflow.serialize() | |
| deserialized = Workflow.deserialize(serialized) | |
| assert deserialized.steps == workflow.steps | |
| ``` | |
| **Good example (invariant-first):** | |
| ```python | |
| # GOOD: Invariant documented, tested for all inputs | |
| @given(st.lists(st.builds(WorkflowStep))) | |
| @settings(DEFAULT_PROFILE) | |
| def test_workflow_serialization_roundtrip(steps): | |
| """ | |
| Test that workflow serialization is lossless for all step lists. | |
| Invariant: For any list of workflow steps, serializing and | |
| deserializing produces an equivalent workflow. | |
| """ | |
| workflow = Workflow(steps=steps) | |
| serialized = workflow.serialize() | |
| deserialized = Workflow.deserialize(serialized) | |
| assert deserialized.steps == workflow.steps | |
| ``` | |
| ## See Also | |
| - [Hypothesis Documentation](https://hypothesis.readthedocs.io) | |
| - [Property-Based Testing](https://hypothesis.works/articles/what-is-property-based-testing/) | |
| - `backend/docs/TEST_QUALITY_STANDARDS.md` - TQ-01 through TQ-05 | |
| - `backend/tests/bug_discovery/TEMPLATES/README.md` - Template usage guide | |