root: test label handling and error reporting in PytestTestRunner (#14000)
* root: test label handling and error reporting in PytestTestRunner - Added a check for empty test labels and return a clear error instead of failing silently. - Improved handling of dotted module paths + included support for single-module names. - Replaced a RuntimeError with a printed error message when a test label can't be resolved. - Wrapped the pytest.main call in a try/except to catch unexpected errors and print them nicely. * Fix handling for test file without extension and lint * oops * little improvments too
This commit is contained in:
		@ -7,6 +7,7 @@ from unittest import TestCase
 | 
				
			|||||||
import pytest
 | 
					import pytest
 | 
				
			||||||
from django.conf import settings
 | 
					from django.conf import settings
 | 
				
			||||||
from django.test.runner import DiscoverRunner
 | 
					from django.test.runner import DiscoverRunner
 | 
				
			||||||
 | 
					from structlog.stdlib import get_logger
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.lib.config import CONFIG
 | 
					from authentik.lib.config import CONFIG
 | 
				
			||||||
from authentik.lib.sentry import sentry_init
 | 
					from authentik.lib.sentry import sentry_init
 | 
				
			||||||
@ -22,6 +23,7 @@ class PytestTestRunner(DiscoverRunner):  # pragma: no cover
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def __init__(self, **kwargs):
 | 
					    def __init__(self, **kwargs):
 | 
				
			||||||
        super().__init__(**kwargs)
 | 
					        super().__init__(**kwargs)
 | 
				
			||||||
 | 
					        self.logger = get_logger().bind(runner="pytest")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.args = []
 | 
					        self.args = []
 | 
				
			||||||
        if self.failfast:
 | 
					        if self.failfast:
 | 
				
			||||||
@ -34,22 +36,33 @@ class PytestTestRunner(DiscoverRunner):  # pragma: no cover
 | 
				
			|||||||
        if kwargs.get("no_capture", False):
 | 
					        if kwargs.get("no_capture", False):
 | 
				
			||||||
            self.args.append("--capture=no")
 | 
					            self.args.append("--capture=no")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self._setup_test_environment()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def _setup_test_environment(self):
 | 
				
			||||||
 | 
					        """Configure test environment settings"""
 | 
				
			||||||
        settings.TEST = True
 | 
					        settings.TEST = True
 | 
				
			||||||
        settings.CELERY["task_always_eager"] = True
 | 
					        settings.CELERY["task_always_eager"] = True
 | 
				
			||||||
        CONFIG.set("events.context_processors.geoip", "tests/GeoLite2-City-Test.mmdb")
 | 
					 | 
				
			||||||
        CONFIG.set("events.context_processors.asn", "tests/GeoLite2-ASN-Test.mmdb")
 | 
					 | 
				
			||||||
        CONFIG.set("blueprints_dir", "./blueprints")
 | 
					 | 
				
			||||||
        CONFIG.set(
 | 
					 | 
				
			||||||
            "outposts.container_image_base",
 | 
					 | 
				
			||||||
            f"ghcr.io/goauthentik/dev-%(type)s:{get_docker_tag()}",
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        CONFIG.set("tenants.enabled", False)
 | 
					 | 
				
			||||||
        CONFIG.set("outposts.disable_embedded_outpost", False)
 | 
					 | 
				
			||||||
        CONFIG.set("error_reporting.sample_rate", 0)
 | 
					 | 
				
			||||||
        CONFIG.set("error_reporting.environment", "testing")
 | 
					 | 
				
			||||||
        CONFIG.set("error_reporting.send_pii", True)
 | 
					 | 
				
			||||||
        sentry_init()
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Test-specific configuration
 | 
				
			||||||
 | 
					        test_config = {
 | 
				
			||||||
 | 
					            "events.context_processors.geoip": "tests/GeoLite2-City-Test.mmdb",
 | 
				
			||||||
 | 
					            "events.context_processors.asn": "tests/GeoLite2-ASN-Test.mmdb",
 | 
				
			||||||
 | 
					            "blueprints_dir": "./blueprints",
 | 
				
			||||||
 | 
					            "outposts.container_image_base": f"ghcr.io/goauthentik/dev-%(type)s:{get_docker_tag()}",
 | 
				
			||||||
 | 
					            "tenants.enabled": False,
 | 
				
			||||||
 | 
					            "outposts.disable_embedded_outpost": False,
 | 
				
			||||||
 | 
					            "error_reporting.sample_rate": 0,
 | 
				
			||||||
 | 
					            "error_reporting.environment": "testing",
 | 
				
			||||||
 | 
					            "error_reporting.send_pii": True,
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        for key, value in test_config.items():
 | 
				
			||||||
 | 
					            CONFIG.set(key, value)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        sentry_init()
 | 
				
			||||||
 | 
					        self.logger.debug("Test environment configured")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Send startup signals
 | 
				
			||||||
        pre_startup.send(sender=self, mode="test")
 | 
					        pre_startup.send(sender=self, mode="test")
 | 
				
			||||||
        startup.send(sender=self, mode="test")
 | 
					        startup.send(sender=self, mode="test")
 | 
				
			||||||
        post_startup.send(sender=self, mode="test")
 | 
					        post_startup.send(sender=self, mode="test")
 | 
				
			||||||
@ -72,7 +85,21 @@ class PytestTestRunner(DiscoverRunner):  # pragma: no cover
 | 
				
			|||||||
            help="Disable any capturing of stdout/stderr during tests.",
 | 
					            help="Disable any capturing of stdout/stderr during tests.",
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def run_tests(self, test_labels, extra_tests=None, **kwargs):
 | 
					    def _validate_test_label(self, label: str) -> bool:
 | 
				
			||||||
 | 
					        """Validate test label format"""
 | 
				
			||||||
 | 
					        if not label:
 | 
				
			||||||
 | 
					            return False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Check for invalid characters, but allow forward slashes and colons
 | 
				
			||||||
 | 
					        # for paths and pytest markers
 | 
				
			||||||
 | 
					        invalid_chars = set('\\*?"<>|')
 | 
				
			||||||
 | 
					        if any(c in label for c in invalid_chars):
 | 
				
			||||||
 | 
					            self.logger.error("Invalid characters in test label", label=label)
 | 
				
			||||||
 | 
					            return False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def run_tests(self, test_labels: list[str], extra_tests=None, **kwargs):
 | 
				
			||||||
        """Run pytest and return the exitcode.
 | 
					        """Run pytest and return the exitcode.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        It translates some of Django's test command option to pytest's.
 | 
					        It translates some of Django's test command option to pytest's.
 | 
				
			||||||
@ -82,10 +109,17 @@ class PytestTestRunner(DiscoverRunner):  # pragma: no cover
 | 
				
			|||||||
        The extra_tests argument has been deprecated since Django 5.x
 | 
					        The extra_tests argument has been deprecated since Django 5.x
 | 
				
			||||||
        It is kept for compatibility with PyCharm's Django test runner.
 | 
					        It is kept for compatibility with PyCharm's Django test runner.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
 | 
					        if not test_labels:
 | 
				
			||||||
 | 
					            self.logger.error("No test files specified")
 | 
				
			||||||
 | 
					            return 1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        for label in test_labels:
 | 
					        for label in test_labels:
 | 
				
			||||||
 | 
					            if not self._validate_test_label(label):
 | 
				
			||||||
 | 
					                return 1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            valid_label_found = False
 | 
					            valid_label_found = False
 | 
				
			||||||
            label_as_path = os.path.abspath(label)
 | 
					            label_as_path = os.path.abspath(label)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            # File path has been specified
 | 
					            # File path has been specified
 | 
				
			||||||
            if os.path.exists(label_as_path):
 | 
					            if os.path.exists(label_as_path):
 | 
				
			||||||
                self.args.append(label_as_path)
 | 
					                self.args.append(label_as_path)
 | 
				
			||||||
@ -93,24 +127,30 @@ class PytestTestRunner(DiscoverRunner):  # pragma: no cover
 | 
				
			|||||||
            elif "::" in label:
 | 
					            elif "::" in label:
 | 
				
			||||||
                self.args.append(label)
 | 
					                self.args.append(label)
 | 
				
			||||||
                valid_label_found = True
 | 
					                valid_label_found = True
 | 
				
			||||||
            # Convert dotted module path to file_path::class::method
 | 
					 | 
				
			||||||
            else:
 | 
					            else:
 | 
				
			||||||
 | 
					                # Check if the label is a dotted module path
 | 
				
			||||||
                path_pieces = label.split(".")
 | 
					                path_pieces = label.split(".")
 | 
				
			||||||
                # Check whether only class or class and method are specified
 | 
					 | 
				
			||||||
                for i in range(-1, -3, -1):
 | 
					                for i in range(-1, -3, -1):
 | 
				
			||||||
 | 
					                    try:
 | 
				
			||||||
                        path = os.path.join(*path_pieces[:i]) + ".py"
 | 
					                        path = os.path.join(*path_pieces[:i]) + ".py"
 | 
				
			||||||
                    label_as_path = os.path.abspath(path)
 | 
					                        if os.path.exists(path):
 | 
				
			||||||
                    if os.path.exists(label_as_path):
 | 
					                            if i < -1:
 | 
				
			||||||
                        path_method = label_as_path + "::" + "::".join(path_pieces[i:])
 | 
					                                path_method = path + "::" + "::".join(path_pieces[i:])
 | 
				
			||||||
                                self.args.append(path_method)
 | 
					                                self.args.append(path_method)
 | 
				
			||||||
 | 
					                            else:
 | 
				
			||||||
 | 
					                                self.args.append(path)
 | 
				
			||||||
                            valid_label_found = True
 | 
					                            valid_label_found = True
 | 
				
			||||||
                            break
 | 
					                            break
 | 
				
			||||||
 | 
					                    except (TypeError, IndexError):
 | 
				
			||||||
 | 
					                        continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if not valid_label_found:
 | 
					            if not valid_label_found:
 | 
				
			||||||
                raise RuntimeError(
 | 
					                self.logger.error("Test file not found", label=label)
 | 
				
			||||||
                    f"One of the test labels: {label!r}, "
 | 
					                return 1
 | 
				
			||||||
                    f"is not supported. Use a dotted module name or "
 | 
					 | 
				
			||||||
                    f"path instead."
 | 
					 | 
				
			||||||
                )
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.logger.info("Running tests", test_files=self.args)
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
            return pytest.main(self.args)
 | 
					            return pytest.main(self.args)
 | 
				
			||||||
 | 
					        except Exception as e:
 | 
				
			||||||
 | 
					            self.logger.error("Error running tests", error=str(e), test_files=self.args)
 | 
				
			||||||
 | 
					            return 1
 | 
				
			||||||
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user