feat(testing): implement Phase 4 advanced testing

- Add Hypothesis property-based tests (19 tests)
- Add Syrupy snapshot tests (7 tests)
- Add pytest-benchmark performance tests (11 tests)
- Fix Hypothesis plugin compatibility by creating MockModule class
- Update pytest.ini to exclude .hypothesis directory
- Add .hypothesis/ to .gitignore
- Update requirements-dev.txt with testing dependencies
- Mark Phase 4 complete in backend-testing-improvement-plan.md

All 947 tests passing.
This commit is contained in:
Will Miao
2026-02-11 11:58:28 +08:00
parent 8e30008b29
commit 85e511d81c
9 changed files with 755 additions and 16 deletions

View File

@@ -0,0 +1,193 @@
"""Property-based tests using Hypothesis.
These tests verify fundamental properties of utility functions using
property-based testing to catch edge cases and ensure correctness.
"""
from __future__ import annotations
import pytest
from hypothesis import given, settings, strategies as st
from py.utils.cache_paths import _sanitize_library_name
from py.utils.file_utils import get_preview_extension, normalize_path
from py.utils.model_utils import determine_base_model
from py.utils.utils import (
calculate_recipe_fingerprint,
fuzzy_match,
sanitize_folder_name,
)
class TestSanitizeFolderName:
"""Property-based tests for sanitize_folder_name function."""
@given(st.text(alphabet='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._- '))
def test_sanitize_is_idempotent_for_ascii(self, name: str):
"""Sanitizing an already sanitized ASCII name should not change it."""
sanitized = sanitize_folder_name(name)
resanitized = sanitize_folder_name(sanitized)
assert sanitized == resanitized
@given(st.text())
def test_sanitize_never_contains_invalid_chars(self, name: str):
"""Sanitized names should never contain filesystem-invalid characters."""
sanitized = sanitize_folder_name(name)
invalid_chars = '<>:"/\\|?*\x00\x01\x02\x03\x04\x05\x06\x07\x08'
for char in invalid_chars:
assert char not in sanitized
@given(st.text())
def test_sanitize_never_returns_none(self, name: str):
"""Sanitize should never return None (always returns a string)."""
result = sanitize_folder_name(name)
assert result is not None
assert isinstance(result, str)
@given(st.text(min_size=1))
def test_sanitize_preserves_some_content(self, name: str):
"""Sanitizing a non-empty string should not produce an empty result
unless the input was only invalid characters."""
result = sanitize_folder_name(name)
# If input had valid characters, output should not be empty
has_valid_chars = any(c.isalnum() or c in '._-' for c in name)
if has_valid_chars:
assert result != ""
class TestSanitizeLibraryName:
"""Property-based tests for _sanitize_library_name function."""
@given(st.text() | st.none())
def test_sanitize_library_name_is_idempotent(self, library_name: str | None):
"""Sanitizing an already sanitized library name should not change it."""
sanitized = _sanitize_library_name(library_name)
resanitized = _sanitize_library_name(sanitized)
assert sanitized == resanitized
@given(st.text())
def test_sanitize_library_name_only_contains_safe_chars(self, library_name: str):
"""Sanitized library names should only contain safe filename characters."""
sanitized = _sanitize_library_name(library_name)
# Should only contain alphanumeric, underscore, dot, and hyphen
for char in sanitized:
assert char.isalnum() or char in '._-'
class TestNormalizePath:
"""Property-based tests for normalize_path function."""
@given(st.text(alphabet='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-/\\') | st.none())
def test_normalize_path_is_idempotent_for_ascii(self, path: str | None):
"""Normalizing an already normalized ASCII path should not change it."""
normalized = normalize_path(path)
renormalized = normalize_path(normalized)
assert normalized == renormalized
@given(st.text())
def test_normalized_path_returns_string(self, path: str):
"""Normalized path should always return a string (or None)."""
normalized = normalize_path(path)
# Result is either None or a string
assert normalized is None or isinstance(normalized, str)
class TestFuzzyMatch:
"""Property-based tests for fuzzy_match function."""
@given(st.text(), st.text())
def test_fuzzy_match_empty_pattern_returns_false(self, text: str, pattern: str):
"""Empty pattern should never match (except empty text with exact match)."""
if not pattern:
result = fuzzy_match(text, pattern)
assert result is False
@given(st.text(min_size=1), st.text(min_size=1))
def test_fuzzy_match_exact_substring_always_matches(self, text: str, pattern: str):
"""If pattern is a substring of text (case-insensitive), it should match."""
# Create a case where pattern is definitely in text
combined = text.lower() + " " + pattern.lower()
result = fuzzy_match(combined, pattern.lower())
assert result is True
@given(st.text(min_size=1), st.text(min_size=1))
def test_fuzzy_match_substring_always_matches(self, text: str, pattern: str):
"""If pattern is a substring of text, it should always match."""
if pattern in text:
result = fuzzy_match(text, pattern)
assert result is True
class TestDetermineBaseModel:
"""Property-based tests for determine_base_model function."""
@given(st.text() | st.none())
def test_determine_base_model_never_returns_none(self, version_string: str | None):
"""Function should never return None (always returns a string)."""
result = determine_base_model(version_string)
assert result is not None
assert isinstance(result, str)
@given(st.text())
def test_determine_base_model_case_insensitive(self, version: str):
"""Base model detection should be case-insensitive."""
lower_result = determine_base_model(version.lower())
upper_result = determine_base_model(version.upper())
# Results should be the same for known mappings
if version.lower() in ['sdxl', 'sd_1.5', 'pony', 'flux1']:
assert lower_result == upper_result
class TestGetPreviewExtension:
"""Property-based tests for get_preview_extension function."""
@given(st.text())
def test_get_preview_extension_returns_string(self, preview_path: str):
"""Function should always return a string."""
result = get_preview_extension(preview_path)
assert isinstance(result, str)
@given(st.text(alphabet='abcdefghijklmnopqrstuvwxyz._'))
def test_get_preview_extension_starts_with_dot(self, preview_path: str):
"""Extension should always start with a dot for valid paths."""
if '.' in preview_path:
result = get_preview_extension(preview_path)
if result:
assert result.startswith('.')
class TestCalculateRecipeFingerprint:
"""Property-based tests for calculate_recipe_fingerprint function."""
@given(st.lists(st.dictionaries(st.text(), st.text() | st.integers() | st.floats(), min_size=1), min_size=0, max_size=50))
def test_fingerprint_is_deterministic(self, loras: list):
"""Same input should always produce same fingerprint."""
fp1 = calculate_recipe_fingerprint(loras)
fp2 = calculate_recipe_fingerprint(loras)
assert fp1 == fp2
@given(st.lists(st.dictionaries(st.text(), st.text() | st.integers() | st.floats(), min_size=1), min_size=0, max_size=50))
def test_fingerprint_returns_string(self, loras: list):
"""Function should always return a string."""
result = calculate_recipe_fingerprint(loras)
assert isinstance(result, str)
def test_fingerprint_empty_list_returns_empty_string(self):
"""Empty list should return empty string."""
result = calculate_recipe_fingerprint([])
assert result == ""
@given(st.lists(st.dictionaries(st.text(), st.text() | st.integers() | st.floats(), min_size=1), min_size=1, max_size=10))
def test_fingerprint_different_inputs_produce_different_results(self, loras1: list):
"""Different inputs should generally produce different fingerprints."""
# Create a different input by modifying the first LoRA
loras2 = loras1.copy()
if loras2:
loras2[0] = {**loras2[0], 'hash': 'different_hash_12345'}
fp1 = calculate_recipe_fingerprint(loras1)
fp2 = calculate_recipe_fingerprint(loras2)
# If the first LoRA had a hash, fingerprints should differ
if loras1 and loras1[0].get('hash'):
assert fp1 != fp2