diff --git a/frappe/commands/testing.py b/frappe/commands/testing.py index f0346a33d8..68e516bd2b 100644 --- a/frappe/commands/testing.py +++ b/frappe/commands/testing.py @@ -1,17 +1,17 @@ -import importlib import os import subprocess import sys import time import unittest -from collections import deque -from pathlib import Path from typing import TYPE_CHECKING import click import frappe from frappe.commands import get_site, pass_context +from frappe.testing.config import TestParameters +from frappe.testing.loader import FrappeTestLoader +from frappe.testing.result import FrappeTestResult from frappe.utils.bench_helper import CliCtxObj if TYPE_CHECKING: @@ -40,8 +40,6 @@ def main( ) -> None: """Main function to run tests""" if light: - from frappe.modules.utils import get_module_name - from frappe.testing.config import TestParameters from frappe.testing.environment import _disable_scheduler_if_needed test_params = TestParameters( @@ -69,83 +67,8 @@ def main( _disable_scheduler_if_needed() frappe.clear_cache() - class FrappeTestLoader(unittest.TestLoader): - def recursive_load_suites_in_pymodule(self, suite): - suites_queue = deque([suite]) - while suites_queue: - suite = suites_queue.popleft() - for elem in suite: - if elem.countTestCases(): - if isinstance(elem, unittest.TestSuite): - suites_queue.append(elem) - elif isinstance(elem, unittest.TestCase): - if self.params.tests: - if elem._testMethodName in self.params.tests: - self.testsuite.addTest(elem) - else: - self.testsuite.addTest(elem) - - def load_testsuites_in_pymodule(self, file_modules): - for module in file_modules: - suite = unittest.defaultTestLoader.loadTestsFromModule(module) - self.recursive_load_suites_in_pymodule(suite) - - def load_pymodule_for_files(self, files: list): - """ - files: list of tuple of (Path, str) - """ - _file_modules = [] - for app_path, test_file in files: - module_name = ( - f"{'.'.join(test_file.relative_to(app_path.parent).parent.parts)}.{test_file.stem}" - ) - module = importlib.import_module(module_name) - _file_modules.append(module) - return _file_modules - - def get_files(self, apps: list) -> list: - files = [] - for app in apps: - app_path = Path(frappe.get_app_path(app)) - for test_file in app_path.glob("**/test_*.py"): - files.append((app_path, test_file)) - return files - - def discover_tests(self, params: TestParameters) -> unittest.TestSuite: - self.params = params - self.testsuite = unittest.TestSuite() - - if self.params.tests: - # handle --test; highest priority; will ignore --doctype and --app - files = self.get_files(frappe.get_installed_apps()) - file_pymodules = self.load_pymodule_for_files(files) - self.load_testsuites_in_pymodule(file_pymodules) - - elif self.params.doctype: - # handle --doctype; will ignore --app - module = frappe.get_cached_value("DocType", self.params.doctype, "module") - app = frappe.get_cached_value("Module Def", module, "app_name") - pymodule_name = get_module_name(self.params.doctype, module, "test_", app=app) - pymodule = importlib.import_module(pymodule_name) - self.load_testsuites_in_pymodule([pymodule]) - - elif self.params.app: - # handle --app - files = self.get_files([self.params.app]) - file_pymodules = self.load_pymodule_for_files(files) - self.load_testsuites_in_pymodule(file_pymodules) - - elif self.params.module: - # handle --module; supports --test as well - pymodule = importlib.import_module(self.params.module) - self.load_testsuites_in_pymodule([pymodule]) - - return self.testsuite - suite = FrappeTestLoader().discover_tests(test_params) - print("Test Cases:", suite.countTestCases()) - res = unittest.TextTestRunner().run(suite) - print("Result:", res) + res = unittest.TextTestRunner(resultclass=FrappeTestResult).run(suite) else: import logging diff --git a/frappe/testing/loader.py b/frappe/testing/loader.py new file mode 100644 index 0000000000..f36f8e28cd --- /dev/null +++ b/frappe/testing/loader.py @@ -0,0 +1,80 @@ +import importlib +import unittest +from collections import deque +from pathlib import Path + +import frappe +from frappe.modules.utils import get_module_name +from frappe.testing.config import TestParameters + + +class FrappeTestLoader(unittest.TestLoader): + def recursive_load_suites_in_pymodule(self, suite): + suites_queue = deque([suite]) + while suites_queue: + suite = suites_queue.popleft() + for elem in suite: + if elem.countTestCases(): + if isinstance(elem, unittest.TestSuite): + suites_queue.append(elem) + elif isinstance(elem, unittest.TestCase): + if self.params.tests: + if elem._testMethodName in self.params.tests: + self.testsuite.addTest(elem) + else: + self.testsuite.addTest(elem) + + def load_testsuites_in_pymodule(self, file_modules): + for module in file_modules: + suite = unittest.defaultTestLoader.loadTestsFromModule(module) + self.recursive_load_suites_in_pymodule(suite) + + def load_pymodule_for_files(self, files: list): + """ + files: list of tuple of (Path, str) + """ + _file_modules = [] + for app_path, test_file in files: + module_name = f"{'.'.join(test_file.relative_to(app_path.parent).parent.parts)}.{test_file.stem}" + module = importlib.import_module(module_name) + _file_modules.append(module) + return _file_modules + + def get_files(self, apps: list) -> list: + files = [] + for app in apps: + app_path = Path(frappe.get_app_path(app)) + for test_file in app_path.glob("**/test_*.py"): + files.append((app_path, test_file)) + return files + + def discover_tests(self, params: TestParameters) -> unittest.TestSuite: + self.params = params + self.testsuite = unittest.TestSuite() + + if self.params.tests: + # handle --test; highest priority; will ignore --doctype and --app + files = self.get_files(frappe.get_installed_apps()) + file_pymodules = self.load_pymodule_for_files(files) + self.load_testsuites_in_pymodule(file_pymodules) + + elif self.params.doctype: + # handle --doctype; will ignore --app + module = frappe.get_cached_value("DocType", self.params.doctype, "module") + app = frappe.get_cached_value("Module Def", module, "app_name") + pymodule_name = get_module_name(self.params.doctype, module, "test_", app=app) + pymodule = importlib.import_module(pymodule_name) + self.load_testsuites_in_pymodule([pymodule]) + + elif self.params.app: + # handle --app + files = self.get_files([self.params.app]) + file_pymodules = self.load_pymodule_for_files(files) + self.load_testsuites_in_pymodule(file_pymodules) + + elif self.params.module: + # handle --module; supports --test as well + pymodule = importlib.import_module(self.params.module) + self.load_testsuites_in_pymodule([pymodule]) + + return self.testsuite diff --git a/frappe/testing/result.py b/frappe/testing/result.py index 4aca1892a6..558790e8de 100644 --- a/frappe/testing/result.py +++ b/frappe/testing/result.py @@ -178,3 +178,48 @@ class TestResult(unittest.TextTestResult): SLOW_TEST_THRESHOLD = 2 + + +class FrappeTestResult(unittest.TextTestResult): + def __init__(self, stream, descriptions, verbosity): + super().__init__(stream, descriptions, verbosity) + + def startTest(self, test): + self.tb_locals = True + self._started_at = time.monotonic() + super().startTest(test) + super(unittest.TextTestResult, self).startTest(test) + test_class = unittest.util.strclass(test.__class__) + if not hasattr(self, "current_test_class") or self.current_test_class != test_class: + click.echo(f"\n{unittest.util.strclass(test.__class__)}") + self.current_test_class = test_class + + def getTestMethodName(self, test): + return test._testMethodName if hasattr(test, "_testMethodName") else str(test) + + def addSuccess(self, test): + super(unittest.TextTestResult, self).addSuccess(test) + elapsed = time.time() - self._started_at + threshold_passed = elapsed >= SLOW_TEST_THRESHOLD + elapsed = click.style(f" ({elapsed:.03}s)", fg="red") if threshold_passed else "" + click.echo(f" {click.style(' ✔ ', fg='green')} {self.getTestMethodName(test)}{elapsed}") + + def addError(self, test, err): + super(unittest.TextTestResult, self).addError(test, err) + click.echo(f" {click.style(' ✖ ', fg='red')} {self.getTestMethodName(test)}") + + def addFailure(self, test, err): + super(unittest.TextTestResult, self).addFailure(test, err) + click.echo(f" {click.style(' ✖ ', fg='red')} {self.getTestMethodName(test)}") + + def addSkip(self, test, reason): + super(unittest.TextTestResult, self).addSkip(test, reason) + click.echo(f" {click.style(' = ', fg='white')} {self.getTestMethodName(test)}") + + def addExpectedFailure(self, test, err): + super(unittest.TextTestResult, self).addExpectedFailure(test, err) + click.echo(f" {click.style(' ✖ ', fg='red')} {self.getTestMethodName(test)}") + + def addUnexpectedSuccess(self, test): + super(unittest.TextTestResult, self).addUnexpectedSuccess(test) + click.echo(f" {click.style(' ✔ ', fg='green')} {self.getTestMethodName(test)}")