diff --git a/tests/conftest.py b/tests/conftest.py index 0eec744..ddc3cbb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,12 @@ -"""Shared pytest fixtures and configuration.""" +"""Shared pytest fixtures and configuration. + +These fixtures use autouse=False and assign to ``self`` via the ``request`` +fixture so they work with unittest.TestCase classes. To use them, decorate +the test class or method with ``@pytest.mark.usefixtures("fixture_name")``. +""" import shutil +import subprocess import tempfile from pathlib import Path @@ -8,38 +14,47 @@ @pytest.fixture -def temp_dir(): - """Create a temporary directory that's cleaned up after test.""" - tmpdir = tempfile.mkdtemp() - yield Path(tmpdir) +def temp_dir(request): + """Create a temporary directory that's cleaned up after test. + + Assigns ``self.temp_dir`` on the test instance. + """ + tmpdir = Path(tempfile.mkdtemp()) + if request.instance is not None: + request.instance.temp_dir = tmpdir + yield tmpdir shutil.rmtree(tmpdir, ignore_errors=True) @pytest.fixture -def git_repo(tmp_path): - """Create a temporary git repository.""" - import subprocess +def git_repo(request, tmp_path): + """Create a temporary git repository with an initial commit. + Assigns ``self.git_repo`` on the test instance. + """ repo_path = tmp_path / "repo" repo_path.mkdir() - # Initialize git subprocess.run(["git", "init"], cwd=repo_path, capture_output=True, check=True) subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=repo_path, capture_output=True, check=True) subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo_path, capture_output=True, check=True) - # Create initial commit (repo_path / "README.md").write_text("# Test Repo") subprocess.run(["git", "add", "."], cwd=repo_path, capture_output=True, check=True) subprocess.run(["git", "commit", "-m", "Initial commit"], cwd=repo_path, capture_output=True, check=True) + if request.instance is not None: + request.instance.git_repo = repo_path return repo_path @pytest.fixture -def sample_recipe_config(): - """Sample recipe configuration.""" - return { +def sample_recipe_config(request): + """Sample recipe configuration dict. + + Assigns ``self.sample_recipe_config`` on the test instance. + """ + config = { "metadata": { "recipe_name": "test_recipe", "recipe_version": "v1.0.0", @@ -48,26 +63,24 @@ def sample_recipe_config(): "generated_at": "2026-02-28T00:00:00Z", } } + if request.instance is not None: + request.instance.sample_recipe_config = config + return config @pytest.fixture -def mock_recipe_files(tmp_path): - """Create mock recipe files.""" +def mock_recipe_files(request, tmp_path): + """Create mock recipe template files in a temporary directory. + + Assigns ``self.mock_recipe_files`` on the test instance. + """ recipe_dir = tmp_path / "recipe" recipe_dir.mkdir() - # Create sample files (recipe_dir / "template.txt").write_text("Hello {{name}}") (recipe_dir / "README.md").write_text("# {{name}}\n\n{{description}}") (recipe_dir / "config.json").write_text('{"version": "1.0.0"}') + if request.instance is not None: + request.instance.mock_recipe_files = recipe_dir return recipe_dir - - -# Pytest configuration -def pytest_configure(config): - """Configure pytest with custom markers.""" - config.addinivalue_line("markers", "integration: mark test as integration test") - config.addinivalue_line("markers", "slow: mark test as slow running") - config.addinivalue_line("markers", "requires_git: mark test as requiring git") - config.addinivalue_line("markers", "requires_network: mark test as requiring network access") diff --git a/tests/functional/test_cli_e2e_integration.py b/tests/functional/test_cli_e2e_integration.py index 91496c4..28f7b03 100644 --- a/tests/functional/test_cli_e2e_integration.py +++ b/tests/functional/test_cli_e2e_integration.py @@ -1,169 +1,190 @@ """End-to-end CLI tests using actual command execution.""" import json +import unittest from pathlib import Path +from tempfile import TemporaryDirectory -import pytest from typer.testing import CliRunner from nskit.cli import create_cli from nskit.client.backends import LocalBackend -@pytest.fixture -def cli_runner(): - """CLI test runner.""" - return CliRunner() - - -@pytest.fixture -def test_backend(tmp_path): - """Create test backend with recipes.""" - recipes_dir = tmp_path / "recipes" - recipes_dir.mkdir() - - # Create test recipe v1.0.0 - v1 = recipes_dir / "test_recipe" / "v1.0.0" - v1.mkdir(parents=True) - (v1 / "README.md").write_text("# {{name}}\n\nTest recipe") - recipe_config_dir = v1 / ".recipe" - recipe_config_dir.mkdir(parents=True) - (recipe_config_dir / "config.yml").write_text( - "metadata:\n recipe_name: test_recipe\n docker_image: test/test_recipe:v1.0.0\n" - ) - - return LocalBackend(recipes_dir=recipes_dir) - - -class TestCLIEndToEnd: +class TestCLIEndToEnd(unittest.TestCase): """End-to-end CLI command tests.""" - def test_init_command_with_yaml(self, cli_runner, tmp_path): - """Test init command with YAML input file.""" - app = create_cli(recipe_entrypoint="nskit.recipes") - - # Create input YAML - input_yaml = tmp_path / "input.yaml" - input_yaml.write_text("name: test_project\nauthor: Test Author\n") - - output_dir = tmp_path / "output" - - result = cli_runner.invoke( - app, - [ - "init", - "--recipe", - "python_package", - "--input-yaml-path", - str(input_yaml), - "--output-base-path", - str(output_dir), - ], + def setUp(self): + """Set up CLI runner.""" + self.cli_runner = CliRunner() + + def _create_test_backend(self, tmp_path): + """Create test backend with recipes.""" + recipes_dir = tmp_path / "recipes" + recipes_dir.mkdir() + + # Create test recipe v1.0.0 + v1 = recipes_dir / "test_recipe" / "v1.0.0" + v1.mkdir(parents=True) + (v1 / "README.md").write_text("# {{name}}\n\nTest recipe") + recipe_config_dir = v1 / ".recipe" + recipe_config_dir.mkdir(parents=True) + (recipe_config_dir / "config.yml").write_text( + "metadata:\n recipe_name: test_recipe\n docker_image: test/test_recipe:v1.0.0\n" ) - # Should fail gracefully if recipe not found - assert result.exit_code in [0, 1] + return LocalBackend(recipes_dir=recipes_dir) - def test_init_command_without_yaml(self, cli_runner, tmp_path): + def test_init_command_with_yaml(self): + """Test init command with YAML input file.""" + with TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + app = create_cli(recipe_entrypoint="nskit.recipes") + + # Create input YAML + input_yaml = tmp_path / "input.yaml" + input_yaml.write_text("name: test_project\nauthor: Test Author\n") + + output_dir = tmp_path / "output" + + result = self.cli_runner.invoke( + app, + [ + "init", + "--recipe", + "python_package", + "--input-yaml-path", + str(input_yaml), + "--output-base-path", + str(output_dir), + ], + ) + + # Should fail gracefully if recipe not found + self.assertIn(result.exit_code, [0, 1]) + + def test_init_command_without_yaml(self): """Test init command without YAML input.""" - app = create_cli(recipe_entrypoint="nskit.recipes") + with TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + app = create_cli(recipe_entrypoint="nskit.recipes") - output_dir = tmp_path / "output" + output_dir = tmp_path / "output" - result = cli_runner.invoke(app, ["init", "--recipe", "python_package", "--output-base-path", str(output_dir)]) + result = self.cli_runner.invoke( + app, ["init", "--recipe", "python_package", "--output-base-path", str(output_dir)] + ) - assert result.exit_code in [0, 1] + self.assertIn(result.exit_code, [0, 1]) - def test_get_required_fields_command(self, cli_runner): + def test_get_required_fields_command(self): """Test get-required-fields returns valid JSON.""" app = create_cli(recipe_entrypoint="nskit.recipes") - result = cli_runner.invoke(app, ["get-required-fields", "--recipe", "python_package"]) + result = self.cli_runner.invoke(app, ["get-required-fields", "--recipe", "python_package"]) if result.exit_code == 0: # Should be valid JSON data = json.loads(result.stdout) - assert isinstance(data, dict) + self.assertIsInstance(data, dict) - def test_list_command_with_backend(self, cli_runner, test_backend): + def test_list_command_with_backend(self): """Test list command with backend.""" - app = create_cli(recipe_entrypoint="nskit.recipes", backend=test_backend) + with TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + test_backend = self._create_test_backend(tmp_path) + app = create_cli(recipe_entrypoint="nskit.recipes", backend=test_backend) - result = cli_runner.invoke(app, ["list"]) + result = self.cli_runner.invoke(app, ["list"]) - assert result.exit_code == 0 - assert "test_recipe" in result.stdout or "No recipes" in result.stdout + self.assertEqual(result.exit_code, 0) + self.assertTrue("test_recipe" in result.stdout or "No recipes" in result.stdout) - def test_discover_command_with_search(self, cli_runner, test_backend): + def test_discover_command_with_search(self): """Test discover command with search term.""" - app = create_cli(recipe_entrypoint="nskit.recipes", backend=test_backend) + with TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + test_backend = self._create_test_backend(tmp_path) + app = create_cli(recipe_entrypoint="nskit.recipes", backend=test_backend) - result = cli_runner.invoke(app, ["discover", "--search", "test"]) + result = self.cli_runner.invoke(app, ["discover", "--search", "test"]) - assert result.exit_code == 0 + self.assertEqual(result.exit_code, 0) - def test_cli_help_command(self, cli_runner): + def test_cli_help_command(self): """Test CLI help output.""" app = create_cli(recipe_entrypoint="nskit.recipes") - result = cli_runner.invoke(app, ["--help"]) + result = self.cli_runner.invoke(app, ["--help"]) - assert result.exit_code == 0 - assert "init" in result.stdout - assert "get-required-fields" in result.stdout + self.assertEqual(result.exit_code, 0) + self.assertIn("init", result.stdout) + self.assertIn("get-required-fields", result.stdout) - def test_init_command_with_override_path(self, cli_runner, tmp_path): + def test_init_command_with_override_path(self): """Test init with output override path.""" - app = create_cli(recipe_entrypoint="nskit.recipes") + with TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + app = create_cli(recipe_entrypoint="nskit.recipes") - override_path = tmp_path / "custom_name" + override_path = tmp_path / "custom_name" - result = cli_runner.invoke( - app, ["init", "--recipe", "python_package", "--output-override-path", str(override_path)] - ) + result = self.cli_runner.invoke( + app, ["init", "--recipe", "python_package", "--output-override-path", str(override_path)] + ) - assert result.exit_code in [0, 1] + self.assertIn(result.exit_code, [0, 1]) - def test_init_command_local_flag(self, cli_runner, test_backend, tmp_path): + def test_init_command_local_flag(self): """Test init with --local flag.""" - app = create_cli(recipe_entrypoint="nskit.recipes", backend=test_backend) + with TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + test_backend = self._create_test_backend(tmp_path) + app = create_cli(recipe_entrypoint="nskit.recipes", backend=test_backend) - result = cli_runner.invoke( - app, ["init", "--recipe", "test_recipe", "--output-base-path", str(tmp_path), "--local"] - ) + result = self.cli_runner.invoke( + app, ["init", "--recipe", "test_recipe", "--output-base-path", str(tmp_path), "--local"] + ) - # Should attempt local execution - assert result.exit_code in [0, 1] + # Should attempt local execution + self.assertIn(result.exit_code, [0, 1]) - def test_cli_invalid_recipe(self, cli_runner): + def test_cli_invalid_recipe(self): """Test CLI with invalid recipe name.""" app = create_cli(recipe_entrypoint="nskit.recipes") - result = cli_runner.invoke(app, ["init", "--recipe", "nonexistent_recipe_xyz"]) + result = self.cli_runner.invoke(app, ["init", "--recipe", "nonexistent_recipe_xyz"]) - assert result.exit_code == 1 + self.assertEqual(result.exit_code, 1) - def test_cli_missing_required_option(self, cli_runner): + def test_cli_missing_required_option(self): """Test CLI with missing required option.""" app = create_cli(recipe_entrypoint="nskit.recipes") - result = cli_runner.invoke(app, ["init"]) + result = self.cli_runner.invoke(app, ["init"]) - assert result.exit_code == 2 # Typer returns 2 for missing options + self.assertEqual(result.exit_code, 2) # Typer returns 2 for missing options - def test_check_command_with_backend(self, cli_runner, test_backend, tmp_path): + def test_check_command_with_backend(self): """Test check command.""" - app = create_cli(recipe_entrypoint="nskit.recipes", backend=test_backend) - - # Create fake project - project_dir = tmp_path / "project" - project_dir.mkdir() - recipe_dir = project_dir / ".recipe" - recipe_dir.mkdir() - (recipe_dir / "config.yml").write_text( - "metadata:\n recipe_name: test_recipe\n docker_image: test/test_recipe:v1.0.0\n" - ) + with TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + test_backend = self._create_test_backend(tmp_path) + app = create_cli(recipe_entrypoint="nskit.recipes", backend=test_backend) + + # Create fake project + project_dir = tmp_path / "project" + project_dir.mkdir() + recipe_dir = project_dir / ".recipe" + recipe_dir.mkdir() + (recipe_dir / "config.yml").write_text( + "metadata:\n recipe_name: test_recipe\n docker_image: test/test_recipe:v1.0.0\n" + ) + + result = self.cli_runner.invoke(app, ["check", "--project-path", str(project_dir)]) + + self.assertEqual(result.exit_code, 0) - result = cli_runner.invoke(app, ["check", "--project-path", str(project_dir)]) - assert result.exit_code == 0 +if __name__ == "__main__": + unittest.main() diff --git a/tests/functional/test_git_utils.py b/tests/functional/test_git_utils.py index ed3b42d..c32988b 100644 --- a/tests/functional/test_git_utils.py +++ b/tests/functional/test_git_utils.py @@ -1,72 +1,41 @@ """Tests for GitUtils uncovered functions.""" -from pathlib import Path +import unittest import pytest from nskit.client.utils.git import GitUtils -class TestGitUtilsAdditional: +@pytest.mark.usefixtures("git_repo") +class TestGitUtilsAdditional(unittest.TestCase): """Test uncovered GitUtils functions.""" - def test_get_current_commit(self, tmp_path): + def test_get_current_commit(self): """Test getting current commit hash.""" - import subprocess - - # Initialize git repo - subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True) - subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=tmp_path, capture_output=True) - subprocess.run(["git", "config", "user.name", "Test"], cwd=tmp_path, capture_output=True) - - # Create and commit a file - (tmp_path / "test.txt").write_text("test") - subprocess.run(["git", "add", "."], cwd=tmp_path, capture_output=True) - subprocess.run(["git", "commit", "-m", "Initial"], cwd=tmp_path, capture_output=True) - - git_utils = GitUtils(tmp_path) + git_utils = GitUtils(self.git_repo) commit = git_utils.get_current_commit() - assert commit is not None - assert len(commit) == 40 # SHA-1 hash + self.assertIsNotNone(commit) + self.assertEqual(len(commit), 40) # SHA-1 hash - def test_has_uncommitted_changes_clean(self, tmp_path): + def test_has_uncommitted_changes_clean(self): """Test checking for uncommitted changes in clean repo.""" - import subprocess - - # Initialize git repo - subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True) - subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=tmp_path, capture_output=True) - subprocess.run(["git", "config", "user.name", "Test"], cwd=tmp_path, capture_output=True) - - # Create and commit a file - (tmp_path / "test.txt").write_text("test") - subprocess.run(["git", "add", "."], cwd=tmp_path, capture_output=True) - subprocess.run(["git", "commit", "-m", "Initial"], cwd=tmp_path, capture_output=True) - - git_utils = GitUtils(tmp_path) + git_utils = GitUtils(self.git_repo) has_changes = git_utils.has_uncommitted_changes() - assert not has_changes + self.assertFalse(has_changes) - def test_has_uncommitted_changes_dirty(self, tmp_path): + def test_has_uncommitted_changes_dirty(self): """Test checking for uncommitted changes in dirty repo.""" - import subprocess - - # Initialize git repo - subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True) - subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=tmp_path, capture_output=True) - subprocess.run(["git", "config", "user.name", "Test"], cwd=tmp_path, capture_output=True) + # Modify existing file + (self.git_repo / "README.md").write_text("modified") - # Create and commit a file - (tmp_path / "test.txt").write_text("test") - subprocess.run(["git", "add", "."], cwd=tmp_path, capture_output=True) - subprocess.run(["git", "commit", "-m", "Initial"], cwd=tmp_path, capture_output=True) + git_utils = GitUtils(self.git_repo) + has_changes = git_utils.has_uncommitted_changes() - # Modify file - (tmp_path / "test.txt").write_text("modified") + self.assertTrue(has_changes) - git_utils = GitUtils(tmp_path) - has_changes = git_utils.has_uncommitted_changes() - assert has_changes +if __name__ == "__main__": + unittest.main() diff --git a/tests/functional/test_git_utils_client.py b/tests/functional/test_git_utils_client.py index 93b42b7..33b2084 100644 --- a/tests/functional/test_git_utils_client.py +++ b/tests/functional/test_git_utils_client.py @@ -8,6 +8,8 @@ from tempfile import TemporaryDirectory from unittest.mock import patch +import pytest + from nskit.client.utils.git import GitUtils @@ -37,7 +39,6 @@ def test_conflict_merge(self) -> None: merged, has_conflicts = utils.merge_file(base, user, template) self.assertTrue(has_conflicts) - # Conflict markers should be present self.assertIn("<<<<<<<", merged) self.assertIn(">>>>>>>", merged) @@ -73,62 +74,21 @@ def test_custom_labels(self) -> None: self.assertIn("MY_TEMPLATE", merged) +@pytest.mark.usefixtures("git_repo") class TestGitUtilsHasUncommittedChanges(unittest.TestCase): """Tests for GitUtils.has_uncommitted_changes.""" def test_clean_repo(self) -> None: """A freshly committed repo has no uncommitted changes.""" - with TemporaryDirectory() as tmp: - tmp_path = Path(tmp) - subprocess.run(["git", "init"], cwd=tmp, capture_output=True) - subprocess.run( - ["git", "config", "user.email", "test@test.com"], - cwd=tmp, - capture_output=True, - ) - subprocess.run( - ["git", "config", "user.name", "Test"], - cwd=tmp, - capture_output=True, - ) - (tmp_path / "file.txt").write_text("content") - subprocess.run(["git", "add", "."], cwd=tmp, capture_output=True) - subprocess.run( - ["git", "commit", "-m", "init"], - cwd=tmp, - capture_output=True, - ) - - utils = GitUtils(tmp_path) - self.assertFalse(utils.has_uncommitted_changes()) + utils = GitUtils(self.git_repo) + self.assertFalse(utils.has_uncommitted_changes()) def test_dirty_repo(self) -> None: """Uncommitted changes are detected.""" - with TemporaryDirectory() as tmp: - tmp_path = Path(tmp) - subprocess.run(["git", "init"], cwd=tmp, capture_output=True) - subprocess.run( - ["git", "config", "user.email", "test@test.com"], - cwd=tmp, - capture_output=True, - ) - subprocess.run( - ["git", "config", "user.name", "Test"], - cwd=tmp, - capture_output=True, - ) - (tmp_path / "file.txt").write_text("content") - subprocess.run(["git", "add", "."], cwd=tmp, capture_output=True) - subprocess.run( - ["git", "commit", "-m", "init"], - cwd=tmp, - capture_output=True, - ) - # Create uncommitted change - (tmp_path / "file.txt").write_text("changed") - - utils = GitUtils(tmp_path) - self.assertTrue(utils.has_uncommitted_changes()) + (self.git_repo / "README.md").write_text("changed") + + utils = GitUtils(self.git_repo) + self.assertTrue(utils.has_uncommitted_changes()) def test_non_git_directory(self) -> None: """Non-git directory returns False.""" @@ -137,15 +97,14 @@ def test_non_git_directory(self) -> None: self.assertFalse(utils.has_uncommitted_changes()) +@pytest.mark.usefixtures("git_repo") class TestGitUtilsIsGitRepository(unittest.TestCase): """Tests for GitUtils.is_git_repository.""" def test_git_repo(self) -> None: """Returns True for a git repository.""" - with TemporaryDirectory() as tmp: - subprocess.run(["git", "init"], cwd=tmp, capture_output=True) - utils = GitUtils(Path(tmp)) - self.assertTrue(utils.is_git_repository()) + utils = GitUtils(self.git_repo) + self.assertTrue(utils.is_git_repository()) def test_non_git_directory(self) -> None: """Returns False for a non-git directory.""" @@ -154,35 +113,32 @@ def test_non_git_directory(self) -> None: self.assertFalse(utils.is_git_repository()) +@pytest.mark.usefixtures("temp_dir") class TestGitUtilsDiffFiles(unittest.TestCase): """Tests for GitUtils.diff_files.""" def test_diff_identical_files(self) -> None: """Identical files produce empty diff.""" - with TemporaryDirectory() as tmp: - tmp_path = Path(tmp) - f1 = tmp_path / "a.txt" - f2 = tmp_path / "b.txt" - f1.write_text("same content") - f2.write_text("same content") + f1 = self.temp_dir / "a.txt" + f2 = self.temp_dir / "b.txt" + f1.write_text("same content") + f2.write_text("same content") - utils = GitUtils() - diff = utils.diff_files(f1, f2) - self.assertEqual(diff.strip(), "") + utils = GitUtils() + diff = utils.diff_files(f1, f2) + self.assertEqual(diff.strip(), "") def test_diff_different_files(self) -> None: """Different files produce non-empty diff.""" - with TemporaryDirectory() as tmp: - tmp_path = Path(tmp) - f1 = tmp_path / "a.txt" - f2 = tmp_path / "b.txt" - f1.write_text("old content") - f2.write_text("new content") - - utils = GitUtils() - diff = utils.diff_files(f1, f2) - self.assertIn("old content", diff) - self.assertIn("new content", diff) + f1 = self.temp_dir / "a.txt" + f2 = self.temp_dir / "b.txt" + f1.write_text("old content") + f2.write_text("new content") + + utils = GitUtils() + diff = utils.diff_files(f1, f2) + self.assertIn("old content", diff) + self.assertIn("new content", diff) if __name__ == "__main__": diff --git a/tests/functional/test_integration.py b/tests/functional/test_integration.py index 2b6537a..a43b9f2 100644 --- a/tests/functional/test_integration.py +++ b/tests/functional/test_integration.py @@ -1,214 +1,225 @@ """Integration tests for the complete recipe workflow.""" import subprocess +import unittest from pathlib import Path - -import pytest +from tempfile import TemporaryDirectory +from unittest.mock import Mock, patch from nskit.client.backends import LocalBackend from nskit.mixer.components import Recipe from nskit.recipes import DiscoveryClient, RecipeClient, UpdateClient -@pytest.fixture -def recipes_dir(tmp_path): - """Create a temporary recipes directory with test recipes.""" - recipes = tmp_path / "recipes" - recipes.mkdir() - - # Create python_package recipe v1.0.0 - recipe_v1 = recipes / "python_package" / "v1.0.0" - recipe_v1.mkdir(parents=True) +class TestIntegrationWorkflow(unittest.TestCase): + """Integration tests for complete workflow.""" - (recipe_v1 / "template.txt").write_text("Hello {{name}} v1.0.0") - (recipe_v1 / "README.md").write_text("# {{name}}\n\nVersion 1.0.0") + def setUp(self): + """Create a temporary recipes directory with test recipes.""" + self._tmp_dir = TemporaryDirectory() + tmp_path = Path(self._tmp_dir.name) - # Create python_package recipe v1.1.0 - recipe_v11 = recipes / "python_package" / "v1.1.0" - recipe_v11.mkdir(parents=True) + self.recipes_dir = tmp_path / "recipes" + self.recipes_dir.mkdir() - (recipe_v11 / "template.txt").write_text("Hello {{name}} v1.1.0") - (recipe_v11 / "README.md").write_text("# {{name}}\n\nVersion 1.1.0\n\nNew features!") - (recipe_v11 / "CHANGELOG.md").write_text("## v1.1.0\n- New features") + # Create python_package recipe v1.0.0 + recipe_v1 = self.recipes_dir / "python_package" / "v1.0.0" + recipe_v1.mkdir(parents=True) - # Create typescript_app recipe v2.0.0 - recipe_ts = recipes / "typescript_app" / "v2.0.0" - recipe_ts.mkdir(parents=True) + (recipe_v1 / "template.txt").write_text("Hello {{name}} v1.0.0") + (recipe_v1 / "README.md").write_text("# {{name}}\n\nVersion 1.0.0") - (recipe_ts / "package.json").write_text('{"name": "{{name}}"}') + # Create python_package recipe v1.1.0 + recipe_v11 = self.recipes_dir / "python_package" / "v1.1.0" + recipe_v11.mkdir(parents=True) - return recipes + (recipe_v11 / "template.txt").write_text("Hello {{name}} v1.1.0") + (recipe_v11 / "README.md").write_text("# {{name}}\n\nVersion 1.1.0\n\nNew features!") + (recipe_v11 / "CHANGELOG.md").write_text("## v1.1.0\n- New features") + # Create typescript_app recipe v2.0.0 + recipe_ts = self.recipes_dir / "typescript_app" / "v2.0.0" + recipe_ts.mkdir(parents=True) -@pytest.fixture -def backend(recipes_dir): - """Create LocalBackend with test recipes.""" - return LocalBackend(recipes_dir=recipes_dir) + (recipe_ts / "package.json").write_text('{"name": "{{name}}"}') + self.backend = LocalBackend(recipes_dir=self.recipes_dir) -class TestIntegrationWorkflow: - """Integration tests for complete workflow.""" + def tearDown(self): + """Clean up temporary directory.""" + self._tmp_dir.cleanup() - def test_discover_list_recipes(self, backend): + def test_discover_list_recipes(self): """Test discovering and listing recipes.""" - discovery = DiscoveryClient(backend) + discovery = DiscoveryClient(self.backend) recipes = discovery.discover_recipes() - assert len(recipes) == 2 + self.assertEqual(len(recipes), 2) recipe_names = [r.name for r in recipes] - assert "python_package" in recipe_names - assert "typescript_app" in recipe_names + self.assertIn("python_package", recipe_names) + self.assertIn("typescript_app", recipe_names) - def test_discover_search_recipes(self, backend): + def test_discover_search_recipes(self): """Test searching recipes.""" - discovery = DiscoveryClient(backend) + discovery = DiscoveryClient(self.backend) recipes = discovery.discover_recipes(search_term="python") - assert len(recipes) == 1 - assert recipes[0].name == "python_package" + self.assertEqual(len(recipes), 1) + self.assertEqual(recipes[0].name, "python_package") - def test_get_recipe_versions(self, backend): + def test_get_recipe_versions(self): """Test getting recipe versions.""" - client = RecipeClient(backend) + client = RecipeClient(self.backend) versions = client.get_recipe_versions("python_package") - assert len(versions) == 2 - assert "v1.0.0" in versions - assert "v1.1.0" in versions + self.assertEqual(len(versions), 2) + self.assertIn("v1.0.0", versions) + self.assertIn("v1.1.0", versions) - def test_full_workflow_init_and_update(self, backend, tmp_path): + def test_full_workflow_init_and_update(self): """Test complete workflow: discover, init, update.""" - from unittest.mock import Mock, patch - - # 1. Discover recipes - discovery = DiscoveryClient(backend) - recipes = discovery.discover_recipes() - assert len(recipes) > 0 - - # 2. Initialize project with v1.0.0 - project_path = tmp_path / "my_project" - project_path.mkdir() - - # Initialize git repo - subprocess.run(["git", "init"], cwd=project_path, capture_output=True) - subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=project_path, capture_output=True) - subprocess.run(["git", "config", "user.name", "Test"], cwd=project_path, capture_output=True) - - # Copy recipe files manually (simulating init) - recipe_v1 = backend.recipes_dir / "python_package" / "v1.0.0" - for file in recipe_v1.rglob("*"): - if file.is_file(): - rel_path = file.relative_to(recipe_v1) - dest = project_path / rel_path - dest.parent.mkdir(parents=True, exist_ok=True) - dest.write_text(file.read_text().replace("{{name}}", "my_project")) - - # Create recipe config - config_dir = project_path / ".recipe" - config_dir.mkdir() - config_content = """metadata: + with TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + + # 1. Discover recipes + discovery = DiscoveryClient(self.backend) + recipes = discovery.discover_recipes() + self.assertGreater(len(recipes), 0) + + # 2. Initialize project with v1.0.0 + project_path = tmp_path / "my_project" + project_path.mkdir() + + # Initialize git repo + subprocess.run(["git", "init"], cwd=project_path, capture_output=True) + subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=project_path, capture_output=True) + subprocess.run(["git", "config", "user.name", "Test"], cwd=project_path, capture_output=True) + + # Copy recipe files manually (simulating init) + recipe_v1 = self.backend.recipes_dir / "python_package" / "v1.0.0" + for file in recipe_v1.rglob("*"): + if file.is_file(): + rel_path = file.relative_to(recipe_v1) + dest = project_path / rel_path + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(file.read_text().replace("{{name}}", "my_project")) + + # Create recipe config + config_dir = project_path / ".recipe" + config_dir.mkdir() + config_content = """metadata: recipe_name: v1.0.0 docker_image: test/python_package:v1.0.0 """ - (config_dir / "config.yml").write_text(config_content) + (config_dir / "config.yml").write_text(config_content) - # Commit initial state - subprocess.run(["git", "add", "."], cwd=project_path, capture_output=True) - subprocess.run(["git", "commit", "-m", "Initial"], cwd=project_path, capture_output=True) + # Commit initial state + subprocess.run(["git", "add", "."], cwd=project_path, capture_output=True) + subprocess.run(["git", "commit", "-m", "Initial"], cwd=project_path, capture_output=True) - # Verify initial files - assert (project_path / "template.txt").exists() - assert "v1.0.0" in (project_path / "template.txt").read_text() + # Verify initial files + self.assertTrue((project_path / "template.txt").exists()) + self.assertIn("v1.0.0", (project_path / "template.txt").read_text()) - # 3. Check for updates (mock the config loading) - with patch("nskit.mixer.components.RecipeConfig") as mock_config_class: - mock_config = Mock() - mock_config.metadata = Mock() - mock_config.metadata.recipe_name = "v1.0.0" - mock_config_class.load_from_file.return_value = mock_config + # 3. Check for updates (mock the config loading) + with patch("nskit.mixer.components.RecipeConfig") as mock_config_class: + mock_config = Mock() + mock_config.metadata = Mock() + mock_config.metadata.recipe_name = "v1.0.0" + mock_config_class.load_from_file.return_value = mock_config - # Mock backend to return versions - backend.get_recipe_versions = Mock(return_value=["v1.0.0", "v1.1.0"]) + # Mock backend to return versions + self.backend.get_recipe_versions = Mock(return_value=["v1.0.0", "v1.1.0"]) - update_client = UpdateClient(backend) - latest = update_client.check_update_available(project_path) - assert latest == "v1.1.0" + update_client = UpdateClient(self.backend) + latest = update_client.check_update_available(project_path) + self.assertEqual(latest, "v1.1.0") - def test_update_preserves_user_changes(self, backend, tmp_path): + def test_update_preserves_user_changes(self): """Test that updates preserve user modifications.""" - project_path = tmp_path / "my_project" - project_path.mkdir() - - # Initialize git - subprocess.run(["git", "init"], cwd=project_path, capture_output=True) - subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=project_path, capture_output=True) - subprocess.run(["git", "config", "user.name", "Test"], cwd=project_path, capture_output=True) - - # Create initial file - test_file = project_path / "README.md" - test_file.write_text("# my_project\n\nVersion 1.0.0\n\nUser custom content") - - # Create recipe config - config_dir = project_path / ".recipe" - config_dir.mkdir() - (config_dir / "config.yml").write_text( - """ + with TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + + project_path = tmp_path / "my_project" + project_path.mkdir() + + # Initialize git + subprocess.run(["git", "init"], cwd=project_path, capture_output=True) + subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=project_path, capture_output=True) + subprocess.run(["git", "config", "user.name", "Test"], cwd=project_path, capture_output=True) + + # Create initial file + test_file = project_path / "README.md" + test_file.write_text("# my_project\n\nVersion 1.0.0\n\nUser custom content") + + # Create recipe config + config_dir = project_path / ".recipe" + config_dir.mkdir() + (config_dir / "config.yml").write_text( + """ metadata: recipe_name: python_package docker_image: test/python_package:v1.0.0 """ - ) + ) - # Commit - subprocess.run(["git", "add", "."], cwd=project_path, capture_output=True) - subprocess.run(["git", "commit", "-m", "Initial"], cwd=project_path, capture_output=True) + # Commit + subprocess.run(["git", "add", "."], cwd=project_path, capture_output=True) + subprocess.run(["git", "commit", "-m", "Initial"], cwd=project_path, capture_output=True) - # Update - update_client = UpdateClient(backend) - update_client.update_project( - project_path=project_path, - target_version="v1.1.0", - ) + # Update + update_client = UpdateClient(self.backend) + update_client.update_project( + project_path=project_path, + target_version="v1.1.0", + ) - # User content should be preserved (or flagged as conflict) - content = test_file.read_text() - assert "User custom content" in content or "my_project" in content + # User content should be preserved (or flagged as conflict) + content = test_file.read_text() + self.assertTrue("User custom content" in content or "my_project" in content) - def test_dry_run_doesnt_modify_files(self, backend, tmp_path): + def test_dry_run_doesnt_modify_files(self): """Test dry run doesn't modify any files.""" - project_path = tmp_path / "my_project" - project_path.mkdir() + with TemporaryDirectory() as tmp: + tmp_path = Path(tmp) - # Setup project - subprocess.run(["git", "init"], cwd=project_path, capture_output=True) - subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=project_path, capture_output=True) - subprocess.run(["git", "config", "user.name", "Test"], cwd=project_path, capture_output=True) + project_path = tmp_path / "my_project" + project_path.mkdir() - test_file = project_path / "test.txt" - test_file.write_text("original content") + # Setup project + subprocess.run(["git", "init"], cwd=project_path, capture_output=True) + subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=project_path, capture_output=True) + subprocess.run(["git", "config", "user.name", "Test"], cwd=project_path, capture_output=True) - config_dir = project_path / ".recipe" - config_dir.mkdir() - (config_dir / "config.yml").write_text( - """ + test_file = project_path / "test.txt" + test_file.write_text("original content") + + config_dir = project_path / ".recipe" + config_dir.mkdir() + (config_dir / "config.yml").write_text( + """ metadata: recipe_name: python_package docker_image: test/python_package:v1.0.0 """ - ) + ) + + subprocess.run(["git", "add", "."], cwd=project_path, capture_output=True) + subprocess.run(["git", "commit", "-m", "Initial"], cwd=project_path, capture_output=True) + + original_content = test_file.read_text() - subprocess.run(["git", "add", "."], cwd=project_path, capture_output=True) - subprocess.run(["git", "commit", "-m", "Initial"], cwd=project_path, capture_output=True) + # Dry run update + update_client = UpdateClient(self.backend) + update_client.update_project( + project_path=project_path, + target_version="v1.1.0", + dry_run=True, + ) - original_content = test_file.read_text() + # File should be unchanged + self.assertEqual(test_file.read_text(), original_content) - # Dry run update - update_client = UpdateClient(backend) - update_client.update_project( - project_path=project_path, - target_version="v1.1.0", - dry_run=True, - ) - # File should be unchanged - assert test_file.read_text() == original_content +if __name__ == "__main__": + unittest.main() diff --git a/tests/functional/test_local_backend.py b/tests/functional/test_local_backend.py index 8071c8b..e671232 100644 --- a/tests/functional/test_local_backend.py +++ b/tests/functional/test_local_backend.py @@ -1,113 +1,149 @@ """Tests for LocalBackend.""" +import unittest from pathlib import Path - -import pytest +from tempfile import TemporaryDirectory from nskit.client.backends import LocalBackend -@pytest.fixture -def recipes_dir(tmp_path): - """Create a recipes directory with test data.""" - # recipe_a with 2 versions - (tmp_path / "recipe_a" / "v1.0.0").mkdir(parents=True) - (tmp_path / "recipe_a" / "v1.0.0" / "README.md").write_text("# Recipe A v1") - (tmp_path / "recipe_a" / "v1.0.0" / "config.txt").write_text("key=value") - (tmp_path / "recipe_a" / "v2.0.0").mkdir(parents=True) - (tmp_path / "recipe_a" / "v2.0.0" / "README.md").write_text("# Recipe A v2") - - # recipe_b with 1 version - (tmp_path / "recipe_b" / "v1.0.0").mkdir(parents=True) - (tmp_path / "recipe_b" / "v1.0.0" / "main.py").write_text("print('hello')") +class TestLocalBackend(unittest.TestCase): + """Test LocalBackend.""" - # hidden dir (should be ignored) - (tmp_path / ".hidden").mkdir() + def _create_recipes_dir(self, tmp_path): + """Create a recipes directory with test data.""" + # recipe_a with 2 versions + (tmp_path / "recipe_a" / "v1.0.0").mkdir(parents=True) + (tmp_path / "recipe_a" / "v1.0.0" / "README.md").write_text("# Recipe A v1") + (tmp_path / "recipe_a" / "v1.0.0" / "config.txt").write_text("key=value") + (tmp_path / "recipe_a" / "v2.0.0").mkdir(parents=True) + (tmp_path / "recipe_a" / "v2.0.0" / "README.md").write_text("# Recipe A v2") - return tmp_path + # recipe_b with 1 version + (tmp_path / "recipe_b" / "v1.0.0").mkdir(parents=True) + (tmp_path / "recipe_b" / "v1.0.0" / "main.py").write_text("print('hello')") + # hidden dir (should be ignored) + (tmp_path / ".hidden").mkdir() -class TestLocalBackend: - """Test LocalBackend.""" + return tmp_path - def test_entrypoint(self, recipes_dir): + def test_entrypoint(self): """Test entrypoint property.""" - backend = LocalBackend(recipes_dir=recipes_dir, entrypoint="custom.recipes") - assert backend.entrypoint == "custom.recipes" + with TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + recipes_dir = self._create_recipes_dir(tmp_path) + backend = LocalBackend(recipes_dir=recipes_dir, entrypoint="custom.recipes") + self.assertEqual(backend.entrypoint, "custom.recipes") - def test_entrypoint_default(self, recipes_dir): + def test_entrypoint_default(self): """Test default entrypoint.""" - backend = LocalBackend(recipes_dir=recipes_dir) - assert backend.entrypoint == "nskit.recipes" + with TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + recipes_dir = self._create_recipes_dir(tmp_path) + backend = LocalBackend(recipes_dir=recipes_dir) + self.assertEqual(backend.entrypoint, "nskit.recipes") - def test_list_recipes(self, recipes_dir): + def test_list_recipes(self): """Test listing recipes from directory.""" - backend = LocalBackend(recipes_dir=recipes_dir) - recipes = backend.list_recipes() + with TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + recipes_dir = self._create_recipes_dir(tmp_path) + backend = LocalBackend(recipes_dir=recipes_dir) + recipes = backend.list_recipes() - names = {r.name for r in recipes} - assert names == {"recipe_a", "recipe_b"} - assert ".hidden" not in names + names = {r.name for r in recipes} + self.assertEqual(names, {"recipe_a", "recipe_b"}) + self.assertNotIn(".hidden", names) - def test_list_recipes_versions(self, recipes_dir): + def test_list_recipes_versions(self): """Test that listed recipes include correct versions.""" - backend = LocalBackend(recipes_dir=recipes_dir) - recipes = backend.list_recipes() + with TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + recipes_dir = self._create_recipes_dir(tmp_path) + backend = LocalBackend(recipes_dir=recipes_dir) + recipes = backend.list_recipes() - recipe_a = next(r for r in recipes if r.name == "recipe_a") - assert recipe_a.versions == ["v1.0.0", "v2.0.0"] + recipe_a = next(r for r in recipes if r.name == "recipe_a") + self.assertEqual(recipe_a.versions, ["v1.0.0", "v2.0.0"]) - recipe_b = next(r for r in recipes if r.name == "recipe_b") - assert recipe_b.versions == ["v1.0.0"] + recipe_b = next(r for r in recipes if r.name == "recipe_b") + self.assertEqual(recipe_b.versions, ["v1.0.0"]) - def test_list_recipes_empty_dir(self, tmp_path): + def test_list_recipes_empty_dir(self): """Test listing recipes from empty directory.""" - backend = LocalBackend(recipes_dir=tmp_path) - assert backend.list_recipes() == [] + with TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + backend = LocalBackend(recipes_dir=tmp_path) + self.assertEqual(backend.list_recipes(), []) - def test_list_recipes_nonexistent_dir(self, tmp_path): + def test_list_recipes_nonexistent_dir(self): """Test listing recipes from nonexistent directory.""" - backend = LocalBackend(recipes_dir=tmp_path / "nonexistent") - assert backend.list_recipes() == [] + with TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + backend = LocalBackend(recipes_dir=tmp_path / "nonexistent") + self.assertEqual(backend.list_recipes(), []) - def test_get_recipe_versions(self, recipes_dir): + def test_get_recipe_versions(self): """Test getting versions for a recipe.""" - backend = LocalBackend(recipes_dir=recipes_dir) - versions = backend.get_recipe_versions("recipe_a") - assert versions == ["v1.0.0", "v2.0.0"] - - def test_get_recipe_versions_single(self, recipes_dir): + with TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + recipes_dir = self._create_recipes_dir(tmp_path) + backend = LocalBackend(recipes_dir=recipes_dir) + versions = backend.get_recipe_versions("recipe_a") + self.assertEqual(versions, ["v1.0.0", "v2.0.0"]) + + def test_get_recipe_versions_single(self): """Test getting versions for recipe with one version.""" - backend = LocalBackend(recipes_dir=recipes_dir) - versions = backend.get_recipe_versions("recipe_b") - assert versions == ["v1.0.0"] - - def test_get_recipe_versions_nonexistent(self, recipes_dir): + with TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + recipes_dir = self._create_recipes_dir(tmp_path) + backend = LocalBackend(recipes_dir=recipes_dir) + versions = backend.get_recipe_versions("recipe_b") + self.assertEqual(versions, ["v1.0.0"]) + + def test_get_recipe_versions_nonexistent(self): """Test getting versions for nonexistent recipe.""" - backend = LocalBackend(recipes_dir=recipes_dir) - assert backend.get_recipe_versions("nonexistent") == [] + with TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + recipes_dir = self._create_recipes_dir(tmp_path) + backend = LocalBackend(recipes_dir=recipes_dir) + self.assertEqual(backend.get_recipe_versions("nonexistent"), []) - def test_fetch_recipe(self, recipes_dir, tmp_path): + def test_fetch_recipe(self): """Test fetching recipe copies files.""" - backend = LocalBackend(recipes_dir=recipes_dir) - dest = tmp_path / "dest" + with TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + recipes_dir = self._create_recipes_dir(tmp_path) + backend = LocalBackend(recipes_dir=recipes_dir) + dest = tmp_path / "dest" - result = backend.fetch_recipe("recipe_a", "v1.0.0", dest) + result = backend.fetch_recipe("recipe_a", "v1.0.0", dest) - assert result == dest / "recipe_a" - assert (result / "README.md").read_text() == "# Recipe A v1" - assert (result / "config.txt").read_text() == "key=value" + self.assertEqual(result, dest / "recipe_a") + self.assertEqual((result / "README.md").read_text(), "# Recipe A v1") + self.assertEqual((result / "config.txt").read_text(), "key=value") - def test_fetch_recipe_nonexistent_version(self, recipes_dir, tmp_path): + def test_fetch_recipe_nonexistent_version(self): """Test fetching nonexistent version raises error.""" - backend = LocalBackend(recipes_dir=recipes_dir) + with TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + recipes_dir = self._create_recipes_dir(tmp_path) + backend = LocalBackend(recipes_dir=recipes_dir) - with pytest.raises(FileNotFoundError): - backend.fetch_recipe("recipe_a", "v9.9.9", tmp_path / "dest") + with self.assertRaises(FileNotFoundError): + backend.fetch_recipe("recipe_a", "v9.9.9", tmp_path / "dest") - def test_fetch_recipe_nonexistent_recipe(self, recipes_dir, tmp_path): + def test_fetch_recipe_nonexistent_recipe(self): """Test fetching nonexistent recipe raises error.""" - backend = LocalBackend(recipes_dir=recipes_dir) + with TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + recipes_dir = self._create_recipes_dir(tmp_path) + backend = LocalBackend(recipes_dir=recipes_dir) + + with self.assertRaises(FileNotFoundError): + backend.fetch_recipe("nonexistent", "v1.0.0", tmp_path / "dest") + - with pytest.raises(FileNotFoundError): - backend.fetch_recipe("nonexistent", "v1.0.0", tmp_path / "dest") +if __name__ == "__main__": + unittest.main() diff --git a/tests/functional/test_three_way_merge.py b/tests/functional/test_three_way_merge.py index 8f2858d..82e9448 100644 --- a/tests/functional/test_three_way_merge.py +++ b/tests/functional/test_three_way_merge.py @@ -1,7 +1,8 @@ -"""Comprehensive tests for 3-way merge behavior.""" +"""Comprehensive tests for 3-way merge behaviour.""" -import subprocess +import unittest from pathlib import Path +from tempfile import TemporaryDirectory import pytest @@ -9,209 +10,165 @@ from nskit.common.models.diff import DiffMode -@pytest.fixture -def git_repo_with_files(tmp_path): - """Create a git repo with initial files.""" - repo = tmp_path / "repo" - repo.mkdir() +@pytest.mark.usefixtures("git_repo") +class TestThreeWayMerge(unittest.TestCase): + """Test 3-way merge behaviour.""" - # Initialize git - subprocess.run(["git", "init"], cwd=repo, capture_output=True, check=True) - subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=repo, capture_output=True, check=True) - subprocess.run(["git", "config", "user.name", "Test"], cwd=repo, capture_output=True, check=True) - - return repo - - -class TestThreeWayMerge: - """Test 3-way merge behavior.""" - - def test_merge_no_conflicts(self, git_repo_with_files): + def test_merge_no_conflicts(self): """Test 3-way merge with no conflicts.""" - repo = git_repo_with_files - - # Create content base_content = "line1\nline2\nline3\n" user_content = "line1_modified_by_user\nline2\nline3\n" template_content = "line1\nline2\nline3_modified_by_recipe\n" - # Perform 3-way merge - git_utils = GitUtils(repo) + git_utils = GitUtils(self.git_repo) merged_content, has_conflicts = git_utils.merge_file(base_content, user_content, template_content) - # Should merge cleanly - assert not has_conflicts - assert "line1_modified_by_user" in merged_content - assert "line3_modified_by_recipe" in merged_content + self.assertFalse(has_conflicts) + self.assertIn("line1_modified_by_user", merged_content) + self.assertIn("line3_modified_by_recipe", merged_content) - def test_merge_with_conflicts(self, git_repo_with_files): + def test_merge_with_conflicts(self): """Test 3-way merge with conflicts.""" - repo = git_repo_with_files - - # Create content base_content = "line1\nline2\nline3\n" user_content = "line1\nline2_user_change\nline3\n" template_content = "line1\nline2_recipe_change\nline3\n" - # Perform 3-way merge - git_utils = GitUtils(repo) + git_utils = GitUtils(self.git_repo) merged_content, has_conflicts = git_utils.merge_file(base_content, user_content, template_content) - # Should have conflicts - assert has_conflicts - assert "<<<<<<" in merged_content - assert "line2_user_change" in merged_content - assert "line2_recipe_change" in merged_content + self.assertTrue(has_conflicts) + self.assertIn("<<<<<<", merged_content) + self.assertIn("line2_user_change", merged_content) + self.assertIn("line2_recipe_change", merged_content) - def test_merge_user_added_lines(self, git_repo_with_files): + def test_merge_user_added_lines(self): """Test merge preserves user-added lines.""" - repo = git_repo_with_files - - # Create content base_content = "line1\nline2\n" user_content = "line1\nline2\nuser_added_line\n" template_content = "line1_updated\nline2\n" - # Perform 3-way merge - git_utils = GitUtils(repo) + git_utils = GitUtils(self.git_repo) merged_content, has_conflicts = git_utils.merge_file(base_content, user_content, template_content) - # Should preserve user addition - assert not has_conflicts - assert "user_added_line" in merged_content - assert "line1_updated" in merged_content + self.assertFalse(has_conflicts) + self.assertIn("user_added_line", merged_content) + self.assertIn("line1_updated", merged_content) - def test_merge_recipe_added_lines(self, git_repo_with_files): + def test_merge_recipe_added_lines(self): """Test merge includes recipe-added lines.""" - repo = git_repo_with_files - - # Create content base_content = "line1\nline2\n" user_content = "line1\nline2\n" template_content = "line1\nline2\nrecipe_added_line\n" - # Perform 3-way merge - git_utils = GitUtils(repo) + git_utils = GitUtils(self.git_repo) merged_content, has_conflicts = git_utils.merge_file(base_content, user_content, template_content) - # Should include recipe addition - assert not has_conflicts - assert "recipe_added_line" in merged_content + self.assertFalse(has_conflicts) + self.assertIn("recipe_added_line", merged_content) - def test_merge_both_deleted_same_line(self, git_repo_with_files): + def test_merge_both_deleted_same_line(self): """Test merge when both deleted same line.""" - repo = git_repo_with_files - - # Create content base_content = "line1\nline2\nline3\n" user_content = "line1\nline3\n" template_content = "line1\nline3\n" - # Perform 3-way merge - git_utils = GitUtils(repo) + git_utils = GitUtils(self.git_repo) merged_content, has_conflicts = git_utils.merge_file(base_content, user_content, template_content) - # Should merge cleanly (both made same change) - assert not has_conflicts - assert "line2" not in merged_content + self.assertFalse(has_conflicts) + self.assertNotIn("line2", merged_content) - def test_merge_complex_scenario(self, git_repo_with_files): + def test_merge_complex_scenario(self): """Test complex merge scenario with multiple changes.""" - repo = git_repo_with_files - - # Create content base_content = "header\nline1\nline2\nline3\nfooter\n" user_content = "header\nline1_user\nline2\nline3\nuser_addition\nfooter\n" template_content = "header_updated\nline1\nline2_recipe\nline3\nfooter\n" - # Perform 3-way merge - git_utils = GitUtils(repo) + git_utils = GitUtils(self.git_repo) merged_content, has_conflicts = git_utils.merge_file(base_content, user_content, template_content) - # Check results - assert "header_updated" in merged_content # Recipe change - assert "line1_user" in merged_content # User change - assert "user_addition" in merged_content # User addition - # line2 will have conflict - assert has_conflicts + self.assertIn("header_updated", merged_content) + self.assertIn("line1_user", merged_content) + self.assertIn("user_addition", merged_content) + self.assertTrue(has_conflicts) - def test_merge_empty_files(self, git_repo_with_files): + def test_merge_empty_files(self): """Test merge with empty files.""" - repo = git_repo_with_files - - # Create content base_content = "" user_content = "user_content\n" template_content = "recipe_content\n" - # Perform 3-way merge - git_utils = GitUtils(repo) + git_utils = GitUtils(self.git_repo) merged_content, has_conflicts = git_utils.merge_file(base_content, user_content, template_content) - # Both additions should be present - assert "user_content" in merged_content - assert "recipe_content" in merged_content + self.assertIn("user_content", merged_content) + self.assertIn("recipe_content", merged_content) - def test_merge_preserves_whitespace(self, git_repo_with_files): + def test_merge_preserves_whitespace(self): """Test merge preserves whitespace correctly.""" - repo = git_repo_with_files - - # Create content with specific whitespace base_content = "line1\n indented\nline3\n" user_content = "line1_modified\n indented\nline3\n" template_content = "line1\n indented\nline3_modified\n" - # Perform 3-way merge - git_utils = GitUtils(repo) + git_utils = GitUtils(self.git_repo) merged_content, has_conflicts = git_utils.merge_file(base_content, user_content, template_content) - # Should preserve indentation - assert not has_conflicts - assert " indented" in merged_content - assert "line1_modified" in merged_content - assert "line3_modified" in merged_content + self.assertFalse(has_conflicts) + self.assertIn(" indented", merged_content) + self.assertIn("line1_modified", merged_content) + self.assertIn("line3_modified", merged_content) -class TestDiffModes: +class TestDiffModes(unittest.TestCase): """Test different diff modes.""" - def test_two_way_diff(self, tmp_path): + def test_two_way_diff(self): """Test 2-way diff mode.""" from nskit.client.diff import DiffEngine from nskit.common.models.diff import DiffMode - old_dir = tmp_path / "old" - old_dir.mkdir() - (old_dir / "file1.txt").write_text("old content") + with TemporaryDirectory() as tmp: + tmp_path = Path(tmp) - new_dir = tmp_path / "new" - new_dir.mkdir() - (new_dir / "file1.txt").write_text("new content") - (new_dir / "file2.txt").write_text("added file") + old_dir = tmp_path / "old" + old_dir.mkdir() + (old_dir / "file1.txt").write_text("old content") - engine = DiffEngine() - result = engine.extract_diff(old_dir, new_dir, DiffMode.TWO_WAY) + new_dir = tmp_path / "new" + new_dir.mkdir() + (new_dir / "file1.txt").write_text("new content") + (new_dir / "file2.txt").write_text("added file") - assert len(result.modified_files) == 1 - assert len(result.added_files) == 1 - assert result.modified_files[0].relative_path == "file1.txt" - assert result.added_files[0].relative_path == "file2.txt" + engine = DiffEngine() + result = engine.extract_diff(old_dir, new_dir, DiffMode.TWO_WAY) - def test_three_way_diff(self, tmp_path): + self.assertEqual(len(result.modified_files), 1) + self.assertEqual(len(result.added_files), 1) + self.assertEqual(result.modified_files[0].relative_path, "file1.txt") + self.assertEqual(result.added_files[0].relative_path, "file2.txt") + + def test_three_way_diff(self): """Test 3-way diff mode.""" from nskit.client.diff import DiffEngine from nskit.common.models.diff import DiffMode - base_dir = tmp_path / "base" - base_dir.mkdir() - (base_dir / "file1.txt").write_text("base content") + with TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + + base_dir = tmp_path / "base" + base_dir.mkdir() + (base_dir / "file1.txt").write_text("base content") + + new_dir = tmp_path / "new" + new_dir.mkdir() + (new_dir / "file1.txt").write_text("new content") + + engine = DiffEngine() + result = engine.extract_diff(base_dir, new_dir, DiffMode.THREE_WAY) - new_dir = tmp_path / "new" - new_dir.mkdir() - (new_dir / "file1.txt").write_text("new content") + self.assertEqual(len(result.modified_files), 1) + self.assertEqual(result.modified_files[0].relative_path, "file1.txt") - engine = DiffEngine() - result = engine.extract_diff(base_dir, new_dir, DiffMode.THREE_WAY) - assert len(result.modified_files) == 1 - assert result.modified_files[0].relative_path == "file1.txt" +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_cli/test_app.py b/tests/unit/test_cli/test_app.py index 8a8f50d..60cd5c1 100644 --- a/tests/unit/test_cli/test_app.py +++ b/tests/unit/test_cli/test_app.py @@ -1,135 +1,130 @@ """Tests for CLI factory.""" +import json +import tempfile +import unittest from pathlib import Path -from unittest.mock import Mock, patch +from unittest.mock import MagicMock, Mock, patch -import pytest from typer.testing import CliRunner from nskit.cli import create_cli from nskit.common.contextmanagers import Env -@pytest.fixture -def mock_backend(): - """Mock backend for testing.""" - backend = Mock() - backend.list_recipes.return_value = [] - backend.get_recipe_versions.return_value = ["v1.0.0"] - return backend - - -@pytest.fixture -def runner(): - """CLI test runner.""" - return CliRunner() - - -class TestCreateCLI: +class TestCreateCLI(unittest.TestCase): """Test CLI factory.""" + def setUp(self): + self.runner = CliRunner() + self.mock_backend = Mock() + self.mock_backend.list_recipes.return_value = [] + self.mock_backend.get_recipe_versions.return_value = ["v1.0.0"] + def test_create_cli_basic(self): """Test creating basic CLI without backend.""" app = create_cli(recipe_entrypoint="test.recipes", app_name="test-cli", app_help="Test CLI") - assert app is not None - assert app.info.name == "test-cli" - assert app.info.help == "Test CLI" + self.assertIsNotNone(app) + self.assertEqual(app.info.name, "test-cli") + self.assertEqual(app.info.help, "Test CLI") - def test_create_cli_with_backend(self, mock_backend): + def test_create_cli_with_backend(self): """Test creating CLI with backend.""" - app = create_cli(recipe_entrypoint="test.recipes", backend=mock_backend) + app = create_cli(recipe_entrypoint="test.recipes", backend=self.mock_backend) - assert app is not None + self.assertIsNotNone(app) - def test_init_command_exists(self, runner): + def test_init_command_exists(self): """Test init command is registered.""" app = create_cli(recipe_entrypoint="test.recipes") - result = runner.invoke(app, ["--help"]) + result = self.runner.invoke(app, ["--help"]) - assert result.exit_code == 0 - assert "init" in result.stdout + self.assertEqual(result.exit_code, 0) + self.assertIn("init", result.stdout) - def test_get_required_fields_command_exists(self, runner): + def test_get_required_fields_command_exists(self): """Test get-required-fields command is registered.""" app = create_cli(recipe_entrypoint="test.recipes") - result = runner.invoke(app, ["--help"]) + result = self.runner.invoke(app, ["--help"]) - assert result.exit_code == 0 - assert "get-required-fields" in result.stdout + self.assertEqual(result.exit_code, 0) + self.assertIn("get-required-fields", result.stdout) - def test_list_command_with_backend(self, runner, mock_backend): + def test_list_command_with_backend(self): """Test list command is available with backend.""" - app = create_cli(recipe_entrypoint="test.recipes", backend=mock_backend) - result = runner.invoke(app, ["--help"]) + app = create_cli(recipe_entrypoint="test.recipes", backend=self.mock_backend) + result = self.runner.invoke(app, ["--help"]) - assert result.exit_code == 0 - assert "list" in result.stdout + self.assertEqual(result.exit_code, 0) + self.assertIn("list", result.stdout) - def test_list_command_without_backend(self, runner): + def test_list_command_without_backend(self): """Test list command is available without backend (uses entry points).""" app = create_cli(recipe_entrypoint="test.recipes") - result = runner.invoke(app, ["--help"]) + result = self.runner.invoke(app, ["--help"]) - assert result.exit_code == 0 - assert "list" in result.stdout + self.assertEqual(result.exit_code, 0) + self.assertIn("list", result.stdout) - def test_update_command_with_backend(self, runner, mock_backend): + def test_update_command_with_backend(self): """Test update command is available with backend.""" - app = create_cli(recipe_entrypoint="test.recipes", backend=mock_backend) - result = runner.invoke(app, ["--help"]) + app = create_cli(recipe_entrypoint="test.recipes", backend=self.mock_backend) + result = self.runner.invoke(app, ["--help"]) - assert result.exit_code == 0 - assert "update" in result.stdout + self.assertEqual(result.exit_code, 0) + self.assertIn("update", result.stdout) - def test_check_command_with_backend(self, runner, mock_backend): + def test_check_command_with_backend(self): """Test check command is available with backend.""" - app = create_cli(recipe_entrypoint="test.recipes", backend=mock_backend) - result = runner.invoke(app, ["--help"]) + app = create_cli(recipe_entrypoint="test.recipes", backend=self.mock_backend) + result = self.runner.invoke(app, ["--help"]) - assert result.exit_code == 0 - assert "check" in result.stdout + self.assertEqual(result.exit_code, 0) + self.assertIn("check", result.stdout) - def test_discover_command_with_backend(self, runner, mock_backend): + def test_discover_command_with_backend(self): """Test discover command is available with backend.""" - app = create_cli(recipe_entrypoint="test.recipes", backend=mock_backend) - result = runner.invoke(app, ["--help"]) + app = create_cli(recipe_entrypoint="test.recipes", backend=self.mock_backend) + result = self.runner.invoke(app, ["--help"]) - assert result.exit_code == 0 - assert "discover" in result.stdout + self.assertEqual(result.exit_code, 0) + self.assertIn("discover", result.stdout) @patch("nskit.mixer.components.recipe.Recipe.load") - def test_init_command_without_backend(self, mock_load, runner, tmp_path): + def test_init_command_without_backend(self, mock_load): """Test init command works without backend.""" mock_recipe = Mock() mock_load.return_value = mock_recipe app = create_cli(recipe_entrypoint="test.recipes") - # Use input-yaml-path to skip interactive prompting - input_file = tmp_path / "input.yaml" - input_file.write_text("name: test\n") - - # Remove VCS tokens so _detect_repo_client doesn't trigger questionary - with Env(remove=["GITHUB_TOKEN", "AZURE_DEVOPS_TOKEN"]): - runner.invoke( - app, - [ - "init", - "--recipe", - "test_recipe", - "--input-yaml-path", - str(input_file), - "--output-base-path", - str(tmp_path), - ], - ) + with tempfile.TemporaryDirectory() as tmp_path: + tmp_path = Path(tmp_path) + # Use input-yaml-path to skip interactive prompting + input_file = tmp_path / "input.yaml" + input_file.write_text("name: test\n") + + # Remove VCS tokens so _detect_repo_client doesn't trigger questionary + with Env(remove=["GITHUB_TOKEN", "AZURE_DEVOPS_TOKEN"]): + self.runner.invoke( + app, + [ + "init", + "--recipe", + "test_recipe", + "--input-yaml-path", + str(input_file), + "--output-base-path", + str(tmp_path), + ], + ) mock_load.assert_called_once() mock_recipe.create.assert_called_once() @patch("nskit.mixer.components.recipe.Recipe.load") - def test_get_required_fields_command(self, mock_load, runner): + def test_get_required_fields_command(self, mock_load): """Test get-required-fields command.""" mock_recipe = Mock() mock_load.return_value = mock_recipe @@ -137,16 +132,16 @@ def test_get_required_fields_command(self, mock_load, runner): app = create_cli(recipe_entrypoint="test.recipes") with patch("nskit.cli.app.get_required_fields_as_dict", return_value={"field1": "str"}): - result = runner.invoke(app, ["get-required-fields", "--recipe", "test_recipe"]) + result = self.runner.invoke(app, ["get-required-fields", "--recipe", "test_recipe"]) - assert result.exit_code == 0 - assert "field1" in result.stdout + self.assertEqual(result.exit_code, 0) + self.assertIn("field1", result.stdout) -class TestCommitAndMaybePush: +class TestCommitAndMaybePush(unittest.TestCase): """Tests for _commit_and_maybe_push.""" - def test_commits_files(self, tmp_path): + def test_commits_files(self): """Always commits generated files.""" import subprocess @@ -154,121 +149,118 @@ def test_commits_files(self, tmp_path): from nskit.cli.app import _commit_and_maybe_push - project = tmp_path / "proj" - project.mkdir() - subprocess.run(["git", "init"], cwd=project, capture_output=True, check=True) - subprocess.run(["git", "config", "user.email", "t@t.com"], cwd=project, capture_output=True) - subprocess.run(["git", "config", "user.name", "T"], cwd=project, capture_output=True) - (project / "file.txt").write_text("content") + with tempfile.TemporaryDirectory() as tmp_path: + project = Path(tmp_path) / "proj" + project.mkdir() + subprocess.run(["git", "init"], cwd=project, capture_output=True, check=True) + subprocess.run(["git", "config", "user.email", "t@t.com"], cwd=project, capture_output=True) + subprocess.run(["git", "config", "user.name", "T"], cwd=project, capture_output=True) + (project / "file.txt").write_text("content") - console = Console() - _commit_and_maybe_push(project, "proj", "", False, None, console) + console = Console() + _commit_and_maybe_push(project, "proj", "", False, None, console) - # Verify commit happened - result = subprocess.run(["git", "log", "--oneline"], cwd=project, capture_output=True, text=True) - assert "Initial commit from recipe" in result.stdout + # Verify commit happened + result = subprocess.run(["git", "log", "--oneline"], cwd=project, capture_output=True, text=True) + self.assertIn("Initial commit from recipe", result.stdout) - def test_skips_without_git(self, tmp_path): + def test_skips_without_git(self): """Does nothing if project has no .git directory.""" from rich.console import Console from nskit.cli.app import _commit_and_maybe_push - project = tmp_path / "proj" - project.mkdir() - (project / "file.txt").write_text("content") + with tempfile.TemporaryDirectory() as tmp_path: + project = Path(tmp_path) / "proj" + project.mkdir() + (project / "file.txt").write_text("content") - console = Console() - # Should not raise - _commit_and_maybe_push(project, "proj", "", False, None, console) + console = Console() + # Should not raise + _commit_and_maybe_push(project, "proj", "", False, None, console) - def test_creates_remote_and_pushes(self, tmp_path): + def test_creates_remote_and_pushes(self): """Creates remote and pushes when create_repo is True.""" import subprocess - from unittest.mock import MagicMock from rich.console import Console from nskit.cli.app import _commit_and_maybe_push - project = tmp_path / "proj" - project.mkdir() - subprocess.run(["git", "init"], cwd=project, capture_output=True, check=True) - subprocess.run(["git", "config", "user.email", "t@t.com"], cwd=project, capture_output=True) - subprocess.run(["git", "config", "user.name", "T"], cwd=project, capture_output=True) - (project / "file.txt").write_text("content") + with tempfile.TemporaryDirectory() as tmp_path: + project = Path(tmp_path) / "proj" + project.mkdir() + subprocess.run(["git", "init"], cwd=project, capture_output=True, check=True) + subprocess.run(["git", "config", "user.email", "t@t.com"], cwd=project, capture_output=True) + subprocess.run(["git", "config", "user.name", "T"], cwd=project, capture_output=True) + (project / "file.txt").write_text("content") - mock_vcs = MagicMock() - mock_vcs.get_remote_url.return_value = "https://github.com/org/proj" - mock_vcs.get_clone_url.return_value = "https://github.com/org/proj.git" + mock_vcs = MagicMock() + mock_vcs.get_remote_url.return_value = "https://github.com/org/proj" + mock_vcs.get_clone_url.return_value = "https://github.com/org/proj.git" - console = Console() - with patch("nskit.recipes.repository_client.subprocess.run", return_value=MagicMock(returncode=0)): - _commit_and_maybe_push(project, "proj", "desc", True, mock_vcs, console) + console = Console() + with patch("nskit.recipes.repository_client.subprocess.run", return_value=MagicMock(returncode=0)): + _commit_and_maybe_push(project, "proj", "desc", True, mock_vcs, console) - mock_vcs.create.assert_called_once_with("proj") + mock_vcs.create.assert_called_once_with("proj") - def test_no_push_when_declined(self, tmp_path): + def test_no_push_when_declined(self): """Does not create remote when create_repo is False.""" import subprocess - from unittest.mock import MagicMock from rich.console import Console from nskit.cli.app import _commit_and_maybe_push - project = tmp_path / "proj" - project.mkdir() - subprocess.run(["git", "init"], cwd=project, capture_output=True, check=True) - subprocess.run(["git", "config", "user.email", "t@t.com"], cwd=project, capture_output=True) - subprocess.run(["git", "config", "user.name", "T"], cwd=project, capture_output=True) - (project / "file.txt").write_text("content") + with tempfile.TemporaryDirectory() as tmp_path: + project = Path(tmp_path) / "proj" + project.mkdir() + subprocess.run(["git", "init"], cwd=project, capture_output=True, check=True) + subprocess.run(["git", "config", "user.email", "t@t.com"], cwd=project, capture_output=True) + subprocess.run(["git", "config", "user.name", "T"], cwd=project, capture_output=True) + (project / "file.txt").write_text("content") - mock_vcs = MagicMock() - console = Console() - _commit_and_maybe_push(project, "proj", "", False, mock_vcs, console) + mock_vcs = MagicMock() + console = Console() + _commit_and_maybe_push(project, "proj", "", False, mock_vcs, console) - mock_vcs.create.assert_not_called() + mock_vcs.create.assert_not_called() -class TestListJsonFlag: +class TestListJsonFlag(unittest.TestCase): """``list --json`` outputs a machine-readable JSON array.""" - def test_list_json_exits_zero(self, runner): - app = create_cli(recipe_entrypoint="nskit.recipes") - result = runner.invoke(app, ["list", "--json"]) - assert result.exit_code == 0, result.output + def setUp(self): + self.runner = CliRunner() + self.app = create_cli(recipe_entrypoint="nskit.recipes") - def test_list_json_outputs_valid_json(self, runner): - import json + def test_list_json_exits_zero(self): + result = self.runner.invoke(self.app, ["list", "--json"]) + self.assertEqual(result.exit_code, 0, result.output) - app = create_cli(recipe_entrypoint="nskit.recipes") - result = runner.invoke(app, ["list", "--json"]) + def test_list_json_outputs_valid_json(self): + result = self.runner.invoke(self.app, ["list", "--json"]) data = json.loads(result.output) - assert isinstance(data, list) + self.assertIsInstance(data, list) - def test_list_json_contains_registered_recipes(self, runner): - import json - - app = create_cli(recipe_entrypoint="nskit.recipes") - result = runner.invoke(app, ["list", "--json"]) + def test_list_json_contains_registered_recipes(self): + result = self.runner.invoke(self.app, ["list", "--json"]) data = json.loads(result.output) - assert "python_package" in data - - def test_list_json_is_sorted(self, runner): - import json + self.assertIn("python_package", data) - app = create_cli(recipe_entrypoint="nskit.recipes") - result = runner.invoke(app, ["list", "--json"]) + def test_list_json_is_sorted(self): + result = self.runner.invoke(self.app, ["list", "--json"]) data = json.loads(result.output) - assert data == sorted(data) + self.assertEqual(data, sorted(data)) - def test_list_without_json_shows_table(self, runner): + def test_list_without_json_shows_table(self): """Without --json, output is a rich table (not JSON).""" - import json - - app = create_cli(recipe_entrypoint="nskit.recipes") - result = runner.invoke(app, ["list"]) - assert result.exit_code == 0 - with pytest.raises(json.JSONDecodeError): + result = self.runner.invoke(self.app, ["list"]) + self.assertEqual(result.exit_code, 0) + with self.assertRaises(json.JSONDecodeError): json.loads(result.output) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_client/test_backends/test_docker_backend.py b/tests/unit/test_client/test_backends/test_docker_backend.py index ed80462..a558c83 100644 --- a/tests/unit/test_client/test_backends/test_docker_backend.py +++ b/tests/unit/test_client/test_backends/test_docker_backend.py @@ -1,14 +1,13 @@ """Tests for Docker backend.""" import subprocess +import unittest from unittest.mock import Mock, patch -import pytest - from nskit.client.backends import DockerBackend -class TestDockerBackend: +class TestDockerBackend(unittest.TestCase): """Test DockerBackend.""" def test_initialization_success(self): @@ -18,8 +17,8 @@ def test_initialization_success(self): backend = DockerBackend(registry_url="ghcr.io", image_prefix="org/project") - assert backend.registry_url == "ghcr.io" - assert backend.image_prefix == "org/project" + self.assertEqual(backend.registry_url, "ghcr.io") + self.assertEqual(backend.image_prefix, "org/project") mock_run.assert_called_once() def test_docker_not_installed(self): @@ -27,7 +26,7 @@ def test_docker_not_installed(self): with patch("nskit.client.backends.docker.subprocess.run") as mock_run: mock_run.side_effect = FileNotFoundError() - with pytest.raises(RuntimeError, match="install Docker"): + with self.assertRaisesRegex(RuntimeError, "install Docker"): DockerBackend() def test_docker_not_running(self): @@ -35,7 +34,7 @@ def test_docker_not_running(self): with patch("nskit.client.backends.docker.subprocess.run") as mock_run: mock_run.side_effect = subprocess.CalledProcessError(1, "docker") - with pytest.raises(RuntimeError, match="not running"): + with self.assertRaisesRegex(RuntimeError, "not running"): DockerBackend() def test_docker_not_responding(self): @@ -43,7 +42,7 @@ def test_docker_not_responding(self): with patch("nskit.client.backends.docker.subprocess.run") as mock_run: mock_run.side_effect = subprocess.TimeoutExpired("docker", 5) - with pytest.raises(RuntimeError, match="not responding"): + with self.assertRaisesRegex(RuntimeError, "not responding"): DockerBackend() def test_build_image_url_with_prefix(self): @@ -52,7 +51,7 @@ def test_build_image_url_with_prefix(self): backend = DockerBackend(registry_url="ghcr.io", image_prefix="myorg/myproject") url = backend._build_image_url("python_package", "v1.0.0") - assert url == "ghcr.io/myorg/myproject/python_package:v1.0.0" + self.assertEqual(url, "ghcr.io/myorg/myproject/python_package:v1.0.0") def test_build_image_url_without_prefix(self): """Test building image URL without prefix.""" @@ -60,4 +59,8 @@ def test_build_image_url_without_prefix(self): backend = DockerBackend(registry_url="ghcr.io") url = backend._build_image_url("python_package", "v1.0.0") - assert url == "ghcr.io/python_package:v1.0.0" + self.assertEqual(url, "ghcr.io/python_package:v1.0.0") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_client/test_backends/test_github_backend.py b/tests/unit/test_client/test_backends/test_github_backend.py index 56297a0..7a70dae 100644 --- a/tests/unit/test_client/test_backends/test_github_backend.py +++ b/tests/unit/test_client/test_backends/test_github_backend.py @@ -1,53 +1,52 @@ """Tests for GitHub backend with mocked API.""" import subprocess +import unittest from pathlib import Path +from tempfile import TemporaryDirectory from unittest.mock import MagicMock, Mock, patch -import pytest - from nskit.client.backends import GitHubBackend from nskit.client.models import RecipeInfo -@pytest.fixture -def mock_ghapi(): - """Mock ghapi client.""" - with patch("nskit.client.backends.github.sync_ghapi") as mock: - yield mock - +class TestGitHubBackend(unittest.TestCase): + """Test GitHubBackend with mocked GitHub API.""" -@pytest.fixture -def mock_subprocess(): - """Mock subprocess for gh CLI.""" - with patch("nskit.client.backends.github.subprocess") as mock: - mock.run.return_value = Mock(stdout="test_token\n") - yield mock + def setUp(self): + """Set up patchers for ghapi and subprocess.""" + self.ghapi_patcher = patch("nskit.client.backends.github.sync_ghapi") + self.subprocess_patcher = patch("nskit.client.backends.github.subprocess") + self.mock_ghapi = self.ghapi_patcher.start() + self.mock_subprocess = self.subprocess_patcher.start() + self.mock_subprocess.run.return_value = Mock(stdout="test_token\n") -class TestGitHubBackend: - """Test GitHubBackend with mocked GitHub API.""" + def tearDown(self): + """Stop patchers.""" + self.ghapi_patcher.stop() + self.subprocess_patcher.stop() - def test_initialization(self, mock_subprocess): + def test_initialization(self): """Test backend initialization.""" backend = GitHubBackend(org="testorg", token="test_token") - assert backend.org == "testorg" - assert backend._token.get_secret_value() == "test_token" + self.assertEqual(backend.org, "testorg") + self.assertEqual(backend._token.get_secret_value(), "test_token") - def test_get_token_from_gh_cli(self, mock_subprocess): + def test_get_token_from_gh_cli(self): """Test getting token from gh CLI.""" backend = GitHubBackend(org="testorg") token = backend._get_token() - assert token == "test_token" - mock_subprocess.run.assert_called_once() + self.assertEqual(token, "test_token") + self.mock_subprocess.run.assert_called_once() - def test_list_recipes(self, mock_ghapi, mock_subprocess): + def test_list_recipes(self): """Test listing recipes from GitHub.""" # Mock GitHub API responses mock_client = MagicMock() - mock_ghapi.return_value = mock_client + self.mock_ghapi.return_value = mock_client # Mock repos mock_repo1 = Mock() @@ -75,15 +74,15 @@ def test_list_recipes(self, mock_ghapi, mock_subprocess): backend = GitHubBackend(org="testorg", token="test_token") recipes = backend.list_recipes() - assert len(recipes) == 2 - assert recipes[0].name == "recipe-python" - assert recipes[0].description == "Python recipe" - assert len(recipes[0].versions) == 2 + self.assertEqual(len(recipes), 2) + self.assertEqual(recipes[0].name, "recipe-python") + self.assertEqual(recipes[0].description, "Python recipe") + self.assertEqual(len(recipes[0].versions), 2) - def test_get_recipe_versions(self, mock_ghapi, mock_subprocess): + def test_get_recipe_versions(self): """Test getting recipe versions.""" mock_client = MagicMock() - mock_ghapi.return_value = mock_client + self.mock_ghapi.return_value = mock_client # Mock releases mock_release1 = Mock() @@ -103,45 +102,48 @@ def test_get_recipe_versions(self, mock_ghapi, mock_subprocess): backend = GitHubBackend(org="testorg", token="test_token") versions = backend.get_recipe_versions("python_package") - assert len(versions) == 2 - assert "v1.0.0" in versions - assert "v2.0.0" in versions - assert "v3.0.0" not in versions # Draft excluded + self.assertEqual(len(versions), 2) + self.assertIn("v1.0.0", versions) + self.assertIn("v2.0.0", versions) + self.assertNotIn("v3.0.0", versions) # Draft excluded - def test_fetch_recipe(self, mock_ghapi, mock_subprocess, tmp_path): + def test_fetch_recipe(self): """Test fetching recipe from GitHub.""" mock_client = MagicMock() - mock_ghapi.return_value = mock_client + self.mock_ghapi.return_value = mock_client # Mock release mock_release = Mock() mock_release.tag_name = "v1.0.0" mock_client.repos.get_release_by_tag.return_value = mock_release - with patch("nskit.client.backends.github.subprocess") as mock_sub: - mock_sub.run.return_value = Mock(returncode=0) + with TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + + with patch("nskit.client.backends.github.subprocess") as mock_sub: + mock_sub.run.return_value = Mock(returncode=0) - with patch("nskit.client.backends.github.zipfile.ZipFile") as mock_zip: - mock_zip_instance = MagicMock() - mock_zip.return_value.__enter__.return_value = mock_zip_instance + with patch("nskit.client.backends.github.zipfile.ZipFile") as mock_zip: + mock_zip_instance = MagicMock() + mock_zip.return_value.__enter__.return_value = mock_zip_instance - backend = GitHubBackend(org="testorg", token="test_token") - result = backend.fetch_recipe("python_package", "v1.0.0", tmp_path) + backend = GitHubBackend(org="testorg", token="test_token") + result = backend.fetch_recipe("python_package", "v1.0.0", tmp_path) - assert result is not None - mock_client.repos.get_release_by_tag.assert_called_once() + self.assertIsNotNone(result) + mock_client.repos.get_release_by_tag.assert_called_once() - def test_repo_pattern_substitution(self, mock_subprocess): + def test_repo_pattern_substitution(self): """Test repository pattern substitution.""" backend = GitHubBackend(org="testorg", repo_pattern="recipe-{recipe_name}", token="test_token") repo_name = backend._get_repo_name("python_package") - assert repo_name == "recipe-python_package" + self.assertEqual(repo_name, "recipe-python_package") - def test_list_recipes_handles_api_errors(self, mock_ghapi, mock_subprocess): + def test_list_recipes_handles_api_errors(self): """Test list_recipes handles API errors gracefully.""" mock_client = MagicMock() - mock_ghapi.return_value = mock_client + self.mock_ghapi.return_value = mock_client mock_repo = Mock() mock_repo.name = "recipe-python" @@ -154,12 +156,13 @@ def test_list_recipes_handles_api_errors(self, mock_ghapi, mock_subprocess): recipes = backend.list_recipes() # Should handle error gracefully and return repo without versions - assert len(recipes) == 1 - assert recipes[0].name == "recipe-python" + self.assertEqual(len(recipes), 1) + self.assertEqual(recipes[0].name, "recipe-python") def test_get_token_not_logged_in(self): """Test error when gh CLI not authenticated.""" - import subprocess + # Stop the default subprocess patcher for this test + self.subprocess_patcher.stop() with patch("nskit.client.backends.github.subprocess") as mock_sub: mock_sub.CalledProcessError = subprocess.CalledProcessError @@ -167,15 +170,28 @@ def test_get_token_not_logged_in(self): backend = GitHubBackend(org="testorg") - with pytest.raises(RuntimeError, match="gh auth login"): + with self.assertRaisesRegex(RuntimeError, "gh auth login"): backend._get_token() - def test_get_token_gh_not_installed(self, mock_ghapi): + # Restart the patcher for tearDown + self.mock_subprocess = self.subprocess_patcher.start() + + def test_get_token_gh_not_installed(self): """Test error when gh CLI not installed.""" + # Stop the default subprocess patcher for this test + self.subprocess_patcher.stop() + with patch("nskit.client.backends.github.subprocess.run") as mock_run: mock_run.side_effect = FileNotFoundError() backend = GitHubBackend(org="testorg") - with pytest.raises(RuntimeError, match="install it"): + with self.assertRaisesRegex(RuntimeError, "install it"): backend._get_token() + + # Restart the patcher for tearDown + self.mock_subprocess = self.subprocess_patcher.start() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_client/test_docker_execution.py b/tests/unit/test_client/test_docker_execution.py index a589ef4..76af7c3 100644 --- a/tests/unit/test_client/test_docker_execution.py +++ b/tests/unit/test_client/test_docker_execution.py @@ -1,30 +1,34 @@ """Tests for Docker execution mode.""" -import json +import unittest from pathlib import Path +from tempfile import TemporaryDirectory from unittest.mock import MagicMock, Mock, patch -import pytest - from nskit.client.engines import DockerEngine, LocalEngine from nskit.client.recipes import RecipeClient -class TestDockerExecution: +class TestDockerExecution(unittest.TestCase): """Test Docker execution pathway.""" - @pytest.fixture - def mock_backend(self): - """Create mock backend.""" - backend = Mock() - backend.entrypoint = "test.recipes" - backend.get_image_url = Mock(return_value="ghcr.io/test/recipe:v1.0.0") - backend.pull_image = Mock() - return backend + def setUp(self): + """Create mock backend and temporary directory.""" + self.mock_backend = Mock() + self.mock_backend.entrypoint = "test.recipes" + self.mock_backend.get_image_url = Mock(return_value="ghcr.io/test/recipe:v1.0.0") + self.mock_backend.pull_image = Mock() + + self._tmp_dir = TemporaryDirectory() + self.tmp_path = Path(self._tmp_dir.name) + + def tearDown(self): + """Clean up temporary directory.""" + self._tmp_dir.cleanup() - def test_docker_mode_pulls_image(self, mock_backend, tmp_path): + def test_docker_mode_pulls_image(self): """Test that Docker mode pulls image.""" - client = RecipeClient(mock_backend, engine=DockerEngine()) + client = RecipeClient(self.mock_backend, engine=DockerEngine()) with patch("subprocess.run") as mock_run: mock_run.return_value = Mock(returncode=0, stdout="", stderr="") @@ -33,16 +37,16 @@ def test_docker_mode_pulls_image(self, mock_backend, tmp_path): recipe="test-recipe", version="v1.0.0", parameters={"name": "test"}, - output_dir=tmp_path / "output", + output_dir=self.tmp_path / "output", ) # Verify image was pulled - mock_backend.get_image_url.assert_called_once_with("test-recipe", "v1.0.0") - mock_backend.pull_image.assert_called_once_with("ghcr.io/test/recipe:v1.0.0") + self.mock_backend.get_image_url.assert_called_once_with("test-recipe", "v1.0.0") + self.mock_backend.pull_image.assert_called_once_with("ghcr.io/test/recipe:v1.0.0") - def test_docker_mode_runs_container(self, mock_backend, tmp_path): + def test_docker_mode_runs_container(self): """Test that Docker mode runs container with correct arguments.""" - client = RecipeClient(mock_backend, engine=DockerEngine()) + client = RecipeClient(self.mock_backend, engine=DockerEngine()) with patch("subprocess.run") as mock_run: mock_run.return_value = Mock(returncode=0, stdout="", stderr="") @@ -51,23 +55,23 @@ def test_docker_mode_runs_container(self, mock_backend, tmp_path): recipe="test-recipe", version="v1.0.0", parameters={"name": "test", "version": "1.0"}, - output_dir=tmp_path / "output", + output_dir=self.tmp_path / "output", ) # Verify docker run was called (find the init call, not the chown) - assert mock_run.called + self.assertTrue(mock_run.called) run_calls = [ c[0][0] for c in mock_run.call_args_list if c[0][0][0:2] == ["docker", "run"] and "init" in c[0][0] ] - assert len(run_calls) == 1 + self.assertEqual(len(run_calls), 1) call_args = run_calls[0] - assert "--rm" in call_args - assert "ghcr.io/test/recipe:v1.0.0" in call_args + self.assertIn("--rm", call_args) + self.assertIn("ghcr.io/test/recipe:v1.0.0", call_args) - def test_docker_mode_mounts_volumes(self, mock_backend, tmp_path): + def test_docker_mode_mounts_volumes(self): """Test that Docker mode mounts output directory.""" - client = RecipeClient(mock_backend, engine=DockerEngine()) - output_dir = tmp_path / "output" + client = RecipeClient(self.mock_backend, engine=DockerEngine()) + output_dir = self.tmp_path / "output" with patch("subprocess.run") as mock_run: mock_run.return_value = Mock(returncode=0, stdout="", stderr="") @@ -83,14 +87,14 @@ def test_docker_mode_mounts_volumes(self, mock_backend, tmp_path): run_calls = [ c[0][0] for c in mock_run.call_args_list if c[0][0][0:2] == ["docker", "run"] and "init" in c[0][0] ] - assert len(run_calls) == 1 + self.assertEqual(len(run_calls), 1) call_args = run_calls[0] - assert "-v" in call_args + self.assertIn("-v", call_args) # Find the output volume mount v_index = [i for i, arg in enumerate(call_args) if arg == "-v"] - assert any(f"{output_dir.absolute()}:/app/output" in call_args[i + 1] for i in v_index) + self.assertTrue(any(f"{output_dir.absolute()}:/app/output" in call_args[i + 1] for i in v_index)) - def test_docker_mode_passes_parameters(self, mock_backend, tmp_path): + def test_docker_mode_passes_parameters(self): """Docker mode writes parameters to a YAML file and mounts it as input. Asserts on the mount and contents rather than the tempfile API, so the @@ -98,7 +102,7 @@ def test_docker_mode_passes_parameters(self, mock_backend, tmp_path): """ import yaml - client = RecipeClient(mock_backend, engine=DockerEngine()) + client = RecipeClient(self.mock_backend, engine=DockerEngine()) params = {"name": "test-project", "version": "1.0", "author": "Test"} staged: dict[str, object] = {} @@ -110,7 +114,7 @@ def capture(cmd, *args, **kwargs): staged["path"] = host_path staged["contents"] = yaml.safe_load(host_path.read_text()) # Simulate recipe output so the engine reports success. - output_dir = tmp_path / "output" + output_dir = self.tmp_path / "output" output_dir.mkdir(parents=True, exist_ok=True) (output_dir / "generated.txt").write_text("x") return Mock(returncode=0, stdout="", stderr="") @@ -120,22 +124,22 @@ def capture(cmd, *args, **kwargs): recipe="test-recipe", version="v1.0.0", parameters=params, - output_dir=tmp_path / "output", + output_dir=self.tmp_path / "output", ) - assert result.success, result.errors - assert staged["contents"] == params + self.assertTrue(result.success, result.errors) + self.assertEqual(staged["contents"], params) # Staging dir cleaned up after the run. - assert not staged["path"].exists() + self.assertFalse(staged["path"].exists()) - def test_input_not_staged_inside_output_directory(self, mock_backend, tmp_path): + def test_input_not_staged_inside_output_directory(self): """The input file must not land inside the generated project. Recipe post-hooks (git init) run against the output directory, and parameters may contain secrets. """ - client = RecipeClient(mock_backend, engine=DockerEngine()) - output_dir = tmp_path / "output" + client = RecipeClient(self.mock_backend, engine=DockerEngine()) + output_dir = self.tmp_path / "output" seen: list[Path] = [] def capture(cmd, *args, **kwargs): @@ -155,32 +159,32 @@ def capture(cmd, *args, **kwargs): output_dir=output_dir, ) - assert result.success, result.errors - assert output_dir not in seen[0].parents + self.assertTrue(result.success, result.errors) + self.assertNotIn(output_dir, seen[0].parents) - def test_no_files_produced_is_reported_as_error(self, mock_backend, tmp_path): + def test_no_files_produced_is_reported_as_error(self): """A clean exit producing nothing must fail, not silently succeed. Docker creates an empty directory at the mount target when the host path is unshared, so the recipe runs inside the container but the host sees nothing. """ - client = RecipeClient(mock_backend, engine=DockerEngine()) + client = RecipeClient(self.mock_backend, engine=DockerEngine()) with patch("subprocess.run", return_value=Mock(returncode=0, stdout="", stderr="")): result = client.initialize_recipe( recipe="test-recipe", version="v1.0.0", parameters={"name": "test"}, - output_dir=tmp_path / "output", + output_dir=self.tmp_path / "output", ) - assert not result.success - assert any("produced no files" in e for e in result.errors) + self.assertFalse(result.success) + self.assertTrue(any("produced no files" in e for e in result.errors)) - def test_local_mode_uses_installed_package(self, mock_backend, tmp_path): + def test_local_mode_uses_installed_package(self): """Test that local mode uses installed package.""" - client = RecipeClient(mock_backend, engine=LocalEngine()) + client = RecipeClient(self.mock_backend, engine=LocalEngine()) with patch("nskit.mixer.components.Recipe.load") as mock_load: mock_recipe = Mock() @@ -191,7 +195,7 @@ def test_local_mode_uses_installed_package(self, mock_backend, tmp_path): recipe="test-recipe", version="v1.0.0", parameters={"name": "test"}, - output_dir=tmp_path / "output", + output_dir=self.tmp_path / "output", ) # Verify Recipe.load was called @@ -199,20 +203,20 @@ def test_local_mode_uses_installed_package(self, mock_backend, tmp_path): mock_recipe.create.assert_called_once() # Verify backend methods were NOT called - mock_backend.get_image_url.assert_not_called() - mock_backend.pull_image.assert_not_called() + self.mock_backend.get_image_url.assert_not_called() + self.mock_backend.pull_image.assert_not_called() - def test_execution_mode_can_be_changed(self, mock_backend, tmp_path): + def test_execution_mode_can_be_changed(self): """Test that engine can be changed after initialization.""" - client = RecipeClient(mock_backend, engine=DockerEngine()) - assert isinstance(client.engine, DockerEngine) + client = RecipeClient(self.mock_backend, engine=DockerEngine()) + self.assertIsInstance(client.engine, DockerEngine) client.engine = LocalEngine() - assert isinstance(client.engine, LocalEngine) + self.assertIsInstance(client.engine, LocalEngine) - def test_docker_mode_handles_container_failure(self, mock_backend, tmp_path): + def test_docker_mode_handles_container_failure(self): """Test that Docker mode handles container execution failures.""" - client = RecipeClient(mock_backend, engine=DockerEngine()) + client = RecipeClient(self.mock_backend, engine=DockerEngine()) with patch("subprocess.run") as mock_run: mock_run.side_effect = Exception("Container failed") @@ -221,21 +225,25 @@ def test_docker_mode_handles_container_failure(self, mock_backend, tmp_path): recipe="test-recipe", version="v1.0.0", parameters={}, - output_dir=tmp_path / "output", + output_dir=self.tmp_path / "output", ) - assert not result.success - assert len(result.errors) > 0 - assert "Container failed" in result.errors[0] + self.assertFalse(result.success) + self.assertGreater(len(result.errors), 0) + self.assertIn("Container failed", result.errors[0]) -class TestEngines: +class TestEngines(unittest.TestCase): """Test execution engines.""" def test_docker_engine_exists(self): """Test DockerEngine class exists.""" - assert DockerEngine is not None + self.assertIsNotNone(DockerEngine) def test_local_engine_exists(self): """Test LocalEngine class exists.""" - assert LocalEngine is not None + self.assertIsNotNone(LocalEngine) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_client/test_properties.py b/tests/unit/test_client/test_properties.py index dcecc19..f7f2c58 100644 --- a/tests/unit/test_client/test_properties.py +++ b/tests/unit/test_client/test_properties.py @@ -1,8 +1,8 @@ """Property-based tests using Hypothesis.""" +import unittest from pathlib import Path -import pytest from hypothesis import given from hypothesis import strategies as st @@ -13,7 +13,7 @@ version_strategy = st.from_regex(r"v?\d+\.\d+\.\d+", fullmatch=True) -class TestRecipeInfoProperties: +class TestRecipeInfoProperties(unittest.TestCase): """Property-based tests for RecipeInfo.""" @given(name=recipe_name_strategy, versions=st.lists(version_strategy, min_size=1, max_size=10)) @@ -21,8 +21,8 @@ def test_recipe_info_creation(self, name, versions): """Test RecipeInfo can be created with any valid inputs.""" recipe = RecipeInfo(name=name, versions=versions) - assert recipe.name == name - assert recipe.versions == versions + self.assertEqual(recipe.name, name) + self.assertEqual(recipe.versions, versions) @given(name=recipe_name_strategy, versions=st.lists(version_strategy, min_size=1, max_size=10)) def test_recipe_info_serialization(self, name, versions): @@ -31,11 +31,11 @@ def test_recipe_info_serialization(self, name, versions): data = recipe.model_dump() restored = RecipeInfo(**data) - assert restored.name == recipe.name - assert restored.versions == recipe.versions + self.assertEqual(restored.name, recipe.name) + self.assertEqual(restored.versions, recipe.versions) -class TestUpdateResultProperties: +class TestUpdateResultProperties(unittest.TestCase): """Property-based tests for UpdateResult.""" @given(success=st.booleans()) @@ -45,29 +45,29 @@ def test_update_result_creation(self, success): success=success, files_updated=[], files_with_conflicts=[], clean_merges=[], errors=[], warnings=[] ) - assert result.success == success + self.assertEqual(result.success, success) -class TestInputValidationProperties: +class TestInputValidationProperties(unittest.TestCase): """Property-based tests for input validation.""" @given(project_name=st.from_regex(r"[a-zA-Z][a-zA-Z0-9_-]{2,49}", fullmatch=True)) def test_valid_project_names(self, project_name): """Test valid project names.""" - assert len(project_name) >= 3 - assert project_name[0].isalpha() + self.assertGreaterEqual(len(project_name), 3) + self.assertTrue(project_name[0].isalpha()) @given(recipe_name=recipe_name_strategy, version=version_strategy) def test_valid_recipe_identifiers(self, recipe_name, version): """Test valid recipe identifiers.""" - assert len(recipe_name) >= 3 - assert len(version) >= 5 # At least "1.0.0" + self.assertGreaterEqual(len(recipe_name), 3) + self.assertGreaterEqual(len(version), 5) # At least "1.0.0" @given(path_str=st.from_regex(r"[a-zA-Z0-9_/-]{1,100}", fullmatch=True)) def test_valid_paths(self, path_str): """Test valid path strings.""" path = Path(path_str) - assert isinstance(path, Path) + self.assertIsInstance(path, Path) @given( yaml_content=st.text( @@ -78,47 +78,52 @@ def test_valid_paths(self, path_str): ) def test_yaml_content_structure(self, yaml_content): """Test YAML content structure.""" - assert isinstance(yaml_content, str) - assert len(yaml_content) >= 10 + self.assertIsInstance(yaml_content, str) + self.assertGreaterEqual(len(yaml_content), 10) -class TestRecipeClientProperties: +class TestRecipeClientProperties(unittest.TestCase): """Property-based tests for RecipeClient operations.""" @given(recipe_name=recipe_name_strategy, output_path=st.from_regex(r"[a-zA-Z0-9_/-]{1,50}", fullmatch=True)) def test_recipe_client_init_params(self, recipe_name, output_path): """Test RecipeClient initialization parameters.""" # Validate parameters without creating actual client - assert len(recipe_name) >= 3 - assert len(output_path) >= 1 + self.assertGreaterEqual(len(recipe_name), 3) + self.assertGreaterEqual(len(output_path), 1) -class TestCLIInputProperties: +class TestCLIInputProperties(unittest.TestCase): """Property-based tests for CLI input validation.""" @given(recipe=recipe_name_strategy, output_path=st.from_regex(r"[a-zA-Z0-9_/-]{1,50}", fullmatch=True)) def test_cli_init_command_inputs(self, recipe, output_path): """Test CLI init command input validation.""" - assert len(recipe) >= 3 - assert len(output_path) >= 1 + self.assertGreaterEqual(len(recipe), 3) + self.assertGreaterEqual(len(output_path), 1) @given(project_path=st.from_regex(r"[a-zA-Z0-9_/-]{1,50}", fullmatch=True), dry_run=st.booleans()) def test_cli_update_command_inputs(self, project_path, dry_run): """Test CLI update command input validation.""" - assert len(project_path) >= 1 - assert isinstance(dry_run, bool) + self.assertGreaterEqual(len(project_path), 1) + self.assertIsInstance(dry_run, bool) @given(query=st.text(min_size=1, max_size=50), limit=st.integers(min_value=1, max_value=100)) def test_cli_discover_command_inputs(self, query, limit): """Test CLI discover command input validation.""" - assert len(query) >= 1 - assert 1 <= limit <= 100 + self.assertGreaterEqual(len(query), 1) + self.assertLessEqual(limit, 100) + self.assertGreaterEqual(limit, 1) -class TestMergeResultProperties: +class TestMergeResultProperties(unittest.TestCase): """Property-based tests for merge results.""" @given(has_conflicts=st.booleans()) def test_merge_result_consistency(self, has_conflicts): """Test merge result consistency.""" - assert isinstance(has_conflicts, bool) + self.assertIsInstance(has_conflicts, bool) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_client/test_recipe_client.py b/tests/unit/test_client/test_recipe_client.py index a12115a..8226c02 100644 --- a/tests/unit/test_client/test_recipe_client.py +++ b/tests/unit/test_client/test_recipe_client.py @@ -1,68 +1,62 @@ """Functional tests for RecipeClient with mocked backends.""" +import unittest from pathlib import Path from unittest.mock import MagicMock, Mock -import pytest - from nskit.client.models import RecipeInfo from nskit.recipes import RecipeClient -@pytest.fixture -def mock_backend(): - """Mock backend for testing.""" - backend = Mock() - backend.entrypoint = "test.recipes" - backend.list_recipes.return_value = [ - RecipeInfo(name="python_package", versions=["v1.0.0", "v1.1.0"]), - RecipeInfo(name="typescript_app", versions=["v2.0.0"]), - ] - backend.get_recipe_versions.return_value = ["v1.0.0", "v1.1.0"] - backend.fetch_recipe.return_value = Path("/tmp/recipe") - return backend - - -class TestRecipeClient: +class TestRecipeClient(unittest.TestCase): """Test RecipeClient functionality.""" - def test_list_recipes(self, mock_backend): + def setUp(self): + """Set up mock backend for testing.""" + self.mock_backend = Mock() + self.mock_backend.entrypoint = "test.recipes" + self.mock_backend.list_recipes.return_value = [ + RecipeInfo(name="python_package", versions=["v1.0.0", "v1.1.0"]), + RecipeInfo(name="typescript_app", versions=["v2.0.0"]), + ] + self.mock_backend.get_recipe_versions.return_value = ["v1.0.0", "v1.1.0"] + self.mock_backend.fetch_recipe.return_value = Path("/tmp/recipe") + + def test_list_recipes(self): """Test listing recipes.""" - client = RecipeClient(mock_backend) + client = RecipeClient(self.mock_backend) recipes = client.list_recipes() - assert len(recipes) == 2 - assert recipes[0].name == "python_package" - assert recipes[1].name == "typescript_app" - mock_backend.list_recipes.assert_called_once() + self.assertEqual(len(recipes), 2) + self.assertEqual(recipes[0].name, "python_package") + self.assertEqual(recipes[1].name, "typescript_app") + self.mock_backend.list_recipes.assert_called_once() - def test_get_recipe_versions(self, mock_backend): + def test_get_recipe_versions(self): """Test getting recipe versions.""" - client = RecipeClient(mock_backend) + client = RecipeClient(self.mock_backend) versions = client.get_recipe_versions("python_package") - assert len(versions) == 2 - assert versions[0] == "v1.0.0" - assert versions[1] == "v1.1.0" - mock_backend.get_recipe_versions.assert_called_once_with("python_package") + self.assertEqual(len(versions), 2) + self.assertEqual(versions[0], "v1.0.0") + self.assertEqual(versions[1], "v1.1.0") + self.mock_backend.get_recipe_versions.assert_called_once_with("python_package") - def test_initialize_recipe(self, mock_backend, tmp_path): + def test_initialize_recipe(self): """Test initializing a recipe.""" - client = RecipeClient(mock_backend) + client = RecipeClient(self.mock_backend) # Just test that the method exists and can be called # Full integration test would require actual recipe files - assert hasattr(client, "initialize_recipe") - assert callable(client.initialize_recipe) + self.assertTrue(hasattr(client, "initialize_recipe")) + self.assertTrue(callable(client.initialize_recipe)) -class TestRecipeClientAdditional: +class TestRecipeClientAdditional(unittest.TestCase): """Additional tests for RecipeClient uncovered functions.""" def test_get_recipe_versions(self): """Test getting recipe versions.""" - from unittest.mock import Mock - backend = Mock() backend.entrypoint = "test.recipes" backend.get_recipe_versions.return_value = ["v1.0.0", "v2.0.0", "v3.0.0"] @@ -70,6 +64,10 @@ def test_get_recipe_versions(self): client = RecipeClient(backend) versions = client.get_recipe_versions("test_recipe") - assert len(versions) == 3 - assert "v1.0.0" in versions - assert "v3.0.0" in versions + self.assertEqual(len(versions), 3) + self.assertIn("v1.0.0", versions) + self.assertIn("v3.0.0", versions) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_client/test_update_client_mocked.py b/tests/unit/test_client/test_update_client_mocked.py index f6b8835..649c24c 100644 --- a/tests/unit/test_client/test_update_client_mocked.py +++ b/tests/unit/test_client/test_update_client_mocked.py @@ -2,92 +2,98 @@ from __future__ import annotations +import unittest from pathlib import Path +from tempfile import TemporaryDirectory from unittest.mock import Mock, patch -import pytest - from nskit.client.exceptions import GitStatusError from nskit.client.update import UpdateClient -@pytest.fixture -def mock_backend(): - """Mock backend for testing.""" - backend = Mock() - backend.entrypoint = "test.recipes" - backend.get_recipe_versions.return_value = ["v1.0.0", "v1.1.0", "v2.0.0"] - backend.fetch_recipe.return_value = Path("/tmp/recipe") - return backend - +class TestUpdateClient(unittest.TestCase): + """Test UpdateClient functionality.""" -@pytest.fixture -def mock_project(tmp_path): - """Create mock project with recipe config.""" - project_path = tmp_path / "project" - project_path.mkdir() + def setUp(self): + """Set up mock backend and mock project.""" + self.mock_backend = Mock() + self.mock_backend.entrypoint = "test.recipes" + self.mock_backend.get_recipe_versions.return_value = ["v1.0.0", "v1.1.0", "v2.0.0"] + self.mock_backend.fetch_recipe.return_value = Path("/tmp/recipe") - recipe_dir = project_path / ".recipe" - recipe_dir.mkdir() + self._tmp_dir = TemporaryDirectory() + tmp_path = Path(self._tmp_dir.name) - config_file = recipe_dir / "config.yml" - config_file.write_text("metadata:\n recipe_name: python_package\n docker_image: test/python_package:v1.0.0\n") + self.mock_project = tmp_path / "project" + self.mock_project.mkdir() - return project_path + recipe_dir = self.mock_project / ".recipe" + recipe_dir.mkdir() + config_file = recipe_dir / "config.yml" + config_file.write_text("metadata:\n recipe_name: python_package\n docker_image: test/python_package:v1.0.0\n") -class TestUpdateClient: - """Test UpdateClient functionality.""" + def tearDown(self): + """Clean up temporary directory.""" + self._tmp_dir.cleanup() - def test_check_update_available_no_config(self, mock_backend, tmp_path): + def test_check_update_available_no_config(self): """Test checking for updates with no recipe config.""" - client = UpdateClient(mock_backend) - latest = client.check_update_available(tmp_path) - assert latest is None + with TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + client = UpdateClient(self.mock_backend) + latest = client.check_update_available(tmp_path) + self.assertIsNone(latest) - def test_check_update_available_no_update(self, mock_backend, mock_project): + def test_check_update_available_no_update(self): """Test checking for updates when already on latest.""" - mock_backend.get_recipe_versions.return_value = ["v1.0.0"] - client = UpdateClient(mock_backend) - latest = client.check_update_available(mock_project) - assert latest is None + self.mock_backend.get_recipe_versions.return_value = ["v1.0.0"] + client = UpdateClient(self.mock_backend) + latest = client.check_update_available(self.mock_project) + self.assertIsNone(latest) @patch("nskit.client.update.GitUtils") - def test_update_project_not_git_repo(self, mock_git_cls, mock_backend, tmp_path): + def test_update_project_not_git_repo(self, mock_git_cls): """Test update raises GitStatusError if not a git repository.""" mock_git_cls.return_value.is_git_repository.return_value = False - client = UpdateClient(mock_backend) - with pytest.raises(GitStatusError, match="not a git repository"): - client.update_project(project_path=tmp_path, target_version="v2.0.0") + with TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + client = UpdateClient(self.mock_backend) + with self.assertRaisesRegex(GitStatusError, "not a git repository"): + client.update_project(project_path=tmp_path, target_version="v2.0.0") @patch("nskit.client.update.GitUtils") - def test_update_project_uncommitted_changes(self, mock_git_cls, mock_backend, mock_project): + def test_update_project_uncommitted_changes(self, mock_git_cls): """Test update raises GitStatusError with uncommitted changes.""" mock_git_cls.return_value.is_git_repository.return_value = True mock_git_cls.return_value.has_uncommitted_changes.return_value = True - client = UpdateClient(mock_backend) - with pytest.raises(GitStatusError, match="uncommitted changes"): - client.update_project(project_path=mock_project, target_version="v2.0.0") + client = UpdateClient(self.mock_backend) + with self.assertRaisesRegex(GitStatusError, "uncommitted changes"): + client.update_project(project_path=self.mock_project, target_version="v2.0.0") @patch("nskit.client.update.GitUtils") - def test_update_project_dry_run_no_file_changes(self, mock_git_cls, mock_backend, mock_project): + def test_update_project_dry_run_no_file_changes(self, mock_git_cls): """Test dry run doesn't modify files.""" mock_git_cls.return_value.is_git_repository.return_value = True mock_git_cls.return_value.has_uncommitted_changes.return_value = False - test_file = mock_project / "test.py" + test_file = self.mock_project / "test.py" test_file.write_text("original content") - client = UpdateClient(mock_backend) + client = UpdateClient(self.mock_backend) try: client.update_project( - project_path=mock_project, + project_path=self.mock_project, target_version="v2.0.0", dry_run=True, ) except Exception: pass - assert test_file.read_text() == "original content" + self.assertEqual(test_file.read_text(), "original content") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_engines.py b/tests/unit/test_engines.py index 1c7515e..181fe93 100644 --- a/tests/unit/test_engines.py +++ b/tests/unit/test_engines.py @@ -1,165 +1,175 @@ """Tests for recipe execution engines.""" +import tempfile +import unittest from pathlib import Path from unittest.mock import Mock, patch -import pytest - from nskit.client.engines import DockerEngine, LocalEngine from nskit.client.models import RecipeResult -class TestDockerEngine: +class TestDockerEngine(unittest.TestCase): """Test DockerEngine directly.""" - def test_requires_image_url(self, tmp_path): + def test_requires_image_url(self): """Execute raises ValueError without image_url.""" engine = DockerEngine() - with pytest.raises(ValueError, match="image_url"): - engine.execute( - recipe="r", - version="v1", - parameters={}, - output_dir=tmp_path, - image_url=None, - ) - - def test_success_returns_result(self, tmp_path): + with tempfile.TemporaryDirectory() as tmp_path: + with self.assertRaisesRegex(ValueError, "image_url"): + engine.execute( + recipe="r", + version="v1", + parameters={}, + output_dir=Path(tmp_path), + image_url=None, + ) + + def test_success_returns_result(self): """Successful execution returns RecipeResult with success=True.""" engine = DockerEngine() - output = tmp_path / "output" - output.mkdir() - (output / "file.txt").write_text("x") - - with patch("subprocess.run") as mock_run: - mock_run.return_value = Mock(returncode=0, stdout="", stderr="") - result = engine.execute( - recipe="my-recipe", - version="v1.0.0", - parameters={"name": "test"}, - output_dir=output, - image_url="ghcr.io/test:v1", - ) - - assert result.success - assert result.recipe_name == "my-recipe" - assert result.recipe_version == "v1.0.0" - assert Path("file.txt") in result.files_created - - def test_subprocess_failure_returns_error(self, tmp_path): + with tempfile.TemporaryDirectory() as tmp_path: + output = Path(tmp_path) / "output" + output.mkdir() + (output / "file.txt").write_text("x") + + with patch("subprocess.run") as mock_run: + mock_run.return_value = Mock(returncode=0, stdout="", stderr="") + result = engine.execute( + recipe="my-recipe", + version="v1.0.0", + parameters={"name": "test"}, + output_dir=output, + image_url="ghcr.io/test:v1", + ) + + self.assertTrue(result.success) + self.assertEqual(result.recipe_name, "my-recipe") + self.assertEqual(result.recipe_version, "v1.0.0") + self.assertIn(Path("file.txt"), result.files_created) + + def test_subprocess_failure_returns_error(self): """Subprocess failure returns RecipeResult with success=False.""" engine = DockerEngine() - with patch("subprocess.run", side_effect=Exception("docker not found")): - result = engine.execute( - recipe="r", - version="v1", - parameters={}, - output_dir=tmp_path, - image_url="img:latest", - ) - - assert not result.success - assert any("docker not found" in e for e in result.errors) - - def test_command_structure(self, tmp_path): + with tempfile.TemporaryDirectory() as tmp_path: + with patch("subprocess.run", side_effect=Exception("docker not found")): + result = engine.execute( + recipe="r", + version="v1", + parameters={}, + output_dir=Path(tmp_path), + image_url="img:latest", + ) + + self.assertFalse(result.success) + self.assertTrue(any("docker not found" in e for e in result.errors)) + + def test_command_structure(self): """Docker run command has correct structure.""" engine = DockerEngine() - output = tmp_path / "output" - output.mkdir() - - with patch("subprocess.run") as mock_run: - mock_run.return_value = Mock(returncode=0, stdout="", stderr="") - engine.execute( - recipe="r", - version="v1", - parameters={}, - output_dir=output, - image_url="img:v1", - ) - - # pull call, then run call (+ optional chown on Linux) - assert mock_run.call_count >= 2 - pull_cmd = mock_run.call_args_list[0][0][0] - assert pull_cmd == ["docker", "pull", "img:v1"] - - run_cmd = mock_run.call_args_list[1][0][0] - assert run_cmd[0:2] == ["docker", "run"] - assert "--rm" in run_cmd - assert "img:v1" in run_cmd - - -class TestLocalEngine: + with tempfile.TemporaryDirectory() as tmp_path: + output = Path(tmp_path) / "output" + output.mkdir() + + with patch("subprocess.run") as mock_run: + mock_run.return_value = Mock(returncode=0, stdout="", stderr="") + engine.execute( + recipe="r", + version="v1", + parameters={}, + output_dir=output, + image_url="img:v1", + ) + + # pull call, then run call (+ optional chown on Linux) + self.assertGreaterEqual(mock_run.call_count, 2) + pull_cmd = mock_run.call_args_list[0][0][0] + self.assertEqual(pull_cmd, ["docker", "pull", "img:v1"]) + + run_cmd = mock_run.call_args_list[1][0][0] + self.assertEqual(run_cmd[0:2], ["docker", "run"]) + self.assertIn("--rm", run_cmd) + self.assertIn("img:v1", run_cmd) + + +class TestLocalEngine(unittest.TestCase): """Test LocalEngine directly.""" - def test_requires_entrypoint(self, tmp_path): + def test_requires_entrypoint(self): """Execute raises ValueError without entrypoint.""" engine = LocalEngine() - with pytest.raises(ValueError, match="entrypoint"): - engine.execute( - recipe="r", - version="v1", - parameters={}, - output_dir=tmp_path, - entrypoint=None, - ) - - def test_success_returns_result(self, tmp_path): + with tempfile.TemporaryDirectory() as tmp_path: + with self.assertRaisesRegex(ValueError, "entrypoint"): + engine.execute( + recipe="r", + version="v1", + parameters={}, + output_dir=Path(tmp_path), + entrypoint=None, + ) + + def test_success_returns_result(self): """Successful execution returns RecipeResult with files.""" engine = LocalEngine() - output = tmp_path / "output" - - with patch("nskit.client.engines.local.Recipe") as MockRecipe: - mock_instance = Mock() - mock_instance.create.return_value = {"README.md": "content", "setup.py": "content"} - MockRecipe.load.return_value = mock_instance - - result = engine.execute( - recipe="my-recipe", - version="v1.0.0", - parameters={"name": "test"}, - output_dir=output, - entrypoint="test.recipes", - ) - - assert result.success - assert result.recipe_name == "my-recipe" - assert set(result.files_created) == {Path("README.md"), Path("setup.py")} - MockRecipe.load.assert_called_once_with("my-recipe", entrypoint="test.recipes", name="test") - - def test_recipe_load_failure_returns_error(self, tmp_path): + with tempfile.TemporaryDirectory() as tmp_path: + output = Path(tmp_path) / "output" + + with patch("nskit.client.engines.local.Recipe") as MockRecipe: + mock_instance = Mock() + mock_instance.create.return_value = {"README.md": "content", "setup.py": "content"} + MockRecipe.load.return_value = mock_instance + + result = engine.execute( + recipe="my-recipe", + version="v1.0.0", + parameters={"name": "test"}, + output_dir=output, + entrypoint="test.recipes", + ) + + self.assertTrue(result.success) + self.assertEqual(result.recipe_name, "my-recipe") + self.assertEqual(set(result.files_created), {Path("README.md"), Path("setup.py")}) + MockRecipe.load.assert_called_once_with("my-recipe", entrypoint="test.recipes", name="test") + + def test_recipe_load_failure_returns_error(self): """Recipe load failure returns RecipeResult with success=False.""" engine = LocalEngine() - - with patch("nskit.client.engines.local.Recipe") as MockRecipe: - MockRecipe.load.side_effect = ModuleNotFoundError("No module named 'fake'") - - result = engine.execute( - recipe="r", - version="v1", - parameters={}, - output_dir=tmp_path, - entrypoint="fake.recipes", - ) - - assert not result.success - assert any("fake" in e for e in result.errors) - - def test_recipe_create_failure_returns_error(self, tmp_path): + with tempfile.TemporaryDirectory() as tmp_path: + with patch("nskit.client.engines.local.Recipe") as MockRecipe: + MockRecipe.load.side_effect = ModuleNotFoundError("No module named 'fake'") + + result = engine.execute( + recipe="r", + version="v1", + parameters={}, + output_dir=Path(tmp_path), + entrypoint="fake.recipes", + ) + + self.assertFalse(result.success) + self.assertTrue(any("fake" in e for e in result.errors)) + + def test_recipe_create_failure_returns_error(self): """Recipe create failure returns RecipeResult with success=False.""" engine = LocalEngine() - - with patch("nskit.client.engines.local.Recipe") as MockRecipe: - mock_instance = Mock() - mock_instance.create.side_effect = RuntimeError("disk full") - MockRecipe.load.return_value = mock_instance - - result = engine.execute( - recipe="r", - version="v1", - parameters={}, - output_dir=tmp_path, - entrypoint="test.recipes", - ) - - assert not result.success - assert any("disk full" in e for e in result.errors) + with tempfile.TemporaryDirectory() as tmp_path: + with patch("nskit.client.engines.local.Recipe") as MockRecipe: + mock_instance = Mock() + mock_instance.create.side_effect = RuntimeError("disk full") + MockRecipe.load.return_value = mock_instance + + result = engine.execute( + recipe="r", + version="v1", + parameters={}, + output_dir=Path(tmp_path), + entrypoint="test.recipes", + ) + + self.assertFalse(result.success) + self.assertTrue(any("disk full" in e for e in result.errors)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_mixer/test_testing.py b/tests/unit/test_mixer/test_testing.py index 6c26bf4..554b4ff 100644 --- a/tests/unit/test_mixer/test_testing.py +++ b/tests/unit/test_mixer/test_testing.py @@ -1,5 +1,7 @@ """Tests for the reusable recipe test harness (nskit.mixer.testing).""" +import unittest + from nskit.mixer.testing import check_recipe, check_recipes, list_recipes _REPO = { @@ -10,44 +12,47 @@ } -def test_list_recipes_includes_builtins(): - names = list_recipes() - assert "python_package" in names - assert names == sorted(names) - +class TestMixerTesting(unittest.TestCase): + """Tests for nskit.mixer.testing utilities.""" -def test_check_recipe_passes_for_builtin(): - result = check_recipe("python_package", {"name": "test_package", "repo": _REPO}) - assert result.ok, result.summary() - assert result.file_count > 0 - assert not result.unresolved_resources - assert not result.template_errors - assert not result.duplicate_paths + def test_list_recipes_includes_builtins(self): + names = list_recipes() + self.assertIn("python_package", names) + self.assertEqual(names, sorted(names)) + def test_check_recipe_passes_for_builtin(self): + result = check_recipe("python_package", {"name": "test_package", "repo": _REPO}) + self.assertTrue(result.ok, result.summary()) + self.assertGreater(result.file_count, 0) + self.assertFalse(result.unresolved_resources) + self.assertFalse(result.template_errors) + self.assertFalse(result.duplicate_paths) -def test_check_recipe_reports_construction_error_on_bad_inputs(): - # Missing required ``repo`` -> construction fails, reported not raised. - result = check_recipe("python_package", {"name": "test_package"}) - assert not result.ok - assert result.construction_error is not None + def test_check_recipe_reports_construction_error_on_bad_inputs(self): + # Missing required ``repo`` -> construction fails, reported not raised. + result = check_recipe("python_package", {"name": "test_package"}) + self.assertFalse(result.ok) + self.assertIsNotNone(result.construction_error) + def test_check_recipes_flags_registered_recipe_without_inputs(self): + # Only supply inputs for one of the registered recipes. + results = check_recipes({"python_package": {"name": "test_package", "repo": _REPO}}) + # Every other registered recipe should be reported as missing inputs. + untested = [ + name for name, r in results.items() if r.construction_error and "no sample inputs" in r.construction_error + ] + self.assertEqual(set(untested), set(list_recipes()) - {"python_package"}) -def test_check_recipes_flags_registered_recipe_without_inputs(): - # Only supply inputs for one of the registered recipes. - results = check_recipes({"python_package": {"name": "test_package", "repo": _REPO}}) - # Every other registered recipe should be reported as missing inputs. - untested = [ - name for name, r in results.items() if r.construction_error and "no sample inputs" in r.construction_error - ] - assert set(untested) == set(list_recipes()) - {"python_package"} + def test_check_recipe_accepts_class_and_instance(self): + from nskit.recipes.python.package import PackageRecipe + by_class = check_recipe(PackageRecipe, {"name": "test_package", "repo": _REPO}) + self.assertTrue(by_class.ok, by_class.summary()) -def test_check_recipe_accepts_class_and_instance(): - from nskit.recipes.python.package import PackageRecipe + instance = PackageRecipe(name="test_package", repo=_REPO) + by_instance = check_recipe(instance) + self.assertTrue(by_instance.ok, by_instance.summary()) - by_class = check_recipe(PackageRecipe, {"name": "test_package", "repo": _REPO}) - assert by_class.ok, by_class.summary() - instance = PackageRecipe(name="test_package", repo=_REPO) - by_instance = check_recipe(instance) - assert by_instance.ok, by_instance.summary() +if __name__ == "__main__": + unittest.main()