Coverage for pyTooling/Testing/__init__.py: 100%
39 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-22 21:29 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-22 21:29 +0000
1# ==================================================================================================================== #
2# _____ _ _ _____ _ _ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _|_ _|__ ___| |_(_)_ __ __ _ #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | | |/ _ \/ __| __| | '_ \ / _` | #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_| | __/\__ \ |_| | | | | (_| | #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)_|\___||___/\__|_|_| |_|\__, | #
7# |_| |___/ |___/ |___/ #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2026-2026 Patrick Lehmann - Bötzingen, Germany #
15# #
16# Licensed under the Apache License, Version 2.0 (the "License"); #
17# you may not use this file except in compliance with the License. #
18# You may obtain a copy of the License at #
19# #
20# http://www.apache.org/licenses/LICENSE-2.0 #
21# #
22# Unless required by applicable law or agreed to in writing, software #
23# distributed under the License is distributed on an "AS IS" BASIS, #
24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
25# See the License for the specific language governing permissions and #
26# limitations under the License. #
27# #
28# SPDX-License-Identifier: Apache-2.0 #
29# ==================================================================================================================== #
30#
31"""
32Enhanced classes for writing unit tests with Python's :mod:`unittest` framework, which pytest runs as well.
34The pieces here are the ones every test suite otherwise rewrites. Currently that is application testing: starting
35the program under test the way a user does, because importing it cannot cover the console-script wiring, the
36argument parsing or the exit codes.
38.. hint::
40 See :ref:`high-level help <TESTING>` for explanations and usage examples.
41"""
42from pathlib import Path
43from re import compile as re_compile
44from shutil import which
45from subprocess import CompletedProcess, run as subprocess_run
46from unittest import TestCase
47from sys import executable as PythonExecutable, version_info
48from typing import Any, ClassVar, Optional as Nullable
49from pyTooling.Decorators import export
50from pyTooling.Exceptions import ToolingException
53_ANSI_COLOR_CODES = re_compile(r"\x1B\[[0-9;]*m") #: Pattern matching an ANSI escape sequence selecting a color.
56@export
57class TestingException(ToolingException):
58 """Base-exception of all exceptions raised by :mod:`pyTooling.Testing`."""
61@export
62def stripANSIColorCodes(text: str) -> str:
63 """
64 Remove ANSI color codes from a text, so it can be compared to an expected output.
66 A program writing to a terminal colors its output; the same program in a pipe usually does not, but that depends
67 on the program. Comparing against an expectation is more robust with the codes removed than with a rule about
68 when they appear.
70 :param text: The text to remove the color codes from.
71 :returns: The text without ANSI color codes.
72 """
73 return _ANSI_COLOR_CODES.sub("", text)
76@export
77class Testcase(TestCase):
78 """
79 The base class for pyTooling's testcases, deriving from :class:`unittest.TestCase`.
81 It adds the assertions Python's :mod:`unittest` gained later than the oldest Python version pyTooling supports,
82 so a test suite can use them whichever interpreter runs it:
84 .. code-block:: python
86 class Slots(Testcase):
87 def test_SlotsAreDerived(self) -> None:
88 self.assertHasAttr(MyClass, "__slots__")
90 On Python 3.14 and newer, :class:`unittest.TestCase` implements them and this class defines nothing, so the
91 standard library's implementations and messages are used.
92 """
94 if version_info < (3, 14): # pragma: no cover
95 def assertHasAttr(self, obj: Any, name: str, msg: Nullable[str] = None) -> None:
96 """
97 Assert an object has an attribute of the given name.
99 Available in :class:`unittest.TestCase` from Python 3.14 on.
101 :param obj: The object to check.
102 :param name: Name of the attribute the object is expected to have.
103 :param msg: Optional, message replacing the generated one.
104 """
105 if not hasattr(obj, name):
106 self.fail(msg or f"{type(obj).__name__!r} object has no attribute {name!r}")
108 def assertNotHasAttr(self, obj: Any, name: str, msg: Nullable[str] = None) -> None:
109 """
110 Assert an object has no attribute of the given name.
112 Available in :class:`unittest.TestCase` from Python 3.14 on.
114 :param obj: The object to check.
115 :param name: Name of the attribute the object is expected not to have.
116 :param msg: Optional, message replacing the generated one.
117 """
118 if hasattr(obj, name):
119 self.fail(msg or f"{type(obj).__name__!r} object has unexpected attribute {name!r}")
122@export
123class ApplicationTestcase(Testcase):
124 """
125 The base class for testcases exercising an application through its command line.
127 It resolves the installed console script once per test class, offers two ways to run the program - through the
128 installed entry point and through ``python -m <module>`` - and an assertion that reports the exit code together
129 with what the program printed.
131 Derive from it and name what is being tested:
133 .. code-block:: python
135 class Commands(ApplicationTestcase):
136 _consoleScript = "myprogram"
137 _runnableModule = "myPackage.CLI"
139 def test_Version(self) -> None:
140 result = self.RunEntrypoint("--version")
142 self.assertExitCode(result, 0)
143 self.assertIn("myprogram", result.stdout)
144 """
146 _consoleScript: ClassVar[Nullable[str]] = None #: Name of the installed console script, resolved on ``PATH``.
147 _runnableModule: ClassVar[Nullable[str]] = None #: Dotted name of the module to run with ``python -m``.
148 _executable: ClassVar[Nullable[str]] = None #: The resolved console script, set by :meth:`setUpClass`.
150 @classmethod
151 def setUpClass(cls) -> None:
152 """
153 Check the test class is set up, and resolve the console script on ``PATH``, once per class.
155 :raises TestingException: If the test class named neither a console script nor a runnable module, or if the
156 console script it named is not installed. Every testcase in the class would
157 otherwise fail, each with a less obvious error.
158 """
159 super().setUpClass()
161 if cls._consoleScript is None:
162 raise TestingException(f"Testcase '{cls.__name__}' has no console script. Set '_consoleScript'.")
164 if cls._runnableModule is None:
165 raise TestingException(f"Testcase '{cls.__name__}' has no runnable module. Set '_runnableModule'.")
167 if (resolved := which(cls._consoleScript)) is None:
168 ex = TestingException(f"Console script '{cls._consoleScript}' was not found in PATH.")
169 raise ex from FileNotFoundError(str(cls._consoleScript))
171 cls._executable = resolved
173 def RunEntrypoint(
174 self,
175 *arguments: str,
176 timeout: float = 10.0,
177 stdInput: Nullable[str] = None,
178 environment: Nullable[dict[str, str]] = None,
179 workingDirectory: Nullable[Path] = None
180 ) -> CompletedProcess:
181 """
182 Run the installed console script.
184 This is the path a user takes, so it covers the entry-point wiring as well as the program itself.
186 :param arguments: Command line arguments to pass to the program.
187 :param timeout: Optional, seconds to wait before the program is killed and :exc:`subprocess.TimeoutExpired`
188 is raised. A test should fail rather than hang.
189 :param stdInput: Optional, text to send to the program's standard input.
190 :param environment: Optional, the environment to run in, or ``None`` to inherit this process's environment.
191 :param workingDirectory: Optional, directory to run in, or ``None`` for the current one.
192 :returns: The completed process, with ``stdout`` and ``stderr`` captured as text.
193 """
194 return subprocess_run(
195 [self._executable, *arguments],
196 capture_output=True,
197 text=True,
198 timeout=timeout,
199 input=stdInput,
200 env=environment,
201 cwd=None if workingDirectory is None else str(workingDirectory)
202 )
204 def RunModule(
205 self,
206 *arguments: str,
207 timeout: float = 10.0,
208 stdInput: Nullable[str] = None,
209 environment: Nullable[dict[str, str]] = None,
210 workingDirectory: Nullable[Path] = None
211 ) -> CompletedProcess:
212 """
213 Run the program as ``python -m <module>``, bypassing the console script.
215 Use it to tell a broken entry point apart from a broken program: if this passes while
216 :meth:`RunEntrypoint` fails, the packaging is at fault, not the code.
218 :param arguments: Command line arguments to pass to the program.
219 :param timeout: Optional, seconds to wait before the program is killed.
220 :param stdInput: Optional, text to send to the program's standard input.
221 :param environment: Optional, the environment to run in, or ``None`` to inherit this process's environment.
222 :param workingDirectory: Optional, directory to run in, or ``None`` for the current one.
223 :returns: The completed process, with ``stdout`` and ``stderr`` captured as text.
224 """
225 return subprocess_run(
226 [PythonExecutable, "-m", self._runnableModule, *arguments],
227 capture_output=True,
228 text=True,
229 timeout=timeout,
230 input=stdInput,
231 env=environment,
232 cwd=None if workingDirectory is None else str(workingDirectory)
233 )
235 def assertExitCode(self, result: CompletedProcess, expected: int = 0) -> None:
236 """
237 Check the exit code of a completed process, reporting what the program printed when it doesn't match.
239 The output is what explains the failure, and it is gone once the test has finished, so it goes into the
240 assertion message rather than into the console.
242 :param result: The completed process to check.
243 :param expected: Optional, the expected exit code, zero by default.
244 """
245 self.assertEqual(
246 expected,
247 result.returncode,
248 msg=(
249 f"Expected exit code {expected}, got {result.returncode} from: {result.args!r}\n"
250 f"--- stdout ---\n{result.stdout}\n"
251 f"--- stderr ---\n{result.stderr}"
252 )
253 )