Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add RuleRunner as a Pytest-style replacement to TestBase #10699

Merged
merged 2 commits into from Aug 28, 2020

Conversation

Eric-Arellano
Copy link
Contributor

Problem

We want to be able to use Pytest-style tests, rather than unittest style tests. Pytest allows for nice features like parameterization and fixtures, including dozens of pre-built fixtures like caplog (capture logging).

For our TestBase-style tests, we need a wrapper around a SchedulerSession in order to make synchronous calls to the engine. We also need to set up a temporary build root.

For isolation between tests, we must be careful to invalidate every test, such as using a new build root every individual test.

Solution

Add RuleRunner, which is a dataclass around all the config necessary to create a BuildConfiguration and SchedulerSession. This dataclass exposes the methods request_product() and run_goal_rule(), along with utilities like add_to_build_file().

Each individual test should create a new instance of a RuleRunner. This is important for isolation between tests.

Conventionally, this will be done with a Pytest fixture, which allows us to set up common config (e.g. rules and target types) for multiple tests but to still get a distinct RuleRunner instance for each test.

@pytest.fixture
def rule_runner() -> RuleRunner:
    return RuleRunner(rules=filedeps.rules(), target_types=[MockTarget, ProtobufLibrary])

def test_no_target(rule_runner: RuleRunner) -> None:
    rule_runner.create_file(...)
    rule_runner.request_product(..)

Users can also create the RuleRunner inline.

If there are multiple different configurations in a test file, the user may set up multiple different Pytest fixtures.

@pytest.fixture
def target_adaptor_rule_runner() -> RuleRunner:
    return RuleRunner(
        rules=[QueryRule(TargetAdaptor, (Address, OptionsBootstrapper))], target_types=[MockTgt]
    )

...

@pytest.fixture
def address_specs_rule_runner() -> RuleRunner:
    return RuleRunner(
        rules=[QueryRule(AddressesWithOrigins, (AddressSpecs, OptionsBootstrapper))],
        target_types=[MockTgt],
    )

Result

We can always use Pytest style tests now in Pants. We deprecate TestBase to be removed in 2.1.0.dev0.

Performance benchmark

We used to only create one SchedulerSession for the entire test class and to invalidate on every individual test. Now, we create a new SchedulerSession every single test. This ends up having a negligible performance impact.

Before:

multitime -n 10 ./pants --no-pantsd test --force src/python/pants/core/util_rules/filter_empty_sources_test.py
1: ./pants --no-pantsd test --force src/python/pants/core/util_rules/filter_empty_sources_test.py
            Mean        Std.Dev.    Min         Median      Max
real        9.269       0.135       8.976       9.286       9.468
user        9.226       0.143       8.992       9.201       9.498
sys         6.960       0.133       6.768       6.980       7.187

After:

multitime -n 10 ./pants --no-pantsd test --force src/python/pants/core/util_rules/filter_empty_sources_test.py
            Mean        Std.Dev.    Min         Median      Max
real        9.347       0.125       9.172       9.339       9.666
user        9.332       0.139       9.189       9.299       9.726
sys         7.083       0.123       6.853       7.096       7.359

Risk: breaking test isolation

It is possible for a user to use a single RuleRunner for multiple tests, e.g. one instance for the entire test class. This will reduce isolation between tests.

We mitigate this risk by only ever documenting how you should properly use RuleRunner, along with adding a warning to https://www.pantsbuild.org/v2.0/docs/rules-api-testing about this risk.

Unsolved issue: rule registration

It is still clunky to register the rules you need for your test to pass. This should still be solved, but it's left for a followup.

[ci skip-rust]
[ci skip-build-wheels]

[ci skip-rust]

[ci skip-build-wheels]
Copy link
Contributor Author

@Eric-Arellano Eric-Arellano left a comment

Choose a reason for hiding this comment

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

I marked which files have meaningful changes. All other files are routine rewrites to Pytest style.

@@ -5,83 +5,66 @@
import zipfile
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This file has meaningful changes because we now use parametrization. In fact, this resolves a TODO Benjy added a few months ago to enable Pytest style tests!

@@ -40,7 +40,7 @@
from pants.option.options_bootstrapper import OptionsBootstrapper
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This file has meaningful changes. We used to have one TestBase for multiple distinct tests - we used a single config even though they were very different tests.

Now, we use a mix of inlined RuleRunners + 2 Pytest fixtures so that each test has the tightest possible graph.

@@ -3,80 +3,81 @@

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This file has meaningful changes because we now use parametrization.

@@ -194,122 +194,118 @@ def find_root(path):
assert "project2/src/python" == find_root("project2/src/python/baz/qux.py")


class AllRootsTest(TestBase):
Copy link
Contributor Author

Choose a reason for hiding this comment

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

There wasn't any need to use TestBase in the first place for these 2 tests.

env={}, args=["--pants-config-files=[]"], allow_pantsrc=False
)
global_options = options_bootstrapper.bootstrap_options.for_global_scope()
local_store_dir = global_options.local_store_dir
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We need to still emulate isolated_local_store, which is used in fs_test.py to avoid using the global lmdb_store. Saving for a followup.

def target_types(self) -> FrozenOrderedSet[Type[Target]]:
return self.build_config.target_types

def request_product(self, product_type: Type[_P], subjects: Iterable[Any]) -> _P:
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Here and below is basically copy pasta from TestBase.

execution_options=ExecutionOptions.from_bootstrap_options(global_options),
).new_session(build_id="buildid_for_test", should_report_workunits=True)
self.scheduler = graph_session.scheduler_session

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Note that we have no way of registering these clean up methods:

def setUp(self):
"""
:API: public
"""
super().setUp()
# Avoid resetting the Runtracker here, as that is specific to fork'd process cleanup.
clean_global_runtime_state(reset_subsystem=True)
self.addCleanup(self._reset_engine)
safe_mkdir(self.build_root, clean=True)
safe_mkdir(self.pants_workdir)
self.addCleanup(safe_rmtree, self.build_root)
BuildRoot().path = self.build_root
self.addCleanup(BuildRoot().reset)

I don't think we need them, though. The tmpdir for build_root should be automatically cleaned. BuildRoot().path will get reset the next time that a new RuleRunner is created, and it can't impact outside of the test file thanks to v2's isolation.

Copy link
Sponsor Contributor

Choose a reason for hiding this comment

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

That sounds right to me.

@coveralls
Copy link

coveralls commented Aug 28, 2020

Coverage Status

Coverage remained the same at 0.0% when pulling 8ec84ba on Eric-Arellano:rule-runner into 2fc8a42 on pantsbuild:master.

# Rust tests and lints will be skipped. Delete if not intended.
[ci skip-rust]

# Building wheels and fs_util will be skipped. Delete if not intended.
[ci skip-build-wheels]
Copy link
Sponsor Contributor

@benjyw benjyw left a comment

Choose a reason for hiding this comment

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

I love it!

("a/b/c.py", ("a/b/c.py",)),
],
)
def test_valid_matches(rule_runner: RuleRunner, glob: str, paths: Tuple[str, ...]) -> None:
Copy link
Sponsor Contributor

Choose a reason for hiding this comment

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

👍

execution_options=ExecutionOptions.from_bootstrap_options(global_options),
).new_session(build_id="buildid_for_test", should_report_workunits=True)
self.scheduler = graph_session.scheduler_session

Copy link
Sponsor Contributor

Choose a reason for hiding this comment

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

That sounds right to me.

@Eric-Arellano Eric-Arellano merged commit d3eee59 into pantsbuild:master Aug 28, 2020
@Eric-Arellano Eric-Arellano deleted the rule-runner branch August 28, 2020 23:59
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.

None yet

3 participants