diff --git a/CHANGELOG.md b/CHANGELOG.md index 20d376f..986ddf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Modified - Add fixture for git user configuration - Skip "Publish to Test PyPi" workflow when there is no change on setup.py +- Added support to pick the only changed/modified and newly added tests ## [0.4.4] - 2020-03-21 ### Fixed diff --git a/README.rst b/README.rst index 2e03c7b..6ba1cc4 100644 --- a/README.rst +++ b/README.rst @@ -87,6 +87,7 @@ Usage $ pytest --picked --mode=unstaged # default + $ pytest --picked --mode=onlychanged Features -------- diff --git a/pytest_picked/modes.py b/pytest_picked/modes.py index 09a5322..06d2e28 100644 --- a/pytest_picked/modes.py +++ b/pytest_picked/modes.py @@ -9,7 +9,6 @@ def __init__(self, test_file_convention): def affected_tests(self): raw_output = self.git_output() - re_list = [ item.replace(".", r"\.").replace("*", ".*") for item in self.test_file_convention @@ -26,6 +25,34 @@ def affected_tests(self): files.append(file_or_folder) return files, folders + def only_tests(self): + raw_output = self.git_output() + re_list = [ + item.replace(".", r"\.").replace("*", ".*") + for item in self.test_file_convention + ] + test_file_regex = r"(\/|^)" + r"|".join(re_list) + test_class_regex = r"(?<=class\s).*Test.*(?=\()" + test_regex = r"(?<=def\s)test.*(?=\()" + + tests = [] + + last_file_name = None + for candidate in raw_output.splitlines(): + file_or_test = self.parser(candidate) + if file_or_test: + if re.search(test_file_regex, file_or_test): + last_file_name = file_or_test + if re.search(test_class_regex, file_or_test): + match = re.findall(test_class_regex, file_or_test) + class_name = "::" + match[0] + + elif re.search(test_regex, file_or_test): + match = re.findall(test_regex, file_or_test) + tests.append(last_file_name + class_name + "::" + match[0]) + class_name = "" + return tests, [] + def git_output(self): output = subprocess.run(self.command(), stdout=subprocess.PIPE) # nosec return output.stdout.decode("utf-8").expandtabs() @@ -127,3 +154,36 @@ def parser(self, candidate): indicator_index = candidate.find(rename_indicator) start_path_index = indicator_index + len(rename_indicator) return candidate[start_path_index:] + + +class OnlyChanged(Mode): + def command(self): + return ["git", "diff"] + + def parser(self, candidate): + """ + Discard the first 6 characters in case of file name. + + Parse the modified file using the file indicator '+++ b/' + Parse affected tests from using regex 'def' and '@@' + The command output would look like this: + diff --git a/tests/migrations/auto.py b/tests/migrations/auto.py + index 2ce07c4..ebd406e 100644 + --- a/tests/migrations/auto.py + +++ b/tests/migrations/auto.py + @@ -181,6 +181,7 @@ def test_positive_migration(session, ad_data): + + Reference: + https://git-scm.com/docs/git-diff#git-diff + """ + start_path_index = 6 + file_indicator = "+++ b/" + modified_test_indicator = "@@ " + new_test_indicator = "+" + + if candidate.startswith(file_indicator): + return candidate[start_path_index:] + if candidate.startswith(modified_test_indicator): + return candidate + if candidate.startswith(new_test_indicator): + return candidate diff --git a/pytest_picked/plugin.py b/pytest_picked/plugin.py index cf5a973..8a8d943 100644 --- a/pytest_picked/plugin.py +++ b/pytest_picked/plugin.py @@ -2,7 +2,7 @@ import _pytest -from .modes import Branch, Unstaged +from .modes import Branch, OnlyChanged, Unstaged def pytest_addoption(parser): @@ -25,7 +25,7 @@ def pytest_addoption(parser): dest="picked_mode", default="unstaged", required=False, - help="Options: unstaged, branch", + help="Options: unstaged, branch, onlychanged", ) @@ -36,6 +36,7 @@ def _get_affected_paths(config): modes = { "branch": Branch(test_file_convention), "unstaged": Unstaged(test_file_convention), + "onlychanged": OnlyChanged(test_file_convention), } try: mode = modes[picked_mode] @@ -45,7 +46,10 @@ def _get_affected_paths(config): config.args = [] raise ValueError(error) else: - return mode.affected_tests() + if picked_mode == "onlychanged": + return mode.only_tests() + else: + return mode.affected_tests() def pytest_configure(config): @@ -62,7 +66,6 @@ def pytest_collection_modifyitems(session, config, items): picked_type = config.getoption("picked") if not picked_type or picked_type != "first": return - affected_files, affected_folders = _get_affected_paths(config) match_paths = affected_files + affected_folders # only reorder if there was anything matched diff --git a/tests/test_modes.py b/tests/test_modes.py index 79a2ae0..73e5210 100644 --- a/tests/test_modes.py +++ b/tests/test_modes.py @@ -2,7 +2,9 @@ import pytest -from pytest_picked.modes import Branch, Unstaged +from pytest_picked.modes import Branch +from pytest_picked.modes import OnlyChanged +from pytest_picked.modes import Unstaged @pytest.fixture @@ -164,3 +166,64 @@ def test_should_only_list_pytest_root_changed_files(self, git_repository, testdi pytestroot.chdir() output = set(Branch([]).git_output().splitlines()) assert output == {"A test_pytestroot.py"} + + +class TestOnlyChanged: + def test_should_return_git_diff_command(self): + mode = OnlyChanged([]) + command = mode.command() + + assert isinstance(command, list) + assert mode.command() == ["git", "diff"] + + @pytest.mark.parametrize( + "line,expected_line", + [ + ( + "@@ -191,29 +191,8 @@ class TestPytestPicked:", + "@@ -191,29 +191,8 @@ class TestPytestPicked:", + ), + ("- def test_positive_update_limit(self):", None), + ("--- a/tests/foreman/cli/test_host_collection.py", None), + ( + "+++ b/tests/pytestpicked/test_positive", + "tests/pytestpicked/test_positive", + ), + ("+ @pytest.mark.tier2", "+ @pytest.mark.tier2"), + ("+ def test", "+ def test"), + ("-def test_positive", None), + ], + ) + def test_parser_should_ignore_no_paths_characters(self, line, expected_line): + mode = OnlyChanged([]) + parsed_line = mode.parser(line) + + assert parsed_line == expected_line + + def test_should_list_changed_tests(self): + raw_output = ( + b"+++ b/tests/pytestpicked/test_modes.py" + b"\n@@ -191,29 +191,8 @@ class TestPytestPicked(CLITestCase):" + b"\n+ def test_with_class(self):" + b"\n+++ b/tests/pytestpicked/test_modes.py" + b"\n+ def test_without_class(self):" + b"\n+++ b/tests/pytestpicked/test_modes.py" + b"\n+ def invalid_function(self):" + b"\n+++ b/tests/pytestpicked/test_modes.py" + b"\n+ def invalid_test(self):" + b"\n+++ b/tests/pytestpicked/test_modes.py" + b"\n@@ -191,29 +191,8 @@ class InvalidClass:" + ) + test_file_convention = ["test_*.py", "*_test.py"] + + with patch("pytest_picked.modes.subprocess.run") as subprocess_mock: + subprocess_mock.return_value.stdout = raw_output + mode = OnlyChanged(test_file_convention) + tests, folders = mode.only_tests() + + expected_tests = [ + "tests/pytestpicked/test_modes.py::TestPytestPicked::test_with_class", + "tests/pytestpicked/test_modes.py::test_without_class", + ] + + assert tests == expected_tests