Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit 1af1250

Browse filesBrowse files
committed
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 9f223c8 commit 1af1250
Copy full SHA for 1af1250

File tree

Expand file treeCollapse file tree

2 files changed

+471
-0
lines changed
Filter options
Expand file treeCollapse file tree

2 files changed

+471
-0
lines changed
+228Lines changed: 228 additions & 0 deletions
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 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.