6161
6262from core import utils
6363
64+ import pytest
6465from typing import Dict , Final , List , Optional , Tuple , cast
6566
6667from . import (
155156 action = 'store_true' ,
156157)
157158_PARSER .add_argument (
158- '--skip-install ' ,
159+ '--skip_install ' ,
159160 help = 'optional; if specified, skips the installation of '
160161 'third party libraries' ,
161162 action = 'store_true' ,
162163)
164+ _PARSER .add_argument (
165+ '--use_pytest' ,
166+ help = 'optional; if specified, uses pytest as the test runner instead of '
167+ 'the default gae_suite runner' ,
168+ action = 'store_true' ,
169+ )
163170
164171
165172def run_shell_cmd (
@@ -443,6 +450,113 @@ def check_test_results(
443450 return total_count , total_errors , total_failures , time_report
444451
445452
453+ def convert_args_to_pytest (parsed_args : argparse .Namespace ) -> List [str ]:
454+ """Convert run_backend_tests.py arguments to pytest arguments.
455+
456+ Args:
457+ parsed_args: argparse.Namespace. Parsed command-line arguments.
458+
459+ Returns:
460+ list(str). List of pytest command-line arguments.
461+
462+ Raises:
463+ Exception. The shard configuration in backend_test_shards.json doesn't
464+ match the actual test files on the filesystem when using the
465+ --test_shard flag. This can happen when (a) a test file listed in
466+ the JSON shards file doesn't exist, (b) a test file exists but
467+ isn't listed in any shard, or (c) a test file is listed in
468+ multiple shards.
469+ """
470+ pytest_args = []
471+
472+ # Add verbosity flag.
473+ pytest_args .append ('-v' if parsed_args .verbose else '-q' )
474+
475+ # Add coverage flags if requested.
476+ if parsed_args .generate_coverage_report :
477+ pytest_args .extend (['--cov=.' , '--cov-report=term-missing' ])
478+ if not parsed_args .ignore_coverage :
479+ pytest_args .append ('--cov-fail-under=100' )
480+
481+ # Handle test selection.
482+ if parsed_args .test_targets :
483+ # Convert dot notation to pytest path notation.
484+ for test_target in parsed_args .test_targets .split (',' ):
485+ # Check if this is a specific test (has _test. in it).
486+ # Since _test always appears as a suffix on module names, we can
487+ # simply split on '.' and find the part ending with '_test'.
488+ if '_test.' in test_target :
489+ # Find the module ending with _test.
490+ parts = test_target .split ('.' )
491+ test_idx = next (
492+ i for i , part in enumerate (parts ) if part .endswith ('_test' )
493+ )
494+
495+ # Original format: module.path_test.ClassName(.method_name).
496+ # Convert to: module/path_test.py::ClassName(::method_name).
497+ test_path = '%s.py::%s' % (
498+ '/' .join (parts [: test_idx + 1 ]),
499+ '::' .join (parts [test_idx + 1 :]),
500+ )
501+
502+ pytest_args .append (test_path )
503+ elif test_target .endswith ('_test' ):
504+ # Just a test module.
505+ test_path = test_target .replace ('.' , '/' ) + '.py'
506+ pytest_args .append (test_path )
507+ else :
508+ # Not a test file, add _test suffix.
509+ test_path = test_target .replace ('.' , '/' ) + '_test.py'
510+ pytest_args .append (test_path )
511+ elif parsed_args .test_path :
512+ pytest_args .append (parsed_args .test_path )
513+ elif parsed_args .test_shard :
514+ # Get all test targets from shard and convert to paths.
515+ validation_error = check_shards_match_tests (include_load_tests = True )
516+ if validation_error :
517+ raise Exception (validation_error )
518+ all_test_targets = get_all_test_targets_from_shard (
519+ parsed_args .test_shard
520+ )
521+ for test_target in all_test_targets :
522+ test_path = test_target .replace ('.' , '/' ) + '.py'
523+ pytest_args .append (test_path )
524+ elif parsed_args .run_on_changed_files_in_branch :
525+ changed_files = git_changes_utils .get_changed_python_test_files ()
526+ for test_target in changed_files :
527+ test_path = test_target .replace ('.' , '/' ) + '.py'
528+ pytest_args .append (test_path )
529+ else :
530+ # Run all tests.
531+ if parsed_args .exclude_load_tests :
532+ pytest_args .append ('--ignore=core/tests/load_tests' )
533+ # Default: run all tests in current directory.
534+ pytest_args .append ('.' )
535+
536+ return pytest_args
537+
538+
539+ def run_tests_with_pytest (parsed_args : argparse .Namespace ) -> int :
540+ """Run tests using pytest instead of gae_suite.
541+
542+ Args:
543+ parsed_args: argparse.Namespace. Parsed command-line arguments.
544+
545+ Returns:
546+ int. Exit code from pytest (0 for success, non-zero for failure).
547+ """
548+ pytest_args = convert_args_to_pytest (parsed_args )
549+
550+ print ('Running tests with pytest...' )
551+ print ('Pytest arguments: %s' % ' ' .join (pytest_args ))
552+ print ('' )
553+
554+ # Run pytest with the converted arguments.
555+ exit_code = pytest .main (pytest_args )
556+
557+ return exit_code
558+
559+
446560def main (args : Optional [List [str ]] = None ) -> None :
447561 """Run the tests."""
448562 parsed_args = _PARSER .parse_args (args = args )
@@ -466,6 +580,25 @@ def main(args: Optional[List[str]] = None) -> None:
466580 raise Exception ('The delimiter in test_path should be a slash (/)' )
467581 if not parsed_args .skip_install :
468582 install_third_party_libs .main ()
583+
584+ # If --use_pytest flag is set, delegate to pytest and return early.
585+ if parsed_args .use_pytest :
586+ with contextlib .ExitStack () as stack :
587+ stack .enter_context (
588+ servers .managed_cloud_datastore_emulator (clear_datastore = True )
589+ )
590+ stack .enter_context (servers .managed_redis_server ())
591+
592+ # Run tests with pytest.
593+ exit_code = run_tests_with_pytest (parsed_args )
594+
595+ if exit_code != 0 :
596+ raise Exception ('Tests failed with exit code %d' % exit_code )
597+
598+ print ('' )
599+ print ('Done!' )
600+ return
601+
469602 with contextlib .ExitStack () as stack :
470603 stack .enter_context (
471604 servers .managed_cloud_datastore_emulator (clear_datastore = True )
0 commit comments