eric7/Testing/Interfaces/TestExecutorBase.py

branch
unittest
changeset 9066
a219ade50f7c
parent 9064
339bb8c8007d
child 9070
eab09a1ab8ce
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/eric7/Testing/Interfaces/TestExecutorBase.py	Mon May 16 19:46:51 2022 +0200
@@ -0,0 +1,262 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2022 Detlev Offenbach <detlev@die-offenbachs.de>
+#
+
+"""
+Module implementing the executor base class for the various testing frameworks
+and supporting classes.
+"""
+
+import os
+from dataclasses import dataclass
+from enum import IntEnum
+
+from PyQt6.QtCore import pyqtSignal, QObject, QProcess, QProcessEnvironment
+
+import Preferences
+
+
+class TestResultCategory(IntEnum):
+    """
+    Class defining the supported result categories.
+    """
+    RUNNING = 0
+    FAIL = 1
+    OK = 2
+    SKIP = 3
+    PENDING = 4
+
+
+@dataclass
+class TestResult:
+    """
+    Class containing the test result data.
+    """
+    category: TestResultCategory    # result category
+    status: str                     # test status
+    name: str                       # test name
+    id: str                         # test id
+    description: str = ""           # short description of test
+    message: str = ""               # short result message
+    extra: list = None              # additional information text
+    duration: float = None          # test duration
+    filename: str = None            # file name of a failed test
+    lineno: int = None              # line number of a failed test
+    subtestResult: bool = False     # flag indicating the result of a subtest
+
+
+@dataclass
+class TestConfig:
+    """
+    Class containing the test run configuration.
+    """
+    interpreter: str                # path of the Python interpreter
+    discover: bool                  # auto discovery flag
+    discoveryStart: str             # start directory for auto discovery
+    testFilename: str               # name of the test script
+    testName: str                   # name of the test function
+    failFast: bool                  # stop on first fail
+    failedOnly: bool                # run failed tests only
+    collectCoverage: bool           # coverage collection flag
+    eraseCoverage: bool             # erase coverage data first
+
+
+class TestExecutorBase(QObject):
+    """
+    Base class for test framework specific implementations.
+    
+    @signal collected(list of tuple of (str, str, str)) emitted after all tests
+        have been collected. Tuple elements are the test id, the test name and
+        a short description of the test.
+    @signal collectError(list of tuple of (str, str)) emitted when errors
+        are encountered during test collection. Tuple elements are the
+        test name and the error message.
+    @signal startTest(tuple of (str, str, str) emitted before tests are run.
+        Tuple elements are test id, test name and short description.
+    @signal testResult(TestResult) emitted when a test result is ready
+    @signal testFinished(list, str) emitted when the test has finished.
+        The elements are the list of test results and the captured output
+        of the test worker (if any).
+    @signal testRunAboutToBeStarted() emitted just before the test run will
+        be started.
+    @signal testRunFinished(int, float) emitted when the test run has finished.
+        The elements are the number of tests run and the duration in seconds
+    @signal stop() emitted when the test process is being stopped.
+    @signal coverageDataSaved(str) emitted after the coverage data was saved.
+        The element is the absolute path of the coverage data file.
+    """
+    collected = pyqtSignal(list)
+    collectError = pyqtSignal(list)
+    startTest = pyqtSignal(tuple)
+    testResult = pyqtSignal(TestResult)
+    testFinished = pyqtSignal(list, str)
+    testRunAboutToBeStarted = pyqtSignal()
+    testRunFinished = pyqtSignal(int, float)
+    stop = pyqtSignal()
+    coverageDataSaved = pyqtSignal(str)
+    
+    module = ""
+    name = ""
+    runner = ""
+    
+    def __init__(self, testWidget):
+        """
+        Constructor
+        
+        @param testWidget reference to the unit test widget
+        @type TestingWidget
+        """
+        super().__init__(testWidget)
+        
+        self.__process = None
+    
+    @classmethod
+    def isInstalled(cls, interpreter):
+        """
+        Class method to check whether a test framework is installed.
+        
+        The test is performed by checking, if a module loader can found.
+        
+        @param interpreter interpreter to be used for the test
+        @type str
+        @return flag indicating the test framework module is installed
+        @rtype bool
+        """
+        if cls.runner:
+            proc = QProcess()
+            proc.start(interpreter, [cls.runner, "installed"])
+            if proc.waitForFinished(3000):
+                exitCode = proc.exitCode()
+                return exitCode == 0
+        
+        return False
+    
+    def getVersions(self, interpreter):
+        """
+        Public method to get the test framework version and version information
+        of its installed plugins.
+        
+        @param interpreter interpreter to be used for the test
+        @type str
+        @return dictionary containing the framework name and version and the
+            list of available plugins with name and version each
+        @rtype dict
+        @exception NotImplementedError this method needs to be implemented by
+            derived classes
+        """
+        raise NotImplementedError
+        
+        return {}
+    
+    def createArguments(self, config):
+        """
+        Public method to create the arguments needed to start the test process.
+        
+        @param config configuration for the test execution
+        @type TestConfig
+        @return list of process arguments
+        @rtype list of str
+        @exception NotImplementedError this method needs to be implemented by
+            derived classes
+        """
+        raise NotImplementedError
+        
+        return []
+    
+    def _prepareProcess(self, workDir, pythonpath):
+        """
+        Protected method to prepare a process object to be started.
+        
+        @param workDir working directory
+        @type str
+        @param pythonpath list of directories to be added to the Python path
+        @type list of str
+        @return prepared process object
+        @rtype QProcess
+        """
+        process = QProcess(self)
+        process.setProcessChannelMode(
+            QProcess.ProcessChannelMode.MergedChannels)
+        process.setWorkingDirectory(workDir)
+        process.finished.connect(self.finished)
+        if pythonpath:
+            env = QProcessEnvironment.systemEnvironment()
+            currentPythonPath = env.value('PYTHONPATH', None)
+            newPythonPath = os.pathsep.join(pythonpath)
+            if currentPythonPath:
+                newPythonPath += os.pathsep + currentPythonPath
+            env.insert('PYTHONPATH', newPythonPath)
+            process.setProcessEnvironment(env)
+        
+        return process
+    
+    def start(self, config, pythonpath):
+        """
+        Public method to start the testing process.
+        
+        @param config configuration for the test execution
+        @type TestConfig
+        @param pythonpath list of directories to be added to the Python path
+        @type list of str
+        @exception RuntimeError raised if the the testing process did not start
+        """
+        workDir = (
+            config.discoveryStart
+            if config.discover else
+            os.path.dirname(config.testFilename)
+        )
+        self.__process = self._prepareProcess(workDir, pythonpath)
+        testArgs = self.createArguments(config)
+        self.testRunAboutToBeStarted.emit()
+        self.__process.start(config.interpreter, testArgs)
+        running = self.__process.waitForStarted()
+        if not running:
+            raise RuntimeError
+    
+    def finished(self):
+        """
+        Public method handling the unit test process been finished.
+        
+        This method should read the results (if necessary) and emit the signal
+        testFinished.
+        
+        @exception NotImplementedError this method needs to be implemented by
+            derived classes
+        """
+        raise NotImplementedError
+    
+    def readAllOutput(self, process=None):
+        """
+        Public method to read all output of the test process.
+        
+        @param process reference to the process object
+        @type QProcess
+        @return test process output
+        @rtype str
+        """
+        if process is None:
+            process = self.__process
+        output = (
+            str(process.readAllStandardOutput(),
+                Preferences.getSystem("IOEncoding"),
+                'replace').strip()
+            if process else
+            ""
+        )
+        return output
+    
+    def stopIfRunning(self):
+        """
+        Public method to stop the testing process, if it is running.
+        """
+        if (
+            self.__process and
+            self.__process.state() == QProcess.ProcessState.Running
+        ):
+            self.__process.terminate()
+            self.__process.waitForFinished(2000)
+            self.__process.kill()
+            self.__process.waitForFinished(3000)
+            
+            self.stop.emit()

eric ide

mercurial