|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2022 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing the executor base class for the various testing frameworks |
|
8 and supporting classes. |
|
9 """ |
|
10 |
|
11 import os |
|
12 from dataclasses import dataclass |
|
13 from enum import IntEnum |
|
14 |
|
15 from PyQt6.QtCore import pyqtSignal, QObject, QProcess, QProcessEnvironment |
|
16 |
|
17 import Preferences |
|
18 |
|
19 |
|
20 class ResultCategory(IntEnum): |
|
21 """ |
|
22 Class defining the supported result categories. |
|
23 """ |
|
24 FAIL = 1 |
|
25 OK = 2 |
|
26 SKIP = 3 |
|
27 PENDING = 4 |
|
28 |
|
29 |
|
30 @dataclass |
|
31 class UTTestResult: |
|
32 """ |
|
33 Class containing the test result data. |
|
34 """ |
|
35 category: int # result category |
|
36 status: str # test status |
|
37 name: str # test name |
|
38 message: str # short result message |
|
39 extra: str # additional information text |
|
40 duration: float # test duration |
|
41 filename: str # file name of a failed test |
|
42 lineno: int # line number of a failed test |
|
43 |
|
44 |
|
45 @dataclass |
|
46 class UTTestConfig: |
|
47 """ |
|
48 Class containing the test run configuration. |
|
49 """ |
|
50 interpreter: str # path of the Python interpreter |
|
51 discover: bool # auto discovery flag |
|
52 discoveryStart: str # start directory for auto discovery |
|
53 testFilename: str # name of the test script |
|
54 testName: str # name of the test function |
|
55 failFast: bool # stop on first fail |
|
56 collectCoverage: bool # coverage collection flag |
|
57 eraseCoverage: bool # erase coverage data first |
|
58 |
|
59 |
|
60 class UTExecutorBase(QObject): |
|
61 """ |
|
62 Base class for test framework specific implementations. |
|
63 |
|
64 @signal collected(list of str) emitted after all tests have been |
|
65 collected |
|
66 @signal collectError(list of tuple of (str, str)) emitted when errors |
|
67 are encountered during test collection. Tuple elements are the |
|
68 test name and the error message. |
|
69 @signal startTest(list of str) emitted before tests are run |
|
70 @signal testResult(UTTestResult) emitted when a test result is ready |
|
71 @signal testFinished(list, str) emitted when the test has finished. |
|
72 The elements are the list of test results and the captured output |
|
73 of the test worker (if any). |
|
74 @signal stop() emitted when the test process is being stopped. |
|
75 """ |
|
76 collected = pyqtSignal(list) |
|
77 collectError = pyqtSignal(list) |
|
78 startTest = pyqtSignal(list) |
|
79 testResult = pyqtSignal(UTTestResult) |
|
80 testFinished = pyqtSignal(list, str) |
|
81 stop = pyqtSignal() |
|
82 |
|
83 module = "" |
|
84 name = "" |
|
85 runner = "" |
|
86 |
|
87 def __init__(self, testWidget, logfile=None): |
|
88 """ |
|
89 Constructor |
|
90 |
|
91 @param testWidget reference to the unit test widget |
|
92 @type UnittestWidget |
|
93 @param logfile file name to log test results to (defaults to None) |
|
94 @type str (optional) |
|
95 """ |
|
96 super().__init__(testWidget) |
|
97 |
|
98 self.__process = None |
|
99 self._logfile = logfile |
|
100 # TODO: add log file creation |
|
101 |
|
102 @classmethod |
|
103 def isInstalled(cls, interpreter): |
|
104 """ |
|
105 Class method to check whether a test framework is installed. |
|
106 |
|
107 The test is performed by checking, if a module loader can found. |
|
108 |
|
109 @param interpreter interpreter to be used for the test |
|
110 @type str |
|
111 @return flag indicating the test framework module is installed |
|
112 @rtype bool |
|
113 """ |
|
114 if cls.runner: |
|
115 proc = QProcess() |
|
116 proc.start(interpreter, [cls.runner, "installed"]) |
|
117 if proc.waitForFinished(3000): |
|
118 exitCode = proc.exitCode() |
|
119 return exitCode == 0 |
|
120 |
|
121 return False |
|
122 |
|
123 def getVersions(self, interpreter): |
|
124 """ |
|
125 Public method to get the test framework version and version information |
|
126 of its installed plugins. |
|
127 |
|
128 @param interpreter interpreter to be used for the test |
|
129 @type str |
|
130 @return dictionary containing the framework name and version and the |
|
131 list of available plugins with name and version each |
|
132 @rtype dict |
|
133 @exception NotImplementedError this method needs to be implemented by |
|
134 derived classes |
|
135 """ |
|
136 raise NotImplementedError |
|
137 |
|
138 return {} |
|
139 |
|
140 def createArguments(self, config): |
|
141 """ |
|
142 Public method to create the arguments needed to start the test process. |
|
143 |
|
144 @param config configuration for the test execution |
|
145 @type UTTestConfig |
|
146 @return list of process arguments |
|
147 @rtype list of str |
|
148 @exception NotImplementedError this method needs to be implemented by |
|
149 derived classes |
|
150 """ |
|
151 raise NotImplementedError |
|
152 |
|
153 return [] |
|
154 |
|
155 def _prepareProcess(self, workDir, pythonpath): |
|
156 """ |
|
157 Protected method to prepare a process object to be started. |
|
158 |
|
159 @param workDir working directory |
|
160 @type str |
|
161 @param pythonpath list of directories to be added to the Python path |
|
162 @type list of str |
|
163 @return prepared process object |
|
164 @rtype QProcess |
|
165 """ |
|
166 process = QProcess(self) |
|
167 process.setProcessChannelMode( |
|
168 QProcess.ProcessChannelMode.MergedChannels) |
|
169 process.setWorkingDirectory(workDir) |
|
170 process.finished.connect(self.finished) |
|
171 if pythonpath: |
|
172 env = QProcessEnvironment.systemEnvironment() |
|
173 currentPythonPath = env.value('PYTHONPATH', None) |
|
174 newPythonPath = os.pathsep.join(pythonpath) |
|
175 if currentPythonPath: |
|
176 newPythonPath += os.pathsep + currentPythonPath |
|
177 env.insert('PYTHONPATH', newPythonPath) |
|
178 process.setProcessEnvironment(env) |
|
179 |
|
180 return process |
|
181 |
|
182 def start(self, config, pythonpath): |
|
183 """ |
|
184 Public method to start the testing process. |
|
185 |
|
186 @param config configuration for the test execution |
|
187 @type UTTestConfig |
|
188 @param pythonpath list of directories to be added to the Python path |
|
189 @type list of str |
|
190 @exception RuntimeError raised if the the testing process did not start |
|
191 """ |
|
192 workDir = ( |
|
193 config.discoveryStart |
|
194 if config.discover else |
|
195 os.path.dirname(config.testFilename) |
|
196 ) |
|
197 self.__process = self._prepareProcess(workDir, pythonpath) |
|
198 testArgs = self.createArguments(config) |
|
199 self.__process.start(config.interpreter, testArgs) |
|
200 running = self.__process.waitForStarted() |
|
201 if not running: |
|
202 raise RuntimeError |
|
203 |
|
204 def finished(self): |
|
205 """ |
|
206 Public method handling the unit test process been finished. |
|
207 |
|
208 This method should read the results (if necessary) and emit the signal |
|
209 testFinished. |
|
210 |
|
211 @exception NotImplementedError this method needs to be implemented by |
|
212 derived classes |
|
213 """ |
|
214 raise NotImplementedError |
|
215 |
|
216 def readAllOutput(self, process=None): |
|
217 """ |
|
218 Public method to read all output of the test process. |
|
219 |
|
220 @param process reference to the process object |
|
221 @type QProcess |
|
222 @return test process output |
|
223 @rtype str |
|
224 """ |
|
225 if process is None: |
|
226 process = self.__process |
|
227 output = ( |
|
228 str(process.readAllStandardOutput(), |
|
229 Preferences.getSystem("IOEncoding"), |
|
230 'replace').strip() |
|
231 if process else |
|
232 "" |
|
233 ) |
|
234 return output |
|
235 |
|
236 def stopIfRunning(self): |
|
237 """ |
|
238 Public method to stop the testing process, if it is running. |
|
239 """ |
|
240 if ( |
|
241 self.__process and |
|
242 self.__process.state() == QProcess.ProcessState.Running |
|
243 ): |
|
244 self.__process.terminate() |
|
245 self.__process.waitForFinished(2000) |
|
246 self.__process.kill() |
|
247 self.__process.waitForFinished(3000) |
|
248 |
|
249 self.stop.emit() |