[SPARK-58480][PYTHON][TEST] Declare DataFrame golden test cases as methods of a test class - #57691
[SPARK-58480][PYTHON][TEST] Declare DataFrame golden test cases as methods of a test class#57691dtenedor wants to merge 1 commit into
Conversation
|
Thank you @dtenedor! cc @zhengruifeng |
gaogaotiantian
left a comment
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
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))) |
There was a problem hiding this comment.
A few things to make this _test -> test.
- This line is technically not 100% correct. This line is converting all the subclass
_testmethods to its owntestmethods. It probably would just work because howunittestdiscover methods, but we can probably make this technically correct. case_methodis actually only used here. I think we should make_make_case_testa classmethod ofDFGoldenTestMixin. The natually the function has theclsargument and can add test method to it. We can also get rid ofcase_method.
| """The declared case names, in declaration order.""" | ||
| names = [] | ||
| for klass in reversed(cls.__mro__): | ||
| for attr, value in vars(klass).items(): |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 = Noneright?
| ) | ||
| header = {"source": "{}.{}".format(cls.__module__, cls.__qualname__)} | ||
| cases = [ | ||
| build_golden_case(name, is_unordered(cls.case_method(name)), cls._golden_actual[name]) |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
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:This is ordinary Python: imports are at the top of the module, cases can share helpers, and
sparkis a parameter rather than a name injected into anexec()namespace.Cases are ordinary unit tests:
DFGoldenTestMixin(a mixin, following the existingpyspark.testing.goldenutils.GoldenFileTestMixinpattern) registers a realtest_<case>method for every_test_<case>method, carrying its docstring over. Each case is reported individually and can be run on its own: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:
exec()'d withsparkinjected into their namespace, which is why they needed anF821lint exemption and a RAT exclusion, and why they could not be imported or run outside the framework..testfile 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.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.
pyspark.sql.tests.df_golden.test_df_golden_frameworkpasses (51 tests).Was this patch authored or co-authored using generative AI tooling?
Generated-by: Cursor (Opus 5)