Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!(test): add a pytest plugin #653

Merged
merged 8 commits into from
Feb 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions craft_application/pytest_plugin.py
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
"""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")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you scope to to session, wouldn't any use of production_mode override it as it will be run only once?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

production_mode uses monkeypatch.setenv, which cleans up after itself during teardown. Since production_mode is function-scoped, it returns the CRAFT_DEBUG environment variable to its initial state on completion of the test function.

If we imagine the fixtures as context managers, pytest is doing roughly the following:

with debug_mode():
    test_that_doesnt_use_production_mode()
    with production_mode():
        production_mode_test()
    another_debug_mode_test()

This is also why we're setting os.environ directly here, as monkeypatch is function-scoped.

If someone modified os.environ["CRAFT_DEBUG"] directly in one of their own tests or fixtures, it would break this behaviour, but it would also break plenty of other assumptions pytest makes.

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()
Comment on lines +110 to +112
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure about clearing it in the teardown part. Is it required?

Copy link
Contributor Author

@lengau lengau Feb 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Imagine these two tests:

def test_with_fake_arch(fake_host_architecture):
    assert util.get_host_architecture() == fake_host_architecture

def test_with_real_arch():
    assert util.get_host_architecture() == subprocess.run(
        ["dpkg", "--print-architecture"], text=True, capture_output=True
    ).stdout.strip()

Running both tests in definition order will cause test_with_real_arch to fail in most cases (it will succeed if the last run of test_with_fake_arch happened to be the real architecture), but running just test_with_real_arch will always succeed.

Don't you love side-effects? 🙃



@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
1 change: 1 addition & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@

extensions = [
"canonical_sphinx",
"sphinx.ext.autodoc",
]
# endregion

Expand Down
20 changes: 19 additions & 1 deletion docs/reference/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
-------------------

Expand All @@ -23,7 +40,7 @@ Application
===========

- Add a feature to allow `Python plugins
https://packaging.python.org/en/latest/guides/creating-and-discovering-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.
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions docs/reference/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Reference
changelog
environment-variables
platforms
pytest-plugin

Indices and tables
==================
Expand Down
38 changes: 38 additions & 0 deletions docs/reference/pytest-plugin.rst
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
11 changes: 0 additions & 11 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,6 @@
from collections.abc import Iterator


@pytest.fixture(autouse=True)
def debug_mode(monkeypatch):
monkeypatch.setenv("CRAFT_DEBUG", "1")


@pytest.fixture
def production_mode(monkeypatch, debug_mode):
# This uses debug_mode to ensure that we run after it.
monkeypatch.delenv("CRAFT_DEBUG")


def _create_fake_build_plan(num_infos: int = 1) -> list[models.BuildInfo]:
"""Create a build plan that is able to execute on the running system."""
arch = util.get_host_architecture()
Expand Down
5 changes: 3 additions & 2 deletions tests/integration/test_application.py
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,7 @@ def test_lifecycle_error_logging(monkeypatch, tmp_path, create_app):
assert parts_message in log_contents


@pytest.mark.usefixtures("pretend_jammy", "emitter", "production_mode")
@pytest.mark.usefixtures("pretend_jammy", "emitter")
def test_runtime_error_logging(monkeypatch, tmp_path, create_app, mocker):
monkeypatch.chdir(tmp_path)
shutil.copytree(INVALID_PROJECTS_DIR / "build-error", tmp_path, dirs_exist_ok=True)
Expand All @@ -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()

Expand Down
21 changes: 6 additions & 15 deletions tests/unit/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """
Expand All @@ -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
Expand Down Expand Up @@ -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
Loading
Loading