diff --git a/craft_application/pytest_plugin.py b/craft_application/pytest_plugin.py new file mode 100644 index 00000000..9ba4f0f2 --- /dev/null +++ b/craft_application/pytest_plugin.py @@ -0,0 +1,145 @@ +# Copyright 2025 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License version 3, as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, +# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License along +# with this program. If not, see . +"""A pytest plugin for assisting in testing apps that use craft-application.""" + +from __future__ import annotations + +import os +import pathlib +import platform +from collections.abc import Iterator +from typing import TYPE_CHECKING + +import craft_platforms +import pytest + +from craft_application import util +from craft_application.util import platforms + +if TYPE_CHECKING: + from pyfakefs.fake_filesystem import FakeFilesystem + + +@pytest.fixture(autouse=True, scope="session") +def debug_mode() -> None: + """Ensure that the application is in debug mode, raising exceptions from run(). + + This fixture is automatically used. To disable debug mode for specific tests that + require it, use the :py:func:`production_mode` fixture. + """ + os.environ["CRAFT_DEBUG"] = "1" + + +@pytest.fixture +def production_mode(monkeypatch: pytest.MonkeyPatch) -> None: + """Put the application into production mode. + + This fixture puts the application into production mode rather than debug mode. + It should only be used if the application needs to test behaviour that differs + between debug mode and production mode. + """ + monkeypatch.setenv("CRAFT_DEBUG", "0") + + +@pytest.fixture +def managed_mode(monkeypatch: pytest.MonkeyPatch) -> None: + """Tell the application it's running in managed mode. + + This fixture sets up the application's environment so that it appears to be using + managed mode. Useful for testing behaviours that only occur in managed mode. + """ + if os.getenv("CRAFT_BUILD_ENVIRONMENT") == "host": + raise LookupError("Managed mode and destructive mode are mutually exclusive.") + monkeypatch.setenv(platforms.ENVIRONMENT_CRAFT_MANAGED_MODE, "1") + + +@pytest.fixture +def destructive_mode(monkeypatch: pytest.MonkeyPatch) -> None: + """Tell the application it's running in destructive mode. + + This fixture sets up the application's environment so that it appears to be running + in destructive mode with the "CRAFT_BUILD_ENVIRONMENT" environment variable set. + """ + if os.getenv(platforms.ENVIRONMENT_CRAFT_MANAGED_MODE): + raise LookupError("Destructive mode and managed mode are mutually exclusive.") + monkeypatch.setenv("CRAFT_BUILD_ENVIRONMENT", "host") + + +def _optional_pyfakefs(request: pytest.FixtureRequest) -> FakeFilesystem | None: + """Get pyfakefs if it's in use by the fixture request.""" + if {"fs", "fs_class", "fs_module", "fs_session"} & set(request.fixturenames): + try: + from pyfakefs.fake_filesystem import FakeFilesystem + + fs = request.getfixturevalue("fs") + if isinstance(fs, FakeFilesystem): + return fs + except ImportError: + # pyfakefs isn't installed,so this fixture means something else. + pass + return None + + +@pytest.fixture(params=craft_platforms.DebianArchitecture) +def fake_host_architecture( + request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch +) -> Iterator[craft_platforms.DebianArchitecture]: + """Run this test as though running on each supported architecture. + + This parametrized fixture provides architecture values for all supported + architectures, simulating as though the application is running on that architecture. + This fixture is limited to setting the architecture within this python process. + """ + arch: craft_platforms.DebianArchitecture = request.param + platform_arch = arch.to_platform_arch() + real_uname = platform.uname() + monkeypatch.setattr( + "platform.uname", lambda: real_uname._replace(machine=platform_arch) + ) + util.get_host_architecture.cache_clear() + yield arch + util.get_host_architecture.cache_clear() + + +@pytest.fixture +def project_path(request: pytest.FixtureRequest) -> pathlib.Path: + """Get a temporary path for a project. + + This fixture creates a temporary path for a project. It does not create any files + in the project directory, but rather provides a pristine project directory without + the need to worry about other fixtures loading things. + + This fixture can be used with or without pyfakefs. + """ + if fs := _optional_pyfakefs(request): + project_path = pathlib.Path("/test/project") + fs.create_dir(project_path) # type: ignore[reportUnknownMemberType] + return project_path + tmp_path: pathlib.Path = request.getfixturevalue("tmp_path") + path = tmp_path / "project" + path.mkdir() + return path + + +@pytest.fixture +def in_project_path( + project_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> pathlib.Path: + """Run the test inside the project path. + + Changes the working directory of the test to use the project path. + Best to use with ``pytest.mark.usefixtures`` + """ + monkeypatch.chdir(project_path) + return project_path diff --git a/docs/conf.py b/docs/conf.py index 527df7a9..aac870ac 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -39,6 +39,7 @@ extensions = [ "canonical_sphinx", + "sphinx.ext.autodoc", ] # endregion diff --git a/docs/reference/changelog.rst b/docs/reference/changelog.rst index fc026206..be2648b8 100644 --- a/docs/reference/changelog.rst +++ b/docs/reference/changelog.rst @@ -4,6 +4,23 @@ Changelog ********* +5.0.0 (2025-Mon-DD) +------------------- + +Testing +======= + +- A new :doc:`pytest-plugin` with a fixture that enables production mode for the + application if a test requires it. + +Breaking changes +================ + +- The pytest plugin includes an auto-used fixture that puts the app into debug mode + by default for tests. + +For a complete list of commits, check out the `5.0.0`_ release on GitHub. + 4.9.1 (2025-Feb-12) ------------------- @@ -23,7 +40,7 @@ Application =========== - Add a feature to allow `Python plugins - https://packaging.python.org/en/latest/guides/creating-and-discovering-plugins/>`_ + `_ to extend or modify the behaviour of applications that use craft-application as a framework. The plugin packages must be installed in the same virtual environment as the application. @@ -588,3 +605,4 @@ For a complete list of commits, check out the `2.7.0`_ release on GitHub. .. _4.8.3: https://github.com/canonical/craft-application/releases/tag/4.8.3 .. _4.9.0: https://github.com/canonical/craft-application/releases/tag/4.9.0 .. _4.9.1: https://github.com/canonical/craft-application/releases/tag/4.9.1 +.. _5.0.0: https://github.com/canonical/craft-application/releases/tag/5.0.0 diff --git a/docs/reference/index.rst b/docs/reference/index.rst index 1e7796c6..e9fa0028 100644 --- a/docs/reference/index.rst +++ b/docs/reference/index.rst @@ -9,6 +9,7 @@ Reference changelog environment-variables platforms + pytest-plugin Indices and tables ================== diff --git a/docs/reference/pytest-plugin.rst b/docs/reference/pytest-plugin.rst new file mode 100644 index 00000000..8b87cd3d --- /dev/null +++ b/docs/reference/pytest-plugin.rst @@ -0,0 +1,38 @@ +.. py:module:: craft_application.pytest_plugin + +pytest plugin +============= + +craft-application includes a `pytest`_ plugin to help ease the testing of apps that use +it as a framework. + +By default, this plugin sets the application into debug mode, meaning the +:py:meth:`~craft_application.Application.run()` method will re-raise generic exceptions. + + +Fixtures +-------- + +.. autofunction:: production_mode + +.. autofunction:: managed_mode + +.. autofunction:: destructive_mode + +.. autofunction:: fake_host_architecture + +.. autofunction:: project_path + +.. autofunction:: in_project_path + +Auto-used fixtures +~~~~~~~~~~~~~~~~~~ + +Some fixtures are automatically enabled for tests, changing the default behaviour of +applications during the testing process. This is kept to a minimum, but is done when +the standard behaviour could cause subtle testing issues. + +.. autofunction:: debug_mode + + +.. _pytest: https://docs.pytest.org diff --git a/pyproject.toml b/pyproject.toml index b3ee7265..150a2320 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,8 @@ classifiers = [ ] requires-python = ">=3.10" -[project.scripts] +[project.entry-points.pytest11] +craft_application = "craft_application.pytest_plugin" [project.optional-dependencies] remote = [ diff --git a/tests/conftest.py b/tests/conftest.py index b482e878..5bf2ec57 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -103,18 +103,6 @@ def fake_config_model() -> type[FakeConfigModel]: return FakeConfigModel -@pytest.fixture(scope="session") -def default_app_metadata(fake_config_model) -> craft_application.AppMetadata: - with pytest.MonkeyPatch.context() as m: - m.setattr(metadata, "version", lambda _: "3.14159") - return craft_application.AppMetadata( - "testcraft", - "A fake app for testing craft-application", - source_ignore_patterns=["*.snap", "*.charm", "*.starcraft"], - ConfigModel=fake_config_model, - ) - - @pytest.fixture def app_metadata(features, fake_config_model) -> craft_application.AppMetadata: with pytest.MonkeyPatch.context() as m: @@ -124,24 +112,11 @@ def app_metadata(features, fake_config_model) -> craft_application.AppMetadata: "A fake app for testing craft-application", source_ignore_patterns=["*.snap", "*.charm", "*.starcraft"], features=craft_application.AppFeatures(**features), - docs_url="www.testcraft.example/docs/{version}", + docs_url="http://testcraft.example/docs/{version}", ConfigModel=fake_config_model, ) -@pytest.fixture -def app_metadata_docs(features) -> craft_application.AppMetadata: - with pytest.MonkeyPatch.context() as m: - m.setattr(metadata, "version", lambda _: "3.14159") - return craft_application.AppMetadata( - "testcraft", - "A fake app for testing craft-application", - docs_url="http://testcraft.example", - source_ignore_patterns=["*.snap", "*.charm", "*.starcraft"], - features=craft_application.AppFeatures(**features), - ) - - @pytest.fixture def fake_project() -> models.Project: arch = util.get_host_architecture() diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 7ec1b8d8..4ee6785e 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -57,10 +57,10 @@ def provider_service(app_metadata, fake_project, fake_build_plan, fake_services) ) -@pytest.fixture(scope="session") -def anonymous_remote_build_service(default_app_metadata): +@pytest.fixture +def anonymous_remote_build_service(app_metadata): """Provider service with install snap disabled for integration tests""" - service = remotebuild.RemoteBuildService(default_app_metadata, services=mock.Mock()) + service = remotebuild.RemoteBuildService(app_metadata, services=mock.Mock()) service.lp = launchpad.Launchpad.anonymous("testcraft") return service diff --git a/tests/integration/test_application.py b/tests/integration/test_application.py index 6da2950d..061b2c1e 100644 --- a/tests/integration/test_application.py +++ b/tests/integration/test_application.py @@ -84,7 +84,7 @@ def app(create_app): For more information about a command, run 'testcraft help '. For a summary of all commands, run 'testcraft help --all'. -For more information about testcraft, check out: www.testcraft.example/docs/3.14159 +For more information about testcraft, check out: http://testcraft.example/docs/3.14159 """ INVALID_COMMAND = """\ @@ -296,7 +296,7 @@ def test_get_command_help(monkeypatch, emitter, capsys, app, cmd, help_param): assert f"testcraft {cmd} [options]" in stderr assert stderr.endswith( "For more information, check out: " - f"www.testcraft.example/docs/3.14159/reference/commands/{cmd}\n\n" + f"http://testcraft.example/docs/3.14159/reference/commands/{cmd}\n\n" ) @@ -503,7 +503,8 @@ def test_runtime_error_logging(monkeypatch, tmp_path, create_app, mocker): monkeypatch.setattr("sys.argv", ["testcraft", "pack", "--destructive-mode"]) app = create_app() - app.run() + with pytest.raises(RuntimeError): + app.run() log_contents = craft_cli.emit._log_filepath.read_text() diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 5de73f33..316fb822 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -22,7 +22,7 @@ import pytest import pytest_mock -from craft_application import git, services, util +from craft_application import git, services from craft_application.services import service_factory BASIC_PROJECT_YAML = """ @@ -37,12 +37,6 @@ """ -@pytest.fixture(params=["amd64", "arm64", "riscv64"]) -def fake_host_architecture(monkeypatch, request) -> str: - monkeypatch.setattr(util, "get_host_architecture", lambda: request.param) - return request.param - - @pytest.fixture def provider_service( app_metadata, fake_project, fake_build_plan, fake_services, tmp_path @@ -110,11 +104,8 @@ def expected_git_command( @pytest.fixture -def fake_project_file(monkeypatch, tmp_path): - project_dir = tmp_path / "project" - project_dir.mkdir() - project_path = project_dir / "testcraft.yaml" - project_path.write_text(BASIC_PROJECT_YAML) - monkeypatch.chdir(project_dir) - - return project_path +def fake_project_file(in_project_path): + project_file = in_project_path / "testcraft.yaml" + project_file.write_text(BASIC_PROJECT_YAML) + + return project_file diff --git a/tests/unit/services/test_config.py b/tests/unit/services/test_config.py index c181b012..4b8ef1b8 100644 --- a/tests/unit/services/test_config.py +++ b/tests/unit/services/test_config.py @@ -27,7 +27,7 @@ import pytest import pytest_subprocess import snaphelpers -from hypothesis import given, strategies +from hypothesis import HealthCheck, given, settings, strategies import craft_application from craft_application import launchpad @@ -64,18 +64,18 @@ TEST_ENTRY_VALUES = CRAFT_APPLICATION_TEST_ENTRY_VALUES + APP_SPECIFIC_TEST_ENTRY_VALUES -@pytest.fixture(scope="module") -def app_environment_handler(default_app_metadata) -> config.AppEnvironmentHandler: - return config.AppEnvironmentHandler(default_app_metadata) +@pytest.fixture +def app_environment_handler(app_metadata) -> config.AppEnvironmentHandler: + return config.AppEnvironmentHandler(app_metadata) -@pytest.fixture(scope="module") -def craft_environment_handler(default_app_metadata) -> config.CraftEnvironmentHandler: - return config.CraftEnvironmentHandler(default_app_metadata) +@pytest.fixture +def craft_environment_handler(app_metadata) -> config.CraftEnvironmentHandler: + return config.CraftEnvironmentHandler(app_metadata) -@pytest.fixture(scope="module") -def snap_config_handler(default_app_metadata) -> Iterator[config.SnapConfigHandler]: +@pytest.fixture +def snap_config_handler(app_metadata) -> Iterator[config.SnapConfigHandler]: with pytest.MonkeyPatch.context() as monkeypatch: monkeypatch.setenv("SNAP", "/snap/testcraft/x1") monkeypatch.setenv("SNAP_COMMON", "/") @@ -85,12 +85,12 @@ def snap_config_handler(default_app_metadata) -> Iterator[config.SnapConfigHandl monkeypatch.setenv("SNAP_USER_DATA", "/") monkeypatch.setenv("SNAP_INSTANCE_NAME", "testcraft") monkeypatch.setenv("SNAP_INSTANCE_KEY", "") - yield config.SnapConfigHandler(default_app_metadata) + yield config.SnapConfigHandler(app_metadata) -@pytest.fixture(scope="module") -def default_config_handler(default_app_metadata) -> config.DefaultConfigHandler: - return config.DefaultConfigHandler(default_app_metadata) +@pytest.fixture +def default_config_handler(app_metadata) -> config.DefaultConfigHandler: + return config.DefaultConfigHandler(app_metadata) @given( @@ -99,6 +99,7 @@ def default_config_handler(default_app_metadata) -> config.DefaultConfigHandler: alphabet=strategies.characters(categories=["L", "M", "N", "P", "S", "Z"]) ), ) +@settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) def test_app_environment_handler(app_environment_handler, item: str, content: str): with pytest.MonkeyPatch.context() as monkeypatch: monkeypatch.setenv(f"TESTCRAFT_{item.upper()}", content) @@ -112,6 +113,7 @@ def test_app_environment_handler(app_environment_handler, item: str, content: st alphabet=strategies.characters(categories=["L", "M", "N", "P", "S", "Z"]) ), ) +@settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) def test_craft_environment_handler(craft_environment_handler, item: str, content: str): with pytest.MonkeyPatch.context() as monkeypatch: monkeypatch.setenv(f"CRAFT_{item.upper()}", content) @@ -149,23 +151,23 @@ def test_craft_environment_handler_error( ), ], ) -def test_snap_config_handler_create_error(mocker, default_app_metadata, error): +def test_snap_config_handler_create_error(mocker, app_metadata, error): mocker.patch("snaphelpers.is_snap", return_value=True) mock_snap_config = mocker.patch( "snaphelpers.SnapConfig", side_effect=error, ) with pytest.raises(OSError, match="Not running as a snap."): - config.SnapConfigHandler(default_app_metadata) + config.SnapConfigHandler(app_metadata) mock_snap_config.assert_called_once_with() -def test_snap_config_handler_not_snap(mocker, default_app_metadata): +def test_snap_config_handler_not_snap(mocker, app_metadata): mock_is_snap = mocker.patch("snaphelpers.is_snap", return_value=False) with pytest.raises(OSError, match="Not running as a snap."): - config.SnapConfigHandler(default_app_metadata) + config.SnapConfigHandler(app_metadata) mock_is_snap.asssert_called_once_with() @@ -176,6 +178,7 @@ def test_snap_config_handler_not_snap(mocker, default_app_metadata): alphabet=strategies.characters(categories=["L", "M", "N", "P", "S", "Z"]) ), ) +@settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) def test_snap_config_handler(snap_config_handler, item: str, content: str): snap_item = item.replace("_", "-") with ( diff --git a/tests/unit/test_application.py b/tests/unit/test_application.py index 898f1f75..dbc51d06 100644 --- a/tests/unit/test_application.py +++ b/tests/unit/test_application.py @@ -1048,7 +1048,7 @@ def test_run_error( """\ Failed to run the build script for part 'foo'. Recommended resolution: Check the build output and verify the project can work with the 'python' plugin. - For more information, check out: http://testcraft.example/reference/plugins.html + For more information, check out: http://testcraft.example/docs/3.14159/reference/plugins.html Full execution log:""" ), ), @@ -1058,14 +1058,14 @@ def test_run_error_with_docs_url( monkeypatch, capsys, mock_dispatcher, - app_metadata_docs, + app_metadata, fake_services, fake_project, error, return_code, error_msg, ): - app = FakeApplication(app_metadata_docs, fake_services) + app = FakeApplication(app_metadata, fake_services) app.set_project(fake_project) mock_dispatcher.load_command.side_effect = error mock_dispatcher.pre_parse_args.return_value = {} @@ -1077,7 +1077,7 @@ def test_run_error_with_docs_url( @pytest.mark.parametrize("error", [KeyError(), ValueError(), Exception()]) -@pytest.mark.usefixtures("emitter") +@pytest.mark.usefixtures("emitter", "debug_mode") def test_run_error_debug(monkeypatch, mock_dispatcher, app, fake_project, error): app.set_project(fake_project) mock_dispatcher.load_command.side_effect = error @@ -1335,41 +1335,39 @@ def test_work_dir_project_managed(monkeypatch, app_metadata, fake_services): @pytest.fixture -def environment_project(monkeypatch, tmp_path): - project_dir = tmp_path / "project" - project_dir.mkdir() - project_path = project_dir / "testcraft.yaml" - project_path.write_text( +def environment_project(in_project_path): + project_file = in_project_path / "testcraft.yaml" + project_file.write_text( dedent( + """\ + name: myproject + version: 1.2.3 + base: ubuntu@24.04 + platforms: + amd64: + arm64: + riscv64: + parts: + mypart: + plugin: nil + source-tag: v$CRAFT_PROJECT_VERSION + build-environment: + - BUILD_ON: $CRAFT_ARCH_BUILD_ON + - BUILD_FOR: $CRAFT_ARCH_BUILD_FOR """ - name: myproject - version: 1.2.3 - base: ubuntu@24.04 - platforms: - arm64: - parts: - mypart: - plugin: nil - source-tag: v$CRAFT_PROJECT_VERSION - build-environment: - - BUILD_ON: $CRAFT_ARCH_BUILD_ON - - BUILD_FOR: $CRAFT_ARCH_BUILD_FOR - """ ) ) - monkeypatch.chdir(project_dir) - return project_path + return in_project_path +@pytest.mark.usefixtures("in_project_path", "fake_host_architecture") def test_expand_environment_build_for_all( - monkeypatch, app_metadata, tmp_path, fake_services, emitter + monkeypatch, app_metadata, project_path, fake_services, emitter ): """Expand build-for to the host arch when build-for is 'all'.""" - project_dir = tmp_path / "project" - project_dir.mkdir() - project_path = project_dir / "testcraft.yaml" - project_path.write_text( + project_file = project_path / "testcraft.yaml" + project_file.write_text( dedent( f"""\ name: myproject @@ -1388,7 +1386,6 @@ def test_expand_environment_build_for_all( """ ) ) - monkeypatch.chdir(project_dir) app = application.Application(app_metadata, fake_services) project = app.get_project() @@ -1406,7 +1403,7 @@ def test_expand_environment_build_for_all( ) -@pytest.mark.usefixtures("environment_project") +@pytest.mark.usefixtures("environment_project", "fake_host_architecture") def test_application_expand_environment(app_metadata, fake_services): app = application.Application(app_metadata, fake_services) project = app.get_project(build_for=get_host_architecture()) @@ -1421,30 +1418,27 @@ def test_application_expand_environment(app_metadata, fake_services): @pytest.fixture -def build_secrets_project(monkeypatch, tmp_path): - project_dir = tmp_path / "project" - project_dir.mkdir() - project_path = project_dir / "testcraft.yaml" - project_path.write_text( +def build_secrets_project(in_project_path): + project_file = in_project_path / "testcraft.yaml" + project_file.write_text( dedent( """ - name: myproject - version: 1.2.3 - base: ubuntu@24.04 - platforms: - arm64: - parts: - mypart: - plugin: nil - source: $(HOST_SECRET:echo ${SECRET_VAR_1})/project - build-environment: - - MY_VAR: $(HOST_SECRET:echo ${SECRET_VAR_2}) - """ + name: myproject + version: 1.2.3 + base: ubuntu@24.04 + platforms: + arm64: + parts: + mypart: + plugin: nil + source: $(HOST_SECRET:echo ${SECRET_VAR_1})/project + build-environment: + - MY_VAR: $(HOST_SECRET:echo ${SECRET_VAR_2}) + """ ) ) - monkeypatch.chdir(project_dir) - return project_path + return in_project_path @pytest.mark.usefixtures("build_secrets_project") @@ -1652,14 +1646,13 @@ def grammar_project_full(tmp_path): @pytest.fixture -def non_grammar_build_plan(mocker): +def non_grammar_build_plan(mocker, fake_host_architecture): """A build plan to build on amd64 to riscv64.""" - host_arch = "amd64" base = util.get_host_base() build_plan = [ models.BuildInfo( "platform-riscv64", - host_arch, + fake_host_architecture, "riscv64", base, ) @@ -2150,9 +2143,9 @@ def test_build_planner_errors(tmp_path, monkeypatch, fake_services): def test_emitter_docs_url(monkeypatch, mocker, app): """Test that the emitter is initialized with the correct url.""" - assert app.app.docs_url == "www.testcraft.example/docs/{version}" + assert app.app.docs_url == "http://testcraft.example/docs/{version}" assert app.app.version == "3.14159" - expected_url = "www.testcraft.example/docs/3.14159" + expected_url = "http://testcraft.example/docs/3.14159" spied_init = mocker.spy(emit, "init") @@ -2243,7 +2236,7 @@ def test_doc_url_in_general_help(help_args, monkeypatch, capsys, app): with pytest.raises(SystemExit): app.run() - expected = "For more information about testcraft, check out: www.testcraft.example/docs/3.14159\n\n" + expected = "For more information about testcraft, check out: http://testcraft.example/docs/3.14159\n\n" _, err = capsys.readouterr() assert err.endswith(expected) @@ -2257,6 +2250,6 @@ def test_doc_url_in_command_help(monkeypatch, capsys, app): with pytest.raises(SystemExit): app.run() - expected = "For more information, check out: www.testcraft.example/docs/3.14159/reference/commands/app-config\n\n" + expected = "For more information, check out: http://testcraft.example/docs/3.14159/reference/commands/app-config\n\n" _, err = capsys.readouterr() assert err.endswith(expected) diff --git a/tests/unit/test_pytest_plugin.py b/tests/unit/test_pytest_plugin.py new file mode 100644 index 00000000..071f0910 --- /dev/null +++ b/tests/unit/test_pytest_plugin.py @@ -0,0 +1,92 @@ +# Copyright 2025 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License version 3, as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, +# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License along +# with this program. If not, see . +"""Simple tests for the pytest plugin.""" + +import os +import pathlib +import platform +from unittest import mock + +import craft_platforms +import pytest +import pytest_check +from pyfakefs.fake_filesystem import FakeFilesystem + +from craft_application import services, util + + +def test_sets_debug_mode_auto_used(app_metadata): + assert os.getenv("CRAFT_DEBUG") == "1" + + config_service = services.ConfigService(app=app_metadata, services=mock.Mock()) + config_service.setup() + assert config_service.get("debug") is True + + +@pytest.mark.usefixtures("production_mode") +def test_production_mode_sets_production_mode(app_metadata): + assert os.getenv("CRAFT_DEBUG") == "0" + + config_service = services.ConfigService(app=app_metadata, services=mock.Mock()) + config_service.setup() + assert config_service.get("debug") is False + + +@pytest.mark.usefixtures("managed_mode") +def test_managed_mode(): + assert services.ProviderService.is_managed() is True + + +@pytest.mark.usefixtures("destructive_mode") +def test_destructive_mode(): + assert services.ProviderService.is_managed() is False + assert os.getenv("CRAFT_BUILD_ENVIRONMENT") == "host" + + +@pytest.mark.xfail( + reason="Setup of this test should fail because the two fixtures cannot be used together.", + raises=LookupError, + strict=True, +) +@pytest.mark.usefixtures("managed_mode", "destructive_mode") +def test_managed_and_destructive_mode_mutually_exclusive(): + pass + + +def test_host_architecture(fake_host_architecture: craft_platforms.DebianArchitecture): + platform_arch = fake_host_architecture.to_platform_arch() + pytest_check.equal(platform_arch, platform.uname().machine) + pytest_check.equal(platform_arch, platform.machine()) + pytest_check.equal(fake_host_architecture.value, util.get_host_architecture()) + pytest_check.equal( + fake_host_architecture, craft_platforms.DebianArchitecture.from_host() + ) + + +def test_project_path_created(project_path, tmp_path): + assert project_path.is_dir() + # Check that it's not the hardcoded fake path for pyfakefs. + assert project_path != pathlib.Path("/test/project") + assert project_path == tmp_path / "project" + + +def test_project_path_created_with_pyfakefs(fs: FakeFilesystem, project_path): + assert fs.exists(project_path) + assert project_path.is_dir() + # Check that it's the hardcoded fake path for pyfakefs. + assert project_path == pathlib.Path("/test/project") + + +def test_in_project_path(in_project_path): + assert pathlib.Path.cwd() == in_project_path