Test coverage pt.1 (#4)
* test: config fetch coverage * ci: add test runner * test: add test stubs * chore: move twine and wheel to bootstrap * infra: add test inv * refactor: review for testability * test: use case testing
This commit is contained in:
parent
26058edc86
commit
c647f9dbd9
11 changed files with 270 additions and 14 deletions
3
.github/workflows/pythonpackage.yml
vendored
3
.github/workflows/pythonpackage.yml
vendored
|
@ -23,3 +23,6 @@ jobs:
|
||||||
- name: Formatter
|
- name: Formatter
|
||||||
run: |
|
run: |
|
||||||
black src *.py --check
|
black src *.py --check
|
||||||
|
- name: Test
|
||||||
|
run: |
|
||||||
|
pytest
|
||||||
|
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -4,8 +4,6 @@ __pycache__/
|
||||||
*$py.class
|
*$py.class
|
||||||
*.sw[a-z]
|
*.sw[a-z]
|
||||||
|
|
||||||
#Temporarily
|
|
||||||
tests/
|
|
||||||
# C extensions
|
# C extensions
|
||||||
*.so
|
*.so
|
||||||
|
|
||||||
|
|
|
@ -6,4 +6,5 @@ pyenv install -s
|
||||||
pyenv virtualenv $VENV_NAME
|
pyenv virtualenv $VENV_NAME
|
||||||
pyenv activate $VENV_NAME
|
pyenv activate $VENV_NAME
|
||||||
|
|
||||||
|
pip install wheel twine
|
||||||
pip install -r $REQ_FILE
|
pip install -r $REQ_FILE
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
RCFILE_PATH = ".carboncopyrc"
|
RCFILE_PATH = ".carboncopyrc"
|
||||||
|
|
||||||
FORCED_IGNORE_PATTERNS = r"^.git/*"
|
FORCED_IGNORE_PATTERNS = r".*\.git/.*"
|
||||||
FETCH_URL_PATTERN = r"Fetch URL"
|
FETCH_URL_PATTERN = r"Fetch URL"
|
||||||
GIT_LINK_PATTERN = r"(?<=git@github.com:)[A-Za-z0-9_\-\.]+/[A-Za-z0-9_\-\.]+"
|
GIT_LINK_PATTERN = r"(?<=git@github.com:)[A-Za-z0-9_\-\.]+/[A-Za-z0-9_\-\.]+"
|
||||||
GIT_EXT_PATTERN = r"\.git$"
|
GIT_EXT_PATTERN = r"\.git$"
|
||||||
|
|
|
@ -38,7 +38,7 @@ def get_template_transforms(path: Path) -> List[Transform]:
|
||||||
while stack:
|
while stack:
|
||||||
current_path = stack.pop()
|
current_path = stack.pop()
|
||||||
|
|
||||||
if not current_path.is_dir():
|
if not Path(current_path).is_dir():
|
||||||
file_paths.append(current_path)
|
file_paths.append(current_path)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
@ -47,7 +47,10 @@ def get_template_transforms(path: Path) -> List[Transform]:
|
||||||
stack.append(child_path)
|
stack.append(child_path)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
Transform(source=filename, destination=filename.relative_to(path))
|
Transform(
|
||||||
|
source=filename,
|
||||||
|
destination=path.parent.joinpath(filename.relative_to(path)),
|
||||||
|
)
|
||||||
for filename in file_paths
|
for filename in file_paths
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -69,9 +72,8 @@ def squash(transform: Transform) -> None:
|
||||||
except IsADirectoryError:
|
except IsADirectoryError:
|
||||||
pretty_print(
|
pretty_print(
|
||||||
"Failed to copy {source} -> {destination}".format(
|
"Failed to copy {source} -> {destination}".format(
|
||||||
source=source, destination=destination
|
source=source, destination=destination,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
pretty_print(e.__class__)
|
pretty_print(str(e.__class__))
|
||||||
pretty_print(e)
|
|
||||||
|
|
|
@ -46,19 +46,24 @@ def get_local_config(root_path: Path = Path(".")) -> Dict[str, Any]:
|
||||||
|
|
||||||
|
|
||||||
class UseCases:
|
class UseCases:
|
||||||
def __init__(self, config: Dict[str, Any]) -> None:
|
def __init__(
|
||||||
|
self, config: Dict[str, Any], non_interactive=False, root_path=Path(".")
|
||||||
|
) -> None:
|
||||||
self.config = config
|
self.config = config
|
||||||
self.template_repo: Dict[str, str] = {}
|
self.template_repo: Dict[str, str] = {}
|
||||||
self.org = None
|
self.org = None
|
||||||
self.repo = None
|
self.repo = None
|
||||||
|
self.non_interactive = non_interactive
|
||||||
|
self.root_path = root_path
|
||||||
|
|
||||||
def fetch_template_repository_details(self) -> None:
|
def fetch_template_repository_details(self) -> None:
|
||||||
org, repo = get_local_repository_meta()
|
org, repo = get_local_repository_meta()
|
||||||
template_repo_data = get_repo_metadata(org, repo)
|
|
||||||
|
|
||||||
if not (org and repo):
|
if not (org and repo):
|
||||||
raise NotInAGitRepositoryError()
|
raise NotInAGitRepositoryError()
|
||||||
|
|
||||||
|
template_repo_data = get_repo_metadata(org, repo)
|
||||||
|
|
||||||
if not template_repo_data:
|
if not template_repo_data:
|
||||||
raise NoTemplateError()
|
raise NoTemplateError()
|
||||||
|
|
||||||
|
@ -72,13 +77,16 @@ class UseCases:
|
||||||
)
|
)
|
||||||
|
|
||||||
def stage_changes(self) -> List[Transform]:
|
def stage_changes(self) -> List[Transform]:
|
||||||
path = Path(self.config["temp_directory"])
|
path = self.root_path.joinpath(Path(self.config["temp_directory"]))
|
||||||
available_transforms = get_template_transforms(path)
|
available_transforms = get_template_transforms(path)
|
||||||
|
|
||||||
def can_stage(path_str: str) -> bool:
|
def can_stage(path_str: str) -> bool:
|
||||||
return not re.match(FORCED_IGNORE_PATTERNS, path_str) and all(
|
is_forced_ignore = re.search(FORCED_IGNORE_PATTERNS, path_str)
|
||||||
[re.match(patt, path_str) for patt in self.config["ignore"]]
|
is_custom_ignore = any(
|
||||||
|
[re.search(patt, path_str) for patt in self.config["ignore"]]
|
||||||
)
|
)
|
||||||
|
is_dir = Path(path_str).is_dir()
|
||||||
|
return not is_dir and not is_custom_ignore and not is_forced_ignore
|
||||||
|
|
||||||
allowed_transforms = [
|
allowed_transforms = [
|
||||||
transform
|
transform
|
||||||
|
@ -86,12 +94,17 @@ class UseCases:
|
||||||
if can_stage(transform.get_destination(as_str=True))
|
if can_stage(transform.get_destination(as_str=True))
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if self.non_interactive:
|
||||||
|
return allowed_transforms
|
||||||
|
|
||||||
destinations = [
|
destinations = [
|
||||||
transform.get_destination(as_str=True) for transform in allowed_transforms
|
transform.get_destination(as_str=True) for transform in allowed_transforms
|
||||||
]
|
]
|
||||||
|
|
||||||
chosen_files = prompt_staging_files_confirmation(
|
chosen_files = prompt_staging_files_confirmation(
|
||||||
destinations, "{}/{}".format(self.org, self.repo)
|
destinations, "{}/{}".format(self.org, self.repo)
|
||||||
)
|
)
|
||||||
|
|
||||||
chosen_transforms = [
|
chosen_transforms = [
|
||||||
transform
|
transform
|
||||||
for transform in allowed_transforms
|
for transform in allowed_transforms
|
||||||
|
@ -106,4 +119,4 @@ class UseCases:
|
||||||
squash(path)
|
squash(path)
|
||||||
|
|
||||||
def clean_up(self) -> None:
|
def clean_up(self) -> None:
|
||||||
clean_temp_files(Path(self.config["temp_directory"]))
|
clean_temp_files(self.root_path.joinpath(Path(self.config["temp_directory"])))
|
||||||
|
|
8
tasks.py
8
tasks.py
|
@ -1,5 +1,7 @@
|
||||||
from invoke import Collection, task
|
from invoke import Collection, task
|
||||||
|
|
||||||
|
TMP_PATH = "/tmp/carboncopy_pytest/"
|
||||||
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def format_all(ctx):
|
def format_all(ctx):
|
||||||
|
@ -26,8 +28,14 @@ def publish(ctx, test=False):
|
||||||
ctx.run("twine upload dist/*")
|
ctx.run("twine upload dist/*")
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def test(ctx):
|
||||||
|
ctx.run("pytest")
|
||||||
|
|
||||||
|
|
||||||
ns = Collection()
|
ns = Collection()
|
||||||
ns.add_task(format_all, name="format")
|
ns.add_task(format_all, name="format")
|
||||||
ns.add_task(typecheck)
|
ns.add_task(typecheck)
|
||||||
ns.add_task(package)
|
ns.add_task(package)
|
||||||
ns.add_task(publish)
|
ns.add_task(publish)
|
||||||
|
ns.add_task(test)
|
||||||
|
|
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
54
tests/__snapshots__/test_use_cases.ambr
Normal file
54
tests/__snapshots__/test_use_cases.ambr
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
# name: test_fetch_template_repository_details_throws_NoTemplateError_if_no_template_repo
|
||||||
|
''
|
||||||
|
---
|
||||||
|
# name: test_fetch_template_repository_details_throws_NoTemplateError_if_no_template_repo.1
|
||||||
|
''
|
||||||
|
---
|
||||||
|
# name: test_fetch_template_repository_details_throws_NotInGitRepositoryError_if_not_in_repo
|
||||||
|
''
|
||||||
|
---
|
||||||
|
# name: test_fetch_template_repository_details_throws_NotInGitRepositoryError_if_not_in_repo.1
|
||||||
|
''
|
||||||
|
---
|
||||||
|
# name: test_get_local_config_merges_rcfile_with_default_config
|
||||||
|
''
|
||||||
|
---
|
||||||
|
# name: test_get_local_config_merges_rcfile_with_default_config.1
|
||||||
|
''
|
||||||
|
---
|
||||||
|
# name: test_get_local_config_returns_default_config_if_invalid_config_file_present
|
||||||
|
'
|
||||||
|
Invalid RC file!
|
||||||
|
|
||||||
|
'
|
||||||
|
---
|
||||||
|
# name: test_get_local_config_returns_default_config_if_invalid_config_file_present.1
|
||||||
|
''
|
||||||
|
---
|
||||||
|
# name: test_get_local_config_returns_default_config_if_no_config_file_present
|
||||||
|
'
|
||||||
|
No config file found in current directory! Proceeding with defaults.
|
||||||
|
|
||||||
|
'
|
||||||
|
---
|
||||||
|
# name: test_get_local_config_returns_default_config_if_no_config_file_present.1
|
||||||
|
''
|
||||||
|
---
|
||||||
|
# name: test_stage_changes_creates_transforms_for_all_valid_changes
|
||||||
|
''
|
||||||
|
---
|
||||||
|
# name: test_stage_changes_creates_transforms_for_all_valid_changes.1
|
||||||
|
''
|
||||||
|
---
|
||||||
|
# name: test_stage_changes_ignores_all_configured_ignore_patterns
|
||||||
|
''
|
||||||
|
---
|
||||||
|
# name: test_stage_changes_ignores_all_configured_ignore_patterns.1
|
||||||
|
''
|
||||||
|
---
|
||||||
|
# name: test_stage_changes_ignores_all_forced_ignore_patterns
|
||||||
|
''
|
||||||
|
---
|
||||||
|
# name: test_stage_changes_ignores_all_forced_ignore_patterns.1
|
||||||
|
''
|
||||||
|
---
|
170
tests/test_use_cases.py
Normal file
170
tests/test_use_cases.py
Normal file
|
@ -0,0 +1,170 @@
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.carboncopy.use_cases import get_local_config, UseCases
|
||||||
|
from src.carboncopy.constants import RCFILE_PATH
|
||||||
|
from src.carboncopy.config_defaults import CONFIG_DEFAULTS
|
||||||
|
from src.carboncopy.git_utils import NotInAGitRepositoryError, NoTemplateError
|
||||||
|
|
||||||
|
from .test_utils import assert_captured_output_matches_snapshot
|
||||||
|
|
||||||
|
def test_get_local_config_returns_default_config_if_no_config_file_present(tmp_path, snapshot, capsys):
|
||||||
|
assert len(list(tmp_path.iterdir())) == 0
|
||||||
|
|
||||||
|
fetched_config = get_local_config(tmp_path)
|
||||||
|
|
||||||
|
assert fetched_config == CONFIG_DEFAULTS
|
||||||
|
|
||||||
|
assert_captured_output_matches_snapshot(capsys, snapshot)
|
||||||
|
|
||||||
|
def test_get_local_config_returns_default_config_if_invalid_config_file_present(tmp_path, snapshot, capsys):
|
||||||
|
invalid_config_file = tmp_path / RCFILE_PATH
|
||||||
|
|
||||||
|
invalid_config_file.write_text('')
|
||||||
|
|
||||||
|
assert len(list(tmp_path.iterdir())) == 1
|
||||||
|
|
||||||
|
fetched_config = get_local_config(tmp_path)
|
||||||
|
|
||||||
|
assert fetched_config == CONFIG_DEFAULTS
|
||||||
|
|
||||||
|
assert_captured_output_matches_snapshot(capsys, snapshot)
|
||||||
|
|
||||||
|
def test_get_local_config_merges_rcfile_with_default_config(tmp_path, snapshot, capsys):
|
||||||
|
valid_config = { "ignore": ["some-file.md"] }
|
||||||
|
|
||||||
|
config_file = tmp_path / RCFILE_PATH
|
||||||
|
config_file.write_text(json.dumps(valid_config))
|
||||||
|
|
||||||
|
assert len(list(tmp_path.iterdir())) == 1
|
||||||
|
|
||||||
|
fetched_config = get_local_config(tmp_path)
|
||||||
|
|
||||||
|
expected_config = {**CONFIG_DEFAULTS, **valid_config}
|
||||||
|
|
||||||
|
assert fetched_config == expected_config
|
||||||
|
|
||||||
|
assert_captured_output_matches_snapshot(capsys, snapshot)
|
||||||
|
|
||||||
|
def test_fetch_template_repository_details_throws_NotInGitRepositoryError_if_not_in_repo(capsys, snapshot, monkeypatch):
|
||||||
|
# This simulates the repository meta not finding a repository.
|
||||||
|
def _mock():
|
||||||
|
return (None, None)
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.carboncopy.use_cases.get_local_repository_meta", _mock)
|
||||||
|
|
||||||
|
use_cases = UseCases(config=CONFIG_DEFAULTS)
|
||||||
|
|
||||||
|
with pytest.raises(NotInAGitRepositoryError):
|
||||||
|
use_cases.fetch_template_repository_details()
|
||||||
|
|
||||||
|
assert_captured_output_matches_snapshot(capsys, snapshot)
|
||||||
|
|
||||||
|
def test_fetch_template_repository_details_throws_NoTemplateError_if_no_template_repo(capsys, monkeypatch, snapshot):
|
||||||
|
# This simulates the repository data not containing a template repo reference
|
||||||
|
def _mock(a, b):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _mock_local_meta():
|
||||||
|
return 'org', 'repo'
|
||||||
|
|
||||||
|
monkeypatch.setattr('src.carboncopy.use_cases.get_local_repository_meta', _mock_local_meta)
|
||||||
|
monkeypatch.setattr("src.carboncopy.use_cases.get_repo_metadata", _mock)
|
||||||
|
|
||||||
|
use_cases = UseCases(config=CONFIG_DEFAULTS, non_interactive=True)
|
||||||
|
|
||||||
|
with pytest.raises(NoTemplateError):
|
||||||
|
use_cases.fetch_template_repository_details()
|
||||||
|
|
||||||
|
assert_captured_output_matches_snapshot(capsys, snapshot)
|
||||||
|
|
||||||
|
def test_stage_changes_ignores_all_forced_ignore_patterns(capsys, tmp_path, snapshot):
|
||||||
|
# Set up a mock template_directory
|
||||||
|
temp_dir = tmp_path / CONFIG_DEFAULTS["temp_directory"]
|
||||||
|
temp_dir.mkdir()
|
||||||
|
# .git is a notoriously ignored directory
|
||||||
|
forced_ignored_dir = temp_dir / ".git"
|
||||||
|
forced_ignored_dir.mkdir()
|
||||||
|
some_file = forced_ignored_dir / "some_file.txt"
|
||||||
|
some_file.write_text("smol file")
|
||||||
|
|
||||||
|
use_cases = UseCases(config=CONFIG_DEFAULTS, non_interactive=True, root_path=tmp_path)
|
||||||
|
|
||||||
|
staged = use_cases.stage_changes()
|
||||||
|
assert len(staged) == 0
|
||||||
|
|
||||||
|
assert_captured_output_matches_snapshot(capsys, snapshot)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stage_changes_ignores_all_configured_ignore_patterns(capsys, tmp_path, snapshot):
|
||||||
|
# Set up a mock template_directory
|
||||||
|
temp_dir = tmp_path / CONFIG_DEFAULTS["temp_directory"]
|
||||||
|
temp_dir.mkdir()
|
||||||
|
forced_ignored_dir = temp_dir / "ignore_folder"
|
||||||
|
forced_ignored_dir.mkdir()
|
||||||
|
some_file = forced_ignored_dir / "some_file.txt"
|
||||||
|
some_file.write_text("smol file")
|
||||||
|
|
||||||
|
config = { "ignore": ["ignore_folder"] }
|
||||||
|
merged_config = {**CONFIG_DEFAULTS, **config}
|
||||||
|
use_cases = UseCases(config=merged_config , non_interactive=True, root_path=tmp_path)
|
||||||
|
|
||||||
|
staged = use_cases.stage_changes()
|
||||||
|
assert len(staged) == 0
|
||||||
|
|
||||||
|
assert_captured_output_matches_snapshot(capsys, snapshot)
|
||||||
|
|
||||||
|
def test_stage_changes_creates_transforms_for_all_valid_changes(capsys, snapshot, tmp_path):
|
||||||
|
# Set up a mock template_directory
|
||||||
|
temp_dir = tmp_path / CONFIG_DEFAULTS["temp_directory"]
|
||||||
|
temp_dir.mkdir()
|
||||||
|
forced_ignored_dir = temp_dir / "coolio"
|
||||||
|
forced_ignored_dir.mkdir()
|
||||||
|
some_file = forced_ignored_dir / "some_file.txt"
|
||||||
|
some_file.write_text("smol file")
|
||||||
|
|
||||||
|
use_cases = UseCases(config=CONFIG_DEFAULTS , non_interactive=True, root_path=tmp_path)
|
||||||
|
|
||||||
|
staged = use_cases.stage_changes()
|
||||||
|
assert len(staged) == 1
|
||||||
|
|
||||||
|
assert_captured_output_matches_snapshot(capsys, snapshot)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_changes_squashes_all_files(tmp_path, capsys, snapshot):
|
||||||
|
temp_dir = tmp_path / CONFIG_DEFAULTS["temp_directory"]
|
||||||
|
temp_dir.mkdir()
|
||||||
|
cloned_repo = temp_dir / "attack_of_the_clone_repos"
|
||||||
|
cloned_repo.mkdir()
|
||||||
|
some_file = cloned_repo / "some_file.txt"
|
||||||
|
some_file.write_text("smol file")
|
||||||
|
|
||||||
|
use_cases = UseCases(config=CONFIG_DEFAULTS, non_interactive=True, root_path=tmp_path)
|
||||||
|
|
||||||
|
staged = use_cases.stage_changes()
|
||||||
|
use_cases.apply_changes(staged)
|
||||||
|
|
||||||
|
resulting_files = list(tmp_path.iterdir())
|
||||||
|
|
||||||
|
resulting_copy = tmp_path / "attack_of_the_clone_repos"
|
||||||
|
assert len(list(resulting_copy.iterdir())) == 1
|
||||||
|
|
||||||
|
copied_file = resulting_copy / "some_file.txt"
|
||||||
|
|
||||||
|
assert copied_file.read_text() == "smol file"
|
||||||
|
|
||||||
|
def test_clean_up_cleans_up_temporary_directory_files(tmp_path):
|
||||||
|
temp_dir = tmp_path / CONFIG_DEFAULTS["temp_directory"]
|
||||||
|
temp_dir.mkdir()
|
||||||
|
cloned_repo = temp_dir / "attack_of_the_clone_repos"
|
||||||
|
cloned_repo.mkdir()
|
||||||
|
some_file = cloned_repo / "some_file.txt"
|
||||||
|
some_file.write_text("smol file")
|
||||||
|
|
||||||
|
use_cases = UseCases(config=CONFIG_DEFAULTS, non_interactive=True, root_path=tmp_path)
|
||||||
|
|
||||||
|
use_cases.clean_up()
|
||||||
|
|
||||||
|
assert len(list(tmp_path.iterdir())) == 0
|
7
tests/test_utils.py
Normal file
7
tests/test_utils.py
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
def assert_captured_output_matches_snapshot(capsys, snapshot):
|
||||||
|
captured_out = capsys.readouterr()
|
||||||
|
|
||||||
|
assert captured_out.out == snapshot
|
||||||
|
assert captured_out.err == snapshot
|
Reference in a new issue