Skip to content

Commit 91608b3

Browse files
committedMar 10, 2025·
tests(feat[property]): Add property-based testing for configuration models
why: Enhance test coverage and verification of configuration models through property-based testing, ensuring models behave correctly with a wide variety of inputs beyond specific examples. what: - Implement property-based testing using Hypothesis for configuration models - Create comprehensive test strategies for generating valid URLs, paths, and model instances - Add tests verifying serialization roundtrips and invariant properties - Ensure tests verify Repository, Settings, VCSPullConfig, LockFile, and LockedRepository models - Fix type annotations and linting issues in test files - Add Hypothesis dependency to development dependencies refs: Addresses "Property-Based Testing" item from TODO.md
1 parent 2af4676 commit 91608b3

File tree

2 files changed

+471
-0
lines changed

2 files changed

+471
-0
lines changed
 
+228
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
"""Property-based tests for lock file models.
2+
3+
This module contains property-based tests using Hypothesis for the
4+
VCSPull lock file models to ensure they meet invariants and
5+
handle edge cases properly.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import datetime
11+
from pathlib import Path
12+
from typing import Any, Callable
13+
14+
import hypothesis.strategies as st
15+
from hypothesis import given
16+
17+
from vcspull.config.models import LockedRepository, LockFile
18+
19+
20+
# Define strategies for generating test data
21+
@st.composite
22+
def valid_url_strategy(draw: Callable[[st.SearchStrategy[Any]], Any]) -> str:
23+
"""Generate valid URLs for repositories."""
24+
protocols = ["https://", "http://", "git://", "ssh://git@"]
25+
domains = ["github.com", "gitlab.com", "bitbucket.org", "example.com"]
26+
usernames = ["user", "organization", "team", draw(st.text(min_size=3, max_size=10))]
27+
repo_names = [
28+
"repo",
29+
"project",
30+
"library",
31+
f"repo-{
32+
draw(
33+
st.text(
34+
alphabet='abcdefghijklmnopqrstuvwxyz0123456789-_',
35+
min_size=1,
36+
max_size=8,
37+
)
38+
)
39+
}",
40+
]
41+
42+
protocol = draw(st.sampled_from(protocols))
43+
domain = draw(st.sampled_from(domains))
44+
username = draw(st.sampled_from(usernames))
45+
repo_name = draw(st.sampled_from(repo_names))
46+
47+
suffix = ".git" if protocol != "ssh://git@" else ""
48+
49+
return f"{protocol}{domain}/{username}/{repo_name}{suffix}"
50+
51+
52+
@st.composite
53+
def valid_path_strategy(draw: Callable[[st.SearchStrategy[Any]], Any]) -> str:
54+
"""Generate valid paths for repositories."""
55+
base_dirs = ["~/code", "~/projects", "/tmp", "./projects"]
56+
sub_dirs = [
57+
"repo",
58+
"lib",
59+
"src",
60+
f"dir-{
61+
draw(
62+
st.text(
63+
alphabet='abcdefghijklmnopqrstuvwxyz0123456789-_',
64+
min_size=1,
65+
max_size=8,
66+
)
67+
)
68+
}",
69+
]
70+
71+
base_dir = draw(st.sampled_from(base_dirs))
72+
sub_dir = draw(st.sampled_from(sub_dirs))
73+
74+
return f"{base_dir}/{sub_dir}"
75+
76+
77+
@st.composite
78+
def valid_revision_strategy(draw: Callable[[st.SearchStrategy[Any]], Any]) -> str:
79+
"""Generate valid revision strings for repositories."""
80+
# Git commit hash (40 chars hex)
81+
git_hash = draw(st.text(alphabet="0123456789abcdef", min_size=7, max_size=40))
82+
83+
# Git branch/tag (simpler text)
84+
git_ref = draw(
85+
st.text(
86+
alphabet="abcdefghijklmnopqrstuvwxyz0123456789-_/.",
87+
min_size=1,
88+
max_size=20,
89+
),
90+
)
91+
92+
# SVN revision number
93+
svn_rev = str(draw(st.integers(min_value=1, max_value=10000)))
94+
95+
# HG changeset ID
96+
hg_id = draw(st.text(alphabet="0123456789abcdef", min_size=12, max_size=40))
97+
98+
result: str = draw(st.sampled_from([git_hash, git_ref, svn_rev, hg_id]))
99+
return result
100+
101+
102+
@st.composite
103+
def datetime_strategy(
104+
draw: Callable[[st.SearchStrategy[Any]], Any],
105+
) -> datetime.datetime:
106+
"""Generate valid datetime objects within a reasonable range."""
107+
# Using fixed datetimes to avoid flaky behavior
108+
datetimes = [
109+
datetime.datetime(2020, 1, 1),
110+
datetime.datetime(2021, 6, 15),
111+
datetime.datetime(2022, 12, 31),
112+
datetime.datetime(2023, 3, 10),
113+
datetime.datetime(2024, 1, 1),
114+
]
115+
116+
result: datetime.datetime = draw(st.sampled_from(datetimes))
117+
return result
118+
119+
120+
@st.composite
121+
def locked_repository_strategy(
122+
draw: Callable[[st.SearchStrategy[Any]], Any],
123+
) -> LockedRepository:
124+
"""Generate valid LockedRepository instances."""
125+
name = draw(st.one_of(st.none(), st.text(min_size=1, max_size=20)))
126+
url = draw(valid_url_strategy())
127+
path = draw(valid_path_strategy())
128+
vcs = draw(st.sampled_from(["git", "hg", "svn"]))
129+
rev = draw(valid_revision_strategy())
130+
locked_at = draw(datetime_strategy())
131+
132+
return LockedRepository(
133+
name=name,
134+
url=url,
135+
path=path,
136+
vcs=vcs,
137+
rev=rev,
138+
locked_at=locked_at,
139+
)
140+
141+
142+
@st.composite
143+
def lock_file_strategy(draw: Callable[[st.SearchStrategy[Any]], Any]) -> LockFile:
144+
"""Generate valid LockFile instances."""
145+
version = draw(st.sampled_from(["1.0.0", "1.0.1", "1.1.0"]))
146+
created_at = draw(datetime_strategy())
147+
148+
# Generate between 0 and 5 locked repositories
149+
repo_count = draw(st.integers(min_value=0, max_value=5))
150+
repositories = [draw(locked_repository_strategy()) for _ in range(repo_count)]
151+
152+
return LockFile(
153+
version=version,
154+
created_at=created_at,
155+
repositories=repositories,
156+
)
157+
158+
159+
class TestLockedRepositoryProperties:
160+
"""Property-based tests for the LockedRepository model."""
161+
162+
@given(
163+
url=valid_url_strategy(),
164+
path=valid_path_strategy(),
165+
vcs=st.sampled_from(["git", "hg", "svn"]),
166+
rev=valid_revision_strategy(),
167+
)
168+
def test_minimal_locked_repository_properties(
169+
self, url: str, path: str, vcs: str, rev: str
170+
) -> None:
171+
"""Test properties of locked repositories."""
172+
repo = LockedRepository(url=url, path=path, vcs=vcs, rev=rev)
173+
174+
# Check invariants
175+
assert repo.url == url
176+
assert Path(repo.path).is_absolute()
177+
assert repo.path.startswith("/") # Path should be absolute after normalization
178+
assert repo.vcs in {"git", "hg", "svn"}
179+
assert repo.rev == rev
180+
assert isinstance(repo.locked_at, datetime.datetime)
181+
182+
@given(repo=locked_repository_strategy())
183+
def test_locked_repository_roundtrip(self, repo: LockedRepository) -> None:
184+
"""Test locked repository serialization and deserialization."""
185+
# Roundtrip test: convert to dict and back to model
186+
repo_dict = repo.model_dump()
187+
repo2 = LockedRepository.model_validate(repo_dict)
188+
189+
# The resulting object should match the original
190+
assert repo2.url == repo.url
191+
assert repo2.path == repo.path
192+
assert repo2.name == repo.name
193+
assert repo2.vcs == repo.vcs
194+
assert repo2.rev == repo.rev
195+
assert repo2.locked_at == repo.locked_at
196+
197+
198+
class TestLockFileProperties:
199+
"""Property-based tests for the LockFile model."""
200+
201+
@given(lock_file=lock_file_strategy())
202+
def test_lock_file_roundtrip(self, lock_file: LockFile) -> None:
203+
"""Test lock file serialization and deserialization."""
204+
# Roundtrip test: convert to dict and back to model
205+
lock_dict = lock_file.model_dump()
206+
lock_file2 = LockFile.model_validate(lock_dict)
207+
208+
# The resulting object should match the original
209+
assert lock_file2.version == lock_file.version
210+
assert lock_file2.created_at == lock_file.created_at
211+
assert len(lock_file2.repositories) == len(lock_file.repositories)
212+
213+
@given(lock_file=lock_file_strategy())
214+
def test_lock_file_repository_paths(self, lock_file: LockFile) -> None:
215+
"""Test that locked repositories have valid paths."""
216+
for repo in lock_file.repositories:
217+
# All paths should be absolute after normalization
218+
assert Path(repo.path).is_absolute()
219+
220+
@given(lock_file=lock_file_strategy())
221+
def test_semver_version_format(self, lock_file: LockFile) -> None:
222+
"""Test that the version follows semver format."""
223+
# Version should be in the format x.y.z
224+
assert lock_file.version.count(".") == 2
225+
major, minor, patch = lock_file.version.split(".")
226+
assert major.isdigit()
227+
assert minor.isdigit()
228+
assert patch.isdigit()

0 commit comments

Comments
 (0)
Please sign in to comment.