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