-
Notifications
You must be signed in to change notification settings - Fork 14
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
Changes from all commits
e2816fb
b2f9e61
c0b79da
7d7acf3
bf70fa3
75a6e7d
2d32c31
262dcb8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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") | ||
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -39,6 +39,7 @@ | |
|
||
extensions = [ | ||
"canonical_sphinx", | ||
"sphinx.ext.autodoc", | ||
] | ||
# endregion | ||
|
||
|
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 |
There was a problem hiding this comment.
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?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
production_mode
usesmonkeypatch.setenv
, which cleans up after itself during teardown. Sinceproduction_mode
is function-scoped, it returns theCRAFT_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:
This is also why we're setting
os.environ
directly here, asmonkeypatch
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.