Skip to content

feat(py_test): add opt-in safeguard against silently-passing tests#3825

Open
thirtyseven wants to merge 14 commits into
bazel-contrib:mainfrom
thirtyseven:feat/validate-test-main
Open

feat(py_test): add opt-in safeguard against silently-passing tests#3825
thirtyseven wants to merge 14 commits into
bazel-contrib:mainfrom
thirtyseven:feat/validate-test-main

Conversation

@thirtyseven

@thirtyseven thirtyseven commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements #3824.

py_test runs the main module directly and does not automatically invoke a test runner (e.g. unittest/pytest). A common pitfall is to define test classes/functions but forget to run them, so the test silently passes without executing anything.

This adds a validation action to py_test that statically analyzes the main module with ast and fails the build if the module's top-level body is "inert" — only definitions, imports, assignments, and docstrings, with nothing that actually runs tests (such as an if __name__ == "__main__": guard invoking a runner).

Because this is technically a breaking change, it is gated behind a new flag, //python/config_settings:validate_test_main, with values auto/enabled/ disabled. The default is auto, which currently resolves to disabled, and can be flipped to enabled in rules_python 3.0.

Notes/limitations:

  • Only applies to py_test targets with a main source file; main_module targets are not checked.
  • Enabling requires the exec-tools toolchain (default for hermetic toolchains); if it's missing, the build fails with a message pointing at the disable flag.

Test plan

  • Analysis tests (tests/base_rules/py_test) verify the PyValidateTestMain
    action and _validation output group are present when enabled and absent
    when disabled.
  • Unit test (tests/validate_test_main) covers the ast classification
    (inert vs. runs-something).
  • Bazel-in-bazel integration test (tests/integration/validate_test_main)
    builds real py_test targets with the flag and asserts an inert test fails
    the build with a descriptive error when enabled, while a correct test builds;
    and that an inert test builds when disabled / by default.

py_test runs the main module directly and does not automatically invoke
a test runner. A common pitfall is to define test classes/functions but
forget to run them, causing the test to silently pass.

Add a validation action, gated behind the new
//python/config_settings:validate_test_main flag (auto/enabled/disabled,
default off), that statically analyzes the main module with ast and fails
the build if its top-level body is inert (only definitions, imports,
assignments, docstrings) with nothing that runs tests.

Fixes bazel-contrib#3824
Add an integration test that builds real py_test targets with the
validate_test_main flag and asserts:
- an inert test main fails the build with a descriptive error when enabled
- a main that invokes a runner builds when enabled
- an inert test builds when disabled and by default

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an opt-in safeguard (validate_test_main) to prevent py_test targets from silently passing when they do not actually execute any tests. It implements a static analysis validator using Python's ast module to detect inert main modules and fail the build if no active test runner is invoked. The feedback suggests extending the validator's inert node list to include ast.Global and ast.TypeAlias (for Python 3.12+ compatibility) and adding defensive checks in Starlark to safely access the exec_tools toolchain.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread python/private/py_test_main_validator.py Outdated
Comment on lines +1324 to +1330
exec_tools_toolchain = ctx.toolchains[EXEC_TOOLS_TOOLCHAIN_TYPE]
if exec_tools_toolchain == None or exec_tools_toolchain.exec_tools.exec_interpreter == None:
fail(
"Validating py_test main modules requires the exec tools toolchain " +
"with an exec interpreter, but none was found. Either register one " +
"or set --@rules_python//python/config_settings:validate_test_main=disabled.",
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To prevent potential Starlark evaluation errors (such as struct has no field 'exec_tools'), we should defensively check if exec_tools_toolchain actually has the exec_tools attribute and that it is not None before attempting to access exec_tools.exec_interpreter.

    exec_tools_toolchain = ctx.toolchains[EXEC_TOOLS_TOOLCHAIN_TYPE]\n    if (exec_tools_toolchain == None or \n        not hasattr(exec_tools_toolchain, 'exec_tools') or \n        exec_tools_toolchain.exec_tools == None or \n        exec_tools_toolchain.exec_tools.exec_interpreter == None):\n        fail(\n            'Validating py_test main modules requires the exec tools toolchain ' +\n            'with an exec interpreter, but none was found. Either register one ' +\n            'or set --@rules_python//python/config_settings:validate_test_main=disabled.',\n        )

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll leave this as-is for consistency with the existing precompile.bzl, which accesses exec_tools_toolchain.exec_tools.precompiler with the same pattern (only a == None check on the toolchain). The exec tools toolchain rule always constructs a PyExecToolsInfo, so .exec_tools is guaranteed present when the toolchain resolves; adding a hasattr guard here would diverge from the rest of the codebase. Happy to revisit if there's a known case of a custom exec_tools_toolchain_type that returns a bare ToolchainInfo.

Address review feedback: top-level `global x` and PEP 695 `type X = ...`
aliases don't run any test code, so they should count as inert when
deciding whether a test main runs something. `ast.TypeAlias` only exists
on Python 3.12+, so it's added dynamically.
The uv module extension transitively loads common_labels.bzl, so adding
the VALIDATE_TEST_MAIN label changed the extension's bzlTransitiveDigest.
Regenerate the integration test's MODULE.bazel.lock to match.
Only flag a main module as inert when it actually defines test classes or
functions but never runs them. A module that defines nothing and only
imports other modules (e.g. an aggregator that relies on import side
effects) is now always allowed, avoiding false positives.

Update unit tests, docs, and add an import-only e2e case.
The import-only fixture intentionally has an unused import; add a noqa so
ruff (pinned v0.15.14 in CI) passes. The comment doesn't affect the AST, so
the module stays import-only for the validator.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an opt-in build-time validation safeguard (validate_test_main) for py_test targets to prevent them from silently passing when they define tests but fail to invoke a test runner. The validation statically analyzes the main module using an AST parser to ensure it contains active statements. The review feedback highlights a critical gap where compound statements like ast.If (e.g., if TYPE_CHECKING:) and ast.Try (e.g., try/except ImportError:) are not recursively checked, allowing inert files to bypass validation. Suggestions were provided to recursively check these blocks and add corresponding unit tests.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread python/private/py_test_main_validator.py
Comment thread tests/validate_test_main/validate_test_main_test.py
Address review feedback: top-level `if TYPE_CHECKING:` and
`try: import foo except ImportError:` blocks whose branches are entirely
inert previously counted as "active" and let an otherwise-inert test main
bypass the check. Inspect `if`/`try`/`try*` branches recursively so these
common import guards are correctly treated as inert. The `if` condition is
ignored (a guard doesn't run tests; a runner call in a branch body is still
detected as active).

Add unit tests for TYPE_CHECKING, try/except ImportError, and a runner
invoked inside an `if` body.
@thirtyseven

Copy link
Copy Markdown
Contributor Author

/gemini PTAL

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an opt-in safeguard (validate_test_main) for py_test targets to statically analyze the main module and prevent tests from silently passing without executing any test code. It adds a validator script (py_test_main_validator.py) using Python's ast module to detect inert top-level statements, along with corresponding build settings, flags, integration tests, and unit tests. The review feedback correctly identifies a critical issue where all assignments (e.g., exit_code = unittest.main()) are unconditionally treated as inert, which would cause false positives. The reviewer suggests dynamically checking assignments and expressions for active nodes (like function calls) using ast.walk and adding corresponding unit tests to verify this behavior.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread python/private/py_test_main_validator.py Outdated
Comment thread python/private/py_test_main_validator.py
"""
)
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add unit tests to verify that active assignments (e.g., exit_code = unittest.main()) and active expressions (e.g., sys.exit(unittest.main())) are correctly identified as active and allowed by the validator.

        )

    def test_definitions_with_active_assignment_runs_tests(self):
        self.assertTrue(
            _runs_tests(
                """
                import unittest

                class MyTest(unittest.TestCase):
                    def test_foo(self):
                        pass

                exit_code = unittest.main()
                """
            )
        )

    def test_definitions_with_active_expression_runs_tests(self):
        self.assertTrue(
            _runs_tests(
                """
                import unittest

                class MyTest(unittest.TestCase):
                    def test_foo(self):
                        pass

                sys.exit(unittest.main())
                """
            )
        )

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 66811adtest_definitions_with_active_assignment_runs_tests and test_definitions_with_active_expression_runs_tests.

Address review feedback: assignments were unconditionally inert, so a main
like `exit_code = unittest.main()` (which actually runs the tests) was a
false positive that would fail a valid test build. Mark an assignment or
expression statement inert only when its value contains no call/await/yield;
class/function bodies are still skipped (those node types remain
unconditionally inert), so the method-body calls don't defeat the check.

Add unit tests for active assignment and active expression mains.
@thirtyseven

Copy link
Copy Markdown
Contributor Author

/gemini PTAL

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an opt-in safeguard (validate_test_main) to prevent py_test targets from silently passing when they define tests but fail to invoke a test runner. It implements a static analysis tool (py_test_main_validator.py) using Python's ast module to check for active statements in the test's main module and integrates this check as a build-time validation action. Feedback on the changes includes explicitly handling ast.Assert statements as inert in the validator (with corresponding unit tests) and appending to the _validation output group in py_executable.bzl rather than overwriting it to prevent conflicts with other validation actions.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread python/private/py_test_main_validator.py
Comment thread tests/validate_test_main/validate_test_main_test.py
Comment thread python/private/py_executable.bzl Outdated
thirtyseven and others added 3 commits June 16, 2026 14:28
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Address review feedback: add unit tests for the ast.Assert handling --
an inert `assert True` with a definition is rejected, while an assert whose
condition runs a call (e.g. `assert unittest.main()`) is treated as active.
@thirtyseven

Copy link
Copy Markdown
Contributor Author

/gemini PTAL

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an opt-in build-time validation safeguard for py_test targets to prevent them from silently passing when no tests are actually executed. It adds the --@rules_python//python/config_settings:validate_test_main flag and a static analysis tool (py_test_main_validator.py) that parses the test's main module using the ast module to ensure it contains active statements (like invoking a test runner) rather than only inert definitions or imports. The feedback points out a potential false positive where a test runner is invoked directly inside an if condition (e.g., if unittest.main().wasSuccessful():), and suggests updating the validator to check if the condition expression itself runs code.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread python/private/py_test_main_validator.py Outdated
thirtyseven and others added 3 commits June 16, 2026 14:42
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
The if handling now also checks the condition for calls; update the comment
which still claimed the condition was ignored.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant