Skip to content

[SPARK-58480][PYTHON][TEST] Declare DataFrame golden test cases as methods of a test class - #57691

Open
dtenedor wants to merge 1 commit into
apache:masterfrom
dtenedor:combine-golden-files
Open

[SPARK-58480][PYTHON][TEST] Declare DataFrame golden test cases as methods of a test class#57691
dtenedor wants to merge 1 commit into
apache:masterfrom
dtenedor:combine-golden-files

Conversation

@dtenedor

Copy link
Copy Markdown
Contributor

What changes were proposed in this pull request?

This PR follows up on #57122 and #57639 by replacing the per-case Python snippet files of the DataFrame golden test framework with a single test class per golden file.

The files under python/pyspark/sql/tests/df_golden/scripts/group_by/ are replaced by one module, python/pyspark/sql/tests/df_golden/test_group_by.py, holding one class whose _test_<case> methods each build and return the DataFrame under test:

        class GroupByGoldenTests(DFGoldenTestMixin, ReusedConnectTestCase):
            golden_file = "group_by.test"
            @unordered
            def _test_group_by_count(self, spark):
                """Aggregate with non-empty GroupBy expressions."""
                return spark.table("testData").groupBy(col("a")).agg(count(col("b")))

This is ordinary Python: imports are at the top of the module, cases can share helpers, and spark is a parameter rather than a name injected into an exec() namespace.

Cases are ordinary unit tests: DFGoldenTestMixin (a mixin, following the existing pyspark.testing.goldenutils.GoldenFileTestMixin pattern) registers a real test_<case> method for every _test_<case> method, carrying its docstring over. Each case is reported individually and can be run on its own:

        python/run-tests --testnames \
          "pyspark.sql.tests.df_golden.test_group_by GroupByGoldenTests.test_group_by_count"

Why are the changes needed?

The framework as merged put each case's DataFrame program in its own file, which drew several objections in the review of #57122:

  1. File count. One file per case does not scale.
  2. The snippets were not real Python. They were exec()'d with spark injected into their namespace, which is why they needed an F821 lint exemption and a RAT exclusion, and why they could not be imported or run outside the framework.
  3. Reproducing a failure was hard A whole .test file used to run as one unittest method, so a failure named a case but gave no way to run just that case. Now every case is a test method that can be selected by name, and the case body is an importable method.
  4. Structure. Organizing cases as methods of a class is the conventional Python shape, and it lets cases share helpers.

Does this PR introduce any user-facing change?

No. This is test-only: the framework and its test corpus.

How was this patch tested?

This PR is test-only. The migration was verified as follows.

  • Golden outputs are unchanged.
  • The regeneration writer reproduces the file byte for byte.
  • The class and the golden file are in sync.
  • Framework unit tests: pyspark.sql.tests.df_golden.test_df_golden_framework passes (51 tests).

Was this patch authored or co-authored using generative AI tooling?

Generated-by: Cursor (Opus 5)

@dtenedor
dtenedor requested a review from gaogaotiantian July 31, 2026 20:55
@dtenedor dtenedor changed the title [SPARK-58480][PYTHON][TEST][FOLLOWUP] Declare DataFrame golden test cases as methods of a test class [SPARK-58480][PYTHON][TEST] Declare DataFrame golden test cases as methods of a test class Jul 31, 2026
@uros-b

uros-b commented Jul 31, 2026

Copy link
Copy Markdown
Member

Thank you @dtenedor! cc @zhengruifeng

@gaogaotiantian gaogaotiantian 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.

Generally, I believe this is the correct direction. We should have a relatively complicated mixin and many easy to read/reproduce test cases. I'm glad that we can get rid of a lot of files.

I have some comments about the implementation. It's a framework and I hope the code is cleaner and easier for extension in the future.

I also have some suggestions for next steps, but they are not for this PR.

def run_script(spark, script_path):
"""
Execute the test case script and return the DataFrame it assigns to ``df``.
def _build_dataframe(spark, build_df):

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.

I think this function is a bit too simple now to be a function. Let's just inline this to the only callsite.

return os.environ.get("SPARK_GENERATE_GOLDEN_FILES") is not None


def check_cases_in_sync(test_file, declared, golden):

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.

If we are doing an assertion here, let's change the function name to assert_cases_in_sync.

for base in cls.__mro__:
if base is DFGoldenTestMixin:
break
if base is not cls and "setUpClass" in vars(base):

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.

Is this correct? A subclass can have a setUpClass method that works with the system right? So A -> B(setUpClass) -> DFGoldenTestMixin -> PySparkTestBase should be a valid inherit chain but this will blocks it.

I don't believe we should use setUpClass to guarantee this. I don't think we have to stop this when the subclass is declared. It's fine to catch this on run-time as it will be a one time thing. We won't keep losing time because we are not able to catch it on declaration.

)
)
for name in cls.case_names():
setattr(cls, "test_" + name, _make_case_test(name, cls.case_method(name)))

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.

A few things to make this _test -> test.

  1. This line is technically not 100% correct. This line is converting all the subclass _test methods to its own test methods. It probably would just work because how unittest discover methods, but we can probably make this technically correct.
  2. case_method is actually only used here. I think we should make _make_case_test a classmethod of DFGoldenTestMixin. The natually the function has the cls argument and can add test method to it. We can also get rid of case_method.

"""The declared case names, in declaration order."""
names = []
for klass in reversed(cls.__mro__):
for attr, value in vars(klass).items():

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.

This might be better:

for name, func in inspect.getmembers(B, predicate=inspect.isfunction):

header, cases = parse_test_file(cls._golden_path)
validate_test_file(cls._golden_path, header, cases)
golden_names = [case["name"] for case in cases]
check_cases_in_sync(cls._golden_path, case_names, golden_names)

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.

It seems like header is only used for validation, so we don't even need to get it from parsing I guess? How about we directly get the dict for cls._golden_cases and check whether it's sync?


@classmethod
def _close_golden_session(cls):
session = cls._golden_session

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.

This logic is a bit weird if we do not consider any racing issues.

It's just

if cls._golden_session is None:
    return
client = cls._golden_session.client
cls.golden_session = None

right?

)
header = {"source": "{}.{}".format(cls.__module__, cls.__qualname__)}
cases = [
build_golden_case(name, is_unordered(cls.case_method(name)), cls._golden_actual[name])

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.

I don't think is_unordered should be specially treated. It should be a "tag" of a case - so whether it's actual or golden, it's just part of the data. When you compare, you check that tag.

We probably have other tags in the future, we don't want to add a new argument for every tag. It's just data.

cls = type(self)
build_df = getattr(self, _CASE_METHOD_PREFIX + name)
actual = compute_case_outputs(
cls._golden_session, build_df, sort_rows=is_unordered(build_df)

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.

Similar here. build_df already has the information whether it's unordered. It does not need to be part of the arguments. compute_case_outputs can deal with the special attributes of build_df inside it and convert everything to tags.

__file_metadata__
--! source
df_golden/group_by
pyspark.sql.tests.df_golden.test_group_by.GroupByGoldenTests

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.

I hope eventually we can get rid of our own file format and use something more generic like toml - which are also super readable by both humans and machines. We won't need to maintain our own parser for each section anymore. But this is not part of this PR.

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.

3 participants