diff options
author | Romain Jobredeaux <jobredeaux@google.com> | 2023-09-27 23:54:48 +0000 |
---|---|---|
committer | Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com> | 2023-09-27 23:54:48 +0000 |
commit | a40e5383d6da4df36a31464e819e9160dc805869 (patch) | |
tree | 329839a67b2b6d2a26d17780d4002e4eee0808fd | |
parent | 79e5caf4425eaa1a299668bbeea5c8ab5a864641 (diff) | |
parent | adb5cee483c2b5e8ae55cbbd32448647af42c3d9 (diff) | |
download | bazelbuild-rules_testing-a40e5383d6da4df36a31464e819e9160dc805869.tar.gz |
Merge remote-tracking branch 'aosp/upstream-master' into HEAD am: 20475333ee am: 36c23a4a19 am: adb5cee483
Original change: https://android-review.googlesource.com/c/platform/external/bazelbuild-rules_testing/+/2758210
Change-Id: Ia14a3bc43c3dbb6e74f2c5090c157dd2b54a69be
Signed-off-by: Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com>
41 files changed, 1763 insertions, 341 deletions
diff --git a/.bazelci/presubmit.yml b/.bazelci/presubmit.yml index 2ea8c13..8bf06bb 100644 --- a/.bazelci/presubmit.yml +++ b/.bazelci/presubmit.yml @@ -16,10 +16,20 @@ tasks: bazel: latest test_flags: - "--enable_bzlmod" - - "--test_tag_filters=-skip-bzlmod" + - "--test_tag_filters=-skip-bzlmod,-docs" test_targets: - "..." + docs: + name: Docs generation + platform: ubuntu2004 + bazel: latest + test_flags: + - "--enable_bzlmod" + test_targets: + - "//docgen/..." + - "//docs/..." + e2e_bzlmod: platform: ${{platform}} working_directory: e2e/bzlmod diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..270f10b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,72 @@ +# rules_testing Changelog + +## Unreleased + +### Added + * Common attributes, such as `tags` and `target_compatible_with` can now + be set on tests themselves. This allows skipping tests based on platform + or filtering out tests using `--test_tag_filters` + ([#43](https://github.com/bazelbuild/rules_testing/issues/43)) + * DefaultInfoSubject for asserting the builtin DefaultInfo provider + ([#52](https://github.com/bazelbuild/rules_testing/issues/52)) + * StructSubject for asserting arbitrary structs. + ([#53](https://github.com/bazelbuild/rules_testing/issues/53)) + * (docs) Created human-friendly changelog + +## [0.3.0] - 2023-07-06 + +### Added + * Publically exposed subjects in `truth.bzl#subjects`. This allows + direct creation of subjects without having to go through the + `expect.that_*` functions. This makes it easier to implement + custom subjects. ([#54](https://github.com/bazelbuild/rules_testing/issues/54)) + * `matching.file_basename_equals` for matching a File basename. + ([#44](https://github.com/bazelbuild/rules_testing/issues/44)) + * `matching.file_extension_in` for matching a File extension. + ([#44](https://github.com/bazelbuild/rules_testing/issues/44)) + * `DictSubject.get` for fetching sub-values within a dict as subjects. + ([#51](https://github.com/bazelbuild/rules_testing/issues/51)) + * `CollectionSubject.transform` for arbitrary transforming and filtering + of a collection. + ([#45](https://github.com/bazelbuild/rules_testing/issues/45)) + +[0.3.0]: https://github.com/bazelbuild/rules_testing/releases/tag/v0.3.0 + +## [0.2.0] - 2023-06-14 + +### Added + * Unit-test style tests. These are tests that don't require a "setup" + phase like analysis tests do, so all you need to write is the + implementation function that does asserts. + ([#37](https://github.com/bazelbuild/rules_testing/issues/37)) + * (docs) Document some best practices for test naming and structure. + +### Deprecated + * `//lib:analysis_test.bzl#test_suite`: use `//lib:test_suite.bzl#test_suite` + instead. The name in `analysis_test.bzl` will be removed in a future + release. + +[0.2.0]: https://github.com/bazelbuild/rules_testing/releases/tag/v0.2.0 + +## [0.1.0] - 2023-05-02 + +### Fixed + * Don't require downstream user to register Python toolchains. + ([#33](https://github.com/bazelbuild/rules_testing/issues/33)) + +[0.1.0]: https://github.com/bazelbuild/rules_testing/releases/tag/v0.1.0 + +## [0.0.5] - 2023-04-25 + +**NOTE: This version is broken with bzlmod** + +## Fixed + * Fix crash when equal collections with differing orders have + `in_order()` checked. + ([#29](https://github.com/bazelbuild/rules_testing/issues/29)) + +## Added + * Generated docs with API reference at https://rules-testing.readthedocs.io + ([#28](https://github.com/bazelbuild/rules_testing/issues/28)) + +[0.0.5]: https://github.com/bazelbuild/rules_testing/releases/tag/v0.0.5 diff --git a/MODULE.bazel b/MODULE.bazel index 515a444..9b60855 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -14,30 +14,30 @@ bazel_dep(name = "rules_license", version = "0.0.4") # work with bzlmod enabled. This defines the repo so load() works. bazel_dep( name = "stardoc", - version = "0.5.3", + version = "0.5.6", dev_dependency = True, repo_name = "io_bazel_stardoc", ) -bazel_dep(name = "rules_python", version = "0.20.0", dev_dependency = True) +bazel_dep(name = "rules_python", version = "0.22.0", dev_dependency = True) python = use_extension( - "@rules_python//python:extensions.bzl", + "@rules_python//python/extensions:python.bzl", "python", dev_dependency = True, ) python.toolchain( - name = "python3_11", + name = "python_3_11", python_version = "3.11", ) # NOTE: use_repo() must be called for each platform that runs the docgen tools use_repo( python, - "python3_11_toolchains", - "python3_11_x86_64-unknown-linux-gnu", + "python_3_11_toolchains", + "python_3_11_x86_64-unknown-linux-gnu", ) -# NOTE: This is actualy a dev dependency, but due to +# NOTE: This is actually a dev dependency, but due to # https://github.com/bazelbuild/bazel/issues/18248 it has to be non-dev to # generate the repo name used in the subsequent register_toolchains() call. # Once 6.2 is the minimum supported version, the register_toolchains @@ -50,15 +50,30 @@ use_repo(dev, "rules_testing_dev_toolchains") # NOTE: This call will be run by downstream users, so the # repos it mentions must exist. -register_toolchains("@rules_testing_dev_toolchains//:all") +register_toolchains( + "@rules_testing_dev_toolchains//:all", + dev_dependency = True, +) + +interpreter = use_extension( + "@rules_python//python/extensions:interpreter.bzl", + "interpreter", + dev_dependency = True, +) +interpreter.install( + name = "python_3_11_interpreter", + python_name = "python_3_11", +) +use_repo(interpreter, "python_3_11_interpreter") pip = use_extension( - "@rules_python//python:extensions.bzl", + "@rules_python//python/extensions:pip.bzl", "pip", dev_dependency = True, ) pip.parse( name = "docs-pypi", + python_interpreter_target = "@python_3_11_interpreter//:python", requirements_lock = "//docs:requirements.txt", ) use_repo(pip, "docs-pypi") @@ -1,9 +1,9 @@ [![Build status](https://badge.buildkite.com/a82ebafd30ad56e0596dcd3a3a19f36985d064f7f7fb89e21e.svg?branch=master)](https://buildkite.com/bazel/rules-testing) -# Framworks and utilities for testing Bazel Starlark rules +# Frameworks and utilities for testing Bazel Starlark -`rules_testing` provides frameworks and utilities to make testing Starlark rules +`rules_testing` provides frameworks and utilities to make testing Starlark code easier and convenient. -For detailed docs, see the [docs directory](docs/index.md). +For detailed docs, see the [docs website](https://rules-testing.readthedocs.io) diff --git a/RELEASING.md b/RELEASING.md index e2f3df0..72059f2 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -7,5 +7,5 @@ Assuming you have a remote named `upstream` pointing to the repo: * `git tag v<VERSION> upstream/master && git push upstream --tags` After pushing, the release action will trigger. It will package it up, create a -relase on the GitHub release page, and trigger an update to the Bazel Central +release on the GitHub release page, and trigger an update to the Bazel Central Registry (BCR). diff --git a/dev_extension.bzl b/dev_extension.bzl index 8be534e..8925d26 100644 --- a/dev_extension.bzl +++ b/dev_extension.bzl @@ -29,7 +29,7 @@ def _dev_toolchains_repo_impl(rctx): # If its the root module, then we're in rules_testing and # it's a dev dependency situation. if rctx.attr.is_root: - toolchain_build = Label("@python3_11_toolchains//:BUILD.bazel") + toolchain_build = Label("@python_3_11_toolchains//:BUILD.bazel") # NOTE: This is brittle. It only works because, luckily, # rules_python's toolchain BUILD file is essentially self-contained. diff --git a/docgen/BUILD b/docgen/BUILD index 3acaa53..dbb8391 100644 --- a/docgen/BUILD +++ b/docgen/BUILD @@ -43,6 +43,9 @@ sphinx_stardocs( "//lib/private:run_environment_info_subject_bzl", "//lib/private:runfiles_subject_bzl", "//lib/private:str_subject_bzl", + "//lib/private:struct_subject_bzl", "//lib/private:target_subject_bzl", + "//lib/private:default_info_subject_bzl", ], + tags = ["docs"], ) diff --git a/docgen/docgen.bzl b/docgen/docgen.bzl index f89328a..1aa2a0f 100644 --- a/docgen/docgen.bzl +++ b/docgen/docgen.bzl @@ -29,11 +29,6 @@ def sphinx_stardocs(name, bzl_libraries, **kwargs): tags) """ - # Stardoc doesn't yet work with bzlmod; we can detect this by - # looking for "@@" vs "@" in labels. - if "@@" in str(Label("//:X")): - kwargs["target_compatible_with"] = ["@platforms//:incompatible"] - docs = [] for label in bzl_libraries: lib_name = Label(label).name.replace("_bzl", "") diff --git a/docs/crossrefs.md b/docs/crossrefs.md index 59d6be1..8c2106f 100644 --- a/docs/crossrefs.md +++ b/docs/crossrefs.md @@ -19,7 +19,9 @@ [`Ordered`]: /api/ordered [`RunfilesSubject`]: /api/runfiles_subject [`str`]: https://bazel.build/rules/lib/string +[`struct`]: https://bazel.build/rules/lib/builtins/struct [`StrSubject`]: /api/str_subject +[`StructSubject`]: /api/struct_subject [`Target`]: https://bazel.build/rules/lib/Target [`TargetSubject`]: /api/target_subject [target-name]: https://bazel.build/concepts/labels#target-names diff --git a/docs/source/best_practices.md b/docs/source/best_practices.md new file mode 100644 index 0000000..ced3de5 --- /dev/null +++ b/docs/source/best_practices.md @@ -0,0 +1,21 @@ +# Best Practices + +Here we collection tips and techniques for keeping your tests maintainable and +avoiding common pitfalls. + +### Put each suite of tests in its own sub-package + +It's recommended to put a given suite of unit tests in their own sub-package +(directory with a BUILD file). This is because the names of your test functions +become target names in the BUILD file, which makes it easier to create name +conflicts. By moving them into their own package, you don't have to worry about +unit test function names in one `.bzl` file conflicting with names in another. + +### Give test functions private names + +It's recommended to give test functions private names, i.e. start with a leading +underscore. This is because if you forget to add a test to the list of tests (an +easy mistake to make in a file with many tests), the test won't run, and it'll +appear as if everything is OK. By using a leading underscore, tools like +buildifier can detect the unused private function and will warn you that it's +unused, preventing you from accidentally forgetting it. diff --git a/docs/source/test_suite.md b/docs/source/test_suite.md new file mode 100644 index 0000000..2bd9d26 --- /dev/null +++ b/docs/source/test_suite.md @@ -0,0 +1,78 @@ +# Test suites + +The `test_suite` macro is a front-end for easily instantiating groups of +Starlark tests. It can handle both analysis tests and unit tests. Under the +hood, each test is its own target with an aggregating `native.test_suite` +for the group of tests. + +## Basic tests + +Basic tests are tests that don't require any custom setup or attributes. This is +the common case for tests of utility code that doesn't interact with objects +only available to rules (e.g. Targets). These tests are created using +`unit_test`. + +To write such a test, simply write a `unit_test` compatible function (one that +accepts `env`) and pass it to `test_suite.basic_tests`. + +```starlark +# BUILD + +load(":my_tests.bzl", "my_test_suite") +load("@rules_testing//lib:test_suite.bzl", "test_suite") + +my_test_suite(name = "my_tests") + +# my_tests.bzl + +def _foo_test(env): + env.expect.that_str(...).equals(...) + +def my_test_suite(name): + test_suite( + name = name, + basic_tests = [ + _foo_test, + ] + ) +``` + +Note that it isn't _required_ to write a custom test suite function, but doing +so is preferred because it's uncommon for BUILD files to pass around function +objects, and tools won't be confused by it. + +## Regular tests + +A regular test is a macro that acts as a setup function and is expected to +create a target of the given name (which is added to the underlying test suite). + +The setup function can perform arbitrary logic, but in the end, it's expected to +call `unit_test` or `analysis_test` to create a target with the provided name. + +If you're writing an `analysis_test`, then you're writing a regular test. + +```starlark +# my_tests.bzl +def _foo_test(name): + analysis_test( + name = name, + impl = _foo_test_impl, + attrs = {"myattr": attr.string(default="default")} + ) + +def _foo_test_impl(env): + env.expect.that_str(...).equals(...) + +def my_test_suite(name): + test_suite( + name = name, + tests = [ + _foo_test, + ] + ) +``` + +Note that a using a setup function with `unit_test` test is not required to +define custom attributes; the above is just an example. If you want to define +custom attributes for every test in a suite, the `test_kwargs` argument of +`test_suite` can be used to pass additional arguments to all tests in the suite. diff --git a/docs/source/unit_tests.md b/docs/source/unit_tests.md new file mode 100644 index 0000000..22ffab9 --- /dev/null +++ b/docs/source/unit_tests.md @@ -0,0 +1,73 @@ +# Unit tests + +Unit tests are for Starlark code that isn't specific to analysis-phase or +loading phase cases; usually utility code of some sort. Such tests typically +don't require a rule `ctx` or instantiating other targets to verify the code +under test. + +To write such a test, simply write a function accepting `env` and pass it to +`test_suite`. The test suite will pass your verification function to +`unit_test()` for you. + +```starlark +# BUILD + +load(":my_tests.bzl", "my_test_suite") +load("@rules_testing//lib:test_suite.bzl", "test_suite") + +my_test_suite(name = "my_tests") + +# my_tests.bzl + +def _foo_test(env): + env.expect.that_str(...).equals(...) + +def my_test_suite(name): + test_suite( + name = name, + basic_tests = [ + _foo_test, + ] + ) +``` + +Note that it isn't _required_ to write a custom test suite function, but doing +so is preferred because it's uncommon for BUILD files to pass around function +objects, and tools won't be confused by it. + +## Customizing setup + +If you want to customize the setup (loading phase) of a unit test, e.g. to add +custom attributes, then you need to write in the same style as an analysis test: +one function is a verification function, and another function performs setup and +calls `unit_test()`, passing in the verification function. + +Custom tests are like basic tests, except you can hook into the loading phase +before the actual unit test is defined. Because you control the invocation of +`unit_test`, you can e.g. define custom attributes specific to the test. + +```starlark +# my_tests.bzl +def _foo_test(name): + unit_test( + name = name, + impl = _foo_test_impl, + attrs = {"myattr": attr.string(default="default")} + ) + +def _foo_test_impl(env): + env.expect.that_str(...).equals(...) + +def my_test_suite(name): + test_suite( + name = name, + custom_tests = [ + _foo_test, + ] + ) +``` + +Note that a custom test is not required to define custom attributes; the above +is just an example. If you want to define custom attributes for every test in a +suite, the `test_kwargs` argument of `test_suite` can be used to pass additional +arguments to all tests in the suite. @@ -13,10 +13,12 @@ # limitations under the License. load("@bazel_skylib//:bzl_library.bzl", "bzl_library") +load("//lib/private:util.bzl", "do_nothing") licenses(["notice"]) package( + default_applicable_licenses = ["//:package_license"], default_visibility = ["//visibility:private"], ) @@ -25,7 +27,10 @@ bzl_library( srcs = ["analysis_test.bzl"], visibility = ["//visibility:public"], deps = [ - "//lib:truth_bzl", + ":test_suite_bzl", + ":truth_bzl", + "//lib/private:analysis_test_bzl", + "//lib/private:util_bzl", ], ) @@ -36,11 +41,13 @@ bzl_library( deps = [ "//lib/private:bool_subject_bzl", "//lib/private:collection_subject_bzl", + "//lib/private:default_info_subject_bzl", "//lib/private:depset_file_subject_bzl", "//lib/private:expect_bzl", "//lib/private:int_subject_bzl", "//lib/private:label_subject_bzl", "//lib/private:matching_bzl", + "//lib/private:struct_subject_bzl", ], ) @@ -56,6 +63,25 @@ bzl_library( ], ) +bzl_library( + name = "unit_test_bzl", + srcs = ["unit_test.bzl"], + visibility = ["//visibility:public"], + deps = [ + "//lib/private:analysis_test_bzl", + ], +) + +bzl_library( + name = "test_suite_bzl", + srcs = ["test_suite.bzl"], + visibility = ["//visibility:public"], + deps = [ + ":unit_test_bzl", + "//lib/private:util_bzl", + ], +) + filegroup( name = "test_deps", testonly = True, @@ -80,3 +106,9 @@ exports_files( "//docgen:__pkg__", ], ) + +# Unit tests need some target because they're based upon analysis tests. +do_nothing( + name = "_stub_target_for_unit_tests", + visibility = ["//visibility:public"], +) diff --git a/lib/analysis_test.bzl b/lib/analysis_test.bzl index 02164a4..d8ad2b1 100644 --- a/lib/analysis_test.bzl +++ b/lib/analysis_test.bzl @@ -17,237 +17,15 @@ Support for testing analysis phase logic, such as rules. """ -load("//lib:truth.bzl", "truth") -load("//lib:util.bzl", "recursive_testing_aspect", "testing_aspect") +load("//lib:test_suite.bzl", _test_suite = "test_suite") +load("//lib/private:analysis_test.bzl", _analysis_test = "analysis_test") -def _impl_function_name(impl): - """Derives the name of the given rule implementation function. +analysis_test = _analysis_test - This can be used for better test feedback. +def test_suite(**kwargs): + """This is an alias to lib/test_suite.bzl#test_suite. Args: - impl: the rule implementation function - - Returns: - The name of the given function - """ - - # Starlark currently stringifies a function as "<function NAME>", so we use - # that knowledge to parse the "NAME" portion out. If this behavior ever - # changes, we'll need to update this. - # TODO(bazel-team): Expose a ._name field on functions to avoid this. - impl_name = str(impl) - impl_name = impl_name.partition("<function ")[-1] - impl_name = impl_name.rpartition(">")[0] - impl_name = impl_name.partition(" ")[0] - - # Strip leading/trailing underscores so that test functions can - # have private names. This better allows unused tests to be flagged by - # buildifier (indicating a bug or code to delete) - return impl_name.strip("_") - -def _fail(env, msg): - """Unconditionally causes the current test to fail. - - Args: - env: The test environment returned by `unittest.begin`. - msg: The message to log describing the failure. - """ - full_msg = "In test %s: %s" % (env.ctx.attr._impl_name, msg) - - # There isn't a better way to output the message in Starlark, so use print. - # buildifier: disable=print - print(full_msg) - env.failures.append(full_msg) - -def _begin_analysis_test(ctx): - """Begins a unit test. - - This should be the first function called in a unit test implementation - function. It initializes a "test environment" that is used to collect - assertion failures so that they can be reported and logged at the end of the - test. - - Args: - ctx: The Starlark context. Pass the implementation function's `ctx` argument - in verbatim. - - Returns: - An analysis_test "environment" struct. The following fields are public: - * ctx: the underlying rule ctx - * expect: a truth Expect object (see truth.bzl). - * fail: A function to register failures for later reporting. - - Other attributes are private, internal details and may change at any time. Do not rely - on internal details. - """ - target = getattr(ctx.attr, "target") - target = target[0] if type(target) == type([]) else target - failures = [] - failures_env = struct( - ctx = ctx, - failures = failures, - ) - truth_env = struct( - ctx = ctx, - fail = lambda msg: _fail(failures_env, msg), - ) - analysis_test_env = struct( - ctx = ctx, - # Visibility: package; only exposed so that our own tests can verify - # failure behavior. - _failures = failures, - fail = truth_env.fail, - expect = truth.expect(truth_env), - ) - return analysis_test_env, target - -def _end_analysis_test(env): - """Ends an analysis test and logs the results. - - This must be called and returned at the end of an analysis test implementation function so - that the results are reported. - - Args: - env: The test environment returned by `analysistest.begin`. - - Returns: - A list of providers needed to automatically register the analysis test result. + **kwargs: Args passed through to test_suite """ - return [AnalysisTestResultInfo( - success = (len(env._failures) == 0), - message = "\n".join(env._failures), - )] - -def analysis_test( - name, - target, - impl, - expect_failure = False, - attrs = {}, - fragments = [], - config_settings = {}, - extra_target_under_test_aspects = [], - collect_actions_recursively = False): - """Creates an analysis test from its implementation function. - - An analysis test verifies the behavior of a "real" rule target by examining - and asserting on the providers given by the real target. - - Each analysis test is defined in an implementation function. This function handles - the boilerplate to create and return a test target and captures the - implementation function's name so that it can be printed in test feedback. - - An example of an analysis test: - - ``` - def basic_test(name): - my_rule(name = name + "_subject", ...) - - analysistest(name = name, target = name + "_subject", impl = _your_test) - - def _your_test(env, target, actions): - env.assert_that(target).runfiles().contains_at_least("foo.txt") - env.assert_that(find_action(actions, generating="foo.txt")).argv().contains("--a") - ``` - - Args: - name: Name of the target. It should be a Starlark identifier, matching pattern - '[A-Za-z_][A-Za-z0-9_]*'. - target: The target to test. - impl: The implementation function of the unit test. - expect_failure: If true, the analysis test will expect the target - to fail. Assertions can be made on the underlying failure using truth.expect_failure - attrs: An optional dictionary to supplement the attrs passed to the - unit test's `rule()` constructor. - fragments: An optional list of fragment names that can be used to give rules access to - language-specific parts of configuration. - config_settings: A dictionary of configuration settings to change for the target under - test and its dependencies. This may be used to essentially change 'build flags' for - the target under test, and may thus be utilized to test multiple targets with different - flags in a single build. NOTE: When values that are labels (e.g. for the - --platforms flag), it's suggested to always explicitly call `Label()` - on the value before passing it in. This ensures the label is resolved - in your repository's context, not rule_testing's. - extra_target_under_test_aspects: An optional list of aspects to apply to the target_under_test - in addition to those set up by default for the test harness itself. - collect_actions_recursively: If true, runs testing_aspect over all attributes, otherwise - it is only applied to the target under test. - - Returns: - (None) - """ - - attrs = dict(attrs) - attrs["_impl_name"] = attr.string(default = _impl_function_name(impl)) - - changed_settings = dict(config_settings) - if expect_failure: - changed_settings["//command_line_option:allow_analysis_failures"] = "True" - - target_attr_kwargs = {} - if changed_settings: - test_transition = analysis_test_transition( - settings = changed_settings, - ) - target_attr_kwargs["cfg"] = test_transition - - attrs["target"] = attr.label( - aspects = [recursive_testing_aspect if collect_actions_recursively else testing_aspect] + extra_target_under_test_aspects, - mandatory = True, - **target_attr_kwargs - ) - - def wrapped_impl(ctx): - env, target = _begin_analysis_test(ctx) - impl(env, target) - return _end_analysis_test(env) - - return testing.analysis_test( - name, - wrapped_impl, - attrs = attrs, - fragments = fragments, - attr_values = {"target": target}, - ) - -def test_suite(name, tests, test_kwargs = {}): - """Instantiates given test macros and gathers their main targets into a `test_suite`. - - Use this function to wrap all tests into a single target. - - ``` - def simple_test_suite(name): - test_suite( - name = name, - tests = [ - your_test, - your_other_test, - ] - ) - ``` - - Then, in your `BUILD` file, simply load the macro and invoke it to have all - of the targets created: - - ``` - load("//path/to/your/package:tests.bzl", "simple_test_suite") - simple_test_suite(name = "simple_test_suite") - ``` - - Args: - name: The name of the `test_suite` target. - tests: A list of test macros, each taking `name` as a parameter, which - will be passed the computed name of the test. - test_kwargs: Additional kwargs to pass onto each test function call. - """ - test_targets = [] - for call in tests: - test_name = _impl_function_name(call) - call(name = test_name, **test_kwargs) - test_targets.append(test_name) - - native.test_suite( - name = name, - tests = test_targets, - ) + _test_suite(**kwargs) diff --git a/lib/private/BUILD b/lib/private/BUILD index 6372128..07f9b99 100644 --- a/lib/private/BUILD +++ b/lib/private/BUILD @@ -27,6 +27,11 @@ exports_files( ) bzl_library( + name = "analysis_test_bzl", + srcs = ["analysis_test.bzl"], +) + +bzl_library( name = "matching_bzl", srcs = ["matching.bzl"], ) @@ -58,6 +63,7 @@ bzl_library( ":int_subject_bzl", ":matching_bzl", ":truth_common_bzl", + ":util_bzl", ], ) @@ -120,6 +126,16 @@ bzl_library( ) bzl_library( + name = "default_info_subject_bzl", + srcs = ["default_info_subject.bzl"], + deps = [ + ":depset_file_subject_bzl", + ":file_subject_bzl", + ":runfiles_subject_bzl", + ], +) + +bzl_library( name = "depset_file_subject_bzl", srcs = ["depset_file_subject.bzl"], deps = [ @@ -210,6 +226,11 @@ bzl_library( ) bzl_library( + name = "struct_subject_bzl", + srcs = ["struct_subject.bzl"], +) + +bzl_library( name = "target_subject_bzl", srcs = ["target_subject.bzl"], deps = [ @@ -241,6 +262,12 @@ bzl_library( ":file_subject_bzl", ":int_subject_bzl", ":str_subject_bzl", + ":struct_subject_bzl", ":target_subject_bzl", ], ) + +bzl_library( + name = "util_bzl", + srcs = ["util.bzl"], +) diff --git a/lib/private/analysis_test.bzl b/lib/private/analysis_test.bzl new file mode 100644 index 0000000..c4c95ac --- /dev/null +++ b/lib/private/analysis_test.bzl @@ -0,0 +1,193 @@ +# Copyright 2022 The Bazel Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""# Analysis test + +Support for testing analysis phase logic, such as rules. +""" + +load("@bazel_skylib//lib:dicts.bzl", "dicts") +load("//lib:truth.bzl", "truth") +load("//lib:util.bzl", "recursive_testing_aspect", "testing_aspect") +load("//lib/private:util.bzl", "get_test_name_from_function") + +def _fail(env, msg): + """Unconditionally causes the current test to fail. + + Args: + env: The test environment returned by `unittest.begin`. + msg: The message to log describing the failure. + """ + full_msg = "In test %s: %s" % (env.ctx.attr._impl_name, msg) + + # There isn't a better way to output the message in Starlark, so use print. + # buildifier: disable=print + print(full_msg) + env.failures.append(full_msg) + +def _begin_analysis_test(ctx): + """Begins a unit test. + + This should be the first function called in a unit test implementation + function. It initializes a "test environment" that is used to collect + assertion failures so that they can be reported and logged at the end of the + test. + + Args: + ctx: The Starlark context. Pass the implementation function's `ctx` argument + in verbatim. + + Returns: + An analysis_test "environment" struct. The following fields are public: + * ctx: the underlying rule ctx + * expect: a truth Expect object (see truth.bzl). + * fail: A function to register failures for later reporting. + + Other attributes are private, internal details and may change at any time. Do not rely + on internal details. + """ + target = getattr(ctx.attr, "target") + target = target[0] if type(target) == type([]) else target + failures = [] + failures_env = struct( + ctx = ctx, + failures = failures, + ) + truth_env = struct( + ctx = ctx, + fail = lambda msg: _fail(failures_env, msg), + ) + analysis_test_env = struct( + ctx = ctx, + # Visibility: package; only exposed so that our own tests can verify + # failure behavior. + _failures = failures, + fail = truth_env.fail, + expect = truth.expect(truth_env), + ) + return analysis_test_env, target + +def _end_analysis_test(env): + """Ends an analysis test and logs the results. + + This must be called and returned at the end of an analysis test implementation function so + that the results are reported. + + Args: + env: The test environment returned by `analysistest.begin`. + + Returns: + A list of providers needed to automatically register the analysis test result. + """ + return [AnalysisTestResultInfo( + success = (len(env._failures) == 0), + message = "\n".join(env._failures), + )] + +def analysis_test( + name, + target, + impl, + expect_failure = False, + attrs = {}, + attr_values = {}, + fragments = [], + config_settings = {}, + extra_target_under_test_aspects = [], + collect_actions_recursively = False): + """Creates an analysis test from its implementation function. + + An analysis test verifies the behavior of a "real" rule target by examining + and asserting on the providers given by the real target. + + Each analysis test is defined in an implementation function. This function handles + the boilerplate to create and return a test target and captures the + implementation function's name so that it can be printed in test feedback. + + An example of an analysis test: + + ``` + def basic_test(name): + my_rule(name = name + "_subject", ...) + + analysistest(name = name, target = name + "_subject", impl = _your_test) + + def _your_test(env, target, actions): + env.assert_that(target).runfiles().contains_at_least("foo.txt") + env.assert_that(find_action(actions, generating="foo.txt")).argv().contains("--a") + ``` + + Args: + name: Name of the target. It should be a Starlark identifier, matching pattern + '[A-Za-z_][A-Za-z0-9_]*'. + target: The target to test. + impl: The implementation function of the analysis test. + expect_failure: If true, the analysis test will expect the target + to fail. Assertions can be made on the underlying failure using truth.expect_failure + attrs: An optional dictionary to supplement the attrs passed to the + unit test's `rule()` constructor. + attr_values: An optional dictionary of kwargs to pass onto the + analysis test target itself (e.g. common attributes like `tags`, + `target_compatible_with`, or attributes from `attrs`). Note that these + are for the analysis test target itself, not the target under test. + fragments: An optional list of fragment names that can be used to give rules access to + language-specific parts of configuration. + config_settings: A dictionary of configuration settings to change for the target under + test and its dependencies. This may be used to essentially change 'build flags' for + the target under test, and may thus be utilized to test multiple targets with different + flags in a single build. NOTE: When values that are labels (e.g. for the + --platforms flag), it's suggested to always explicitly call `Label()` + on the value before passing it in. This ensures the label is resolved + in your repository's context, not rule_testing's. + extra_target_under_test_aspects: An optional list of aspects to apply to the target_under_test + in addition to those set up by default for the test harness itself. + collect_actions_recursively: If true, runs testing_aspect over all attributes, otherwise + it is only applied to the target under test. + + Returns: + (None) + """ + + attrs = dict(attrs) + attrs["_impl_name"] = attr.string(default = get_test_name_from_function(impl)) + + changed_settings = dict(config_settings) + if expect_failure: + changed_settings["//command_line_option:allow_analysis_failures"] = "True" + + target_attr_kwargs = {} + if changed_settings: + test_transition = analysis_test_transition( + settings = changed_settings, + ) + target_attr_kwargs["cfg"] = test_transition + + attrs["target"] = attr.label( + aspects = [recursive_testing_aspect if collect_actions_recursively else testing_aspect] + extra_target_under_test_aspects, + mandatory = True, + **target_attr_kwargs + ) + + def wrapped_impl(ctx): + env, target = _begin_analysis_test(ctx) + impl(env, target) + return _end_analysis_test(env) + + return testing.analysis_test( + name, + wrapped_impl, + attrs = attrs, + fragments = fragments, + attr_values = dicts.add(attr_values, {"target": target}), + ) diff --git a/lib/private/collection_subject.bzl b/lib/private/collection_subject.bzl index 8b093eb..6d72efe 100644 --- a/lib/private/collection_subject.bzl +++ b/lib/private/collection_subject.bzl @@ -35,6 +35,14 @@ load( load(":int_subject.bzl", "IntSubject") load(":matching.bzl", "matching") load(":truth_common.bzl", "to_list") +load(":util.bzl", "get_function_name") + +def _identity(v): + return v + +def _always_true(v): + _ = v # @unused + return True def _collection_subject_new( values, @@ -64,7 +72,6 @@ def _collection_subject_new( public = struct( # keep sorted start actual = values, - has_size = lambda *a, **k: _collection_subject_has_size(self, *a, **k), contains = lambda *a, **k: _collection_subject_contains(self, *a, **k), contains_at_least = lambda *a, **k: _collection_subject_contains_at_least(self, *a, **k), contains_at_least_predicates = lambda *a, **k: _collection_subject_contains_at_least_predicates(self, *a, **k), @@ -72,8 +79,11 @@ def _collection_subject_new( contains_exactly_predicates = lambda *a, **k: _collection_subject_contains_exactly_predicates(self, *a, **k), contains_none_of = lambda *a, **k: _collection_subject_contains_none_of(self, *a, **k), contains_predicate = lambda *a, **k: _collection_subject_contains_predicate(self, *a, **k), + has_size = lambda *a, **k: _collection_subject_has_size(self, *a, **k), not_contains = lambda *a, **k: _collection_subject_not_contains(self, *a, **k), not_contains_predicate = lambda *a, **k: _collection_subject_not_contains_predicate(self, *a, **k), + offset = lambda *a, **k: _collection_subject_offset(self, *a, **k), + transform = lambda *a, **k: _collection_subject_transform(self, *a, **k), # keep sorted end ) self = struct( @@ -334,17 +344,121 @@ def _collection_subject_not_contains_predicate(self, matcher): sort = self.sortable, ) +def _collection_subject_offset(self, offset, factory): + """Fetches an element from the collection as a subject. + + Args: + self: implicitly added. + offset: ([`int`]) the offset to fetch + factory: ([`callable`]). The factory function to use to create + the subject for the offset's value. It must have the following + signature: `def factory(value, *, meta)`. + + Returns: + Object created by `factory`. + """ + value = self.actual[offset] + return factory( + value, + meta = self.meta.derive("offset({})".format(offset)), + ) + +def _collection_subject_transform( + self, + desc = None, + *, + map_each = None, + loop = None, + filter = None): + """Transforms a collections's value and returns another CollectionSubject. + + This is equivalent to applying a list comprehension over the collection values, + but takes care of propagating context information and wrapping the value + in a `CollectionSubject`. + + `transform(map_each=M, loop=L, filter=F)` is equivalent to + `[M(v) for v in L(collection) if F(v)]`. + + Args: + self: implicitly added. + desc: (optional [`str`]) a human-friendly description of the transform + for use in error messages. Required when a description can't be + inferred from the other args. The description can be inferred if the + filter arg is a named function (non-lambda) or Matcher object. + map_each: (optional [`callable`]) function to transform an element in + the collection. It takes one positional arg, the loop's + current iteration value, and its return value will be the element's + new value. If not specified, the values from the loop iteration are + returned unchanged. + loop: (optional [`callable`]) function to produce values from the + original collection and whose values are iterated over. It takes one + positional arg, which is the original collection. If not specified, + the original collection values are iterated over. + filter: (optional [`callable`]) function that decides what values are + passed onto `map_each` for inclusion in the final result. It takes + one positional arg, the value to match (which is the current + iteration value before `map_each` is applied), and returns a bool + (True if the value should be included in the result, False if it + should be skipped). + + Returns: + [`CollectionSubject`] of the transformed values. + """ + if not desc: + if map_each or loop: + fail("description required when map_each or loop used") + + if matching.is_matcher(filter): + desc = "filter=" + filter.desc + else: + func_name = get_function_name(filter) + if func_name == "lambda": + fail("description required: description cannot be " + + "inferred from lambdas. Explicitly specify the " + + "description, use a named function for the filter, " + + "or use a Matcher for the filter.") + else: + desc = "filter={}(...)".format(func_name) + + map_each = map_each or _identity + loop = loop or _identity + + if filter: + if matching.is_matcher(filter): + filter_func = filter.match + else: + filter_func = filter + else: + filter_func = _always_true + + new_values = [map_each(v) for v in loop(self.actual) if filter_func(v)] + + return _collection_subject_new( + new_values, + meta = self.meta.derive( + "transform()", + details = ["transform: {}".format(desc)], + ), + container_name = self.container_name, + sortable = self.sortable, + element_plural_name = self.element_plural_name, + ) + # We use this name so it shows up nice in docs. # buildifier: disable=name-conventions CollectionSubject = struct( - new = _collection_subject_new, - has_size = _collection_subject_has_size, + # keep sorted start contains = _collection_subject_contains, + contains_at_least = _collection_subject_contains_at_least, + contains_at_least_predicates = _collection_subject_contains_at_least_predicates, contains_exactly = _collection_subject_contains_exactly, contains_exactly_predicates = _collection_subject_contains_exactly_predicates, contains_none_of = _collection_subject_contains_none_of, contains_predicate = _collection_subject_contains_predicate, - contains_at_least = _collection_subject_contains_at_least, - contains_at_least_predicates = _collection_subject_contains_at_least_predicates, + has_size = _collection_subject_has_size, + new = _collection_subject_new, not_contains_predicate = _collection_subject_not_contains_predicate, + offset = _collection_subject_offset, + transform = _collection_subject_transform, + # keep sorted end ) diff --git a/lib/private/default_info_subject.bzl b/lib/private/default_info_subject.bzl new file mode 100644 index 0000000..3a66a48 --- /dev/null +++ b/lib/private/default_info_subject.bzl @@ -0,0 +1,127 @@ +# Copyright 2023 The Bazel Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""# DefaultInfoSubject""" + +load(":runfiles_subject.bzl", "RunfilesSubject") +load(":depset_file_subject.bzl", "DepsetFileSubject") +load(":file_subject.bzl", "FileSubject") + +def _default_info_subject_new(info, *, meta): + """Creates a `DefaultInfoSubject` + + Args: + info: ([`DefaultInfo`]) the DefaultInfo object to wrap. + meta: ([`ExpectMeta`]) call chain information. + + Returns: + [`DefaultInfoSubject`] object. + """ + self = struct(actual = info, meta = meta) + public = struct( + # keep sorted start + actual = info, + runfiles = lambda *a, **k: _default_info_subject_runfiles(self, *a, **k), + data_runfiles = lambda *a, **k: _default_info_subject_data_runfiles(self, *a, **k), + default_outputs = lambda *a, **k: _default_info_subject_default_outputs(self, *a, **k), + executable = lambda *a, **k: _default_info_subject_executable(self, *a, **k), + runfiles_manifest = lambda *a, **k: _default_info_subject_runfiles_manifest(self, *a, **k), + # keep sorted end + ) + return public + +def _default_info_subject_runfiles(self): + """Creates a subject for the default runfiles. + + Args: + self: implicitly added. + + Returns: + [`RunfilesSubject`] object + """ + return RunfilesSubject.new( + self.actual.default_runfiles, + meta = self.meta.derive("runfiles()"), + kind = "default", + ) + +def _default_info_subject_data_runfiles(self): + """Creates a subject for the data runfiles. + + Args: + self: implicitly added. + + Returns: + [`RunfilesSubject`] object + """ + return RunfilesSubject.new( + self.actual.data_runfiles, + meta = self.meta.derive("data_runfiles()"), + kind = "data", + ) + +def _default_info_subject_default_outputs(self): + """Creates a subject for the default outputs. + + Args: + self: implicitly added. + + Returns: + [`DepsetFileSubject`] object. + """ + return DepsetFileSubject.new( + self.actual.files, + meta = self.meta.derive("default_outputs()"), + ) + +def _default_info_subject_executable(self): + """Creates a subject for the executable file. + + Args: + self: implicitly added. + + Returns: + [`FileSubject`] object. + """ + return FileSubject.new( + self.actual.files_to_run.executable, + meta = self.meta.derive("executable()"), + ) + +def _default_info_subject_runfiles_manifest(self): + """Creates a subject for the runfiles manifest. + + Args: + self: implicitly added. + + Returns: + [`FileSubject`] object. + """ + return FileSubject.new( + self.actual.files_to_run.runfiles_manifest, + meta = self.meta.derive("runfiles_manifest()"), + ) + +# We use this name so it shows up nice in docs. +# buildifier: disable=name-conventions +DefaultInfoSubject = struct( + # keep sorted start + new = _default_info_subject_new, + runfiles = _default_info_subject_runfiles, + data_runfiles = _default_info_subject_data_runfiles, + default_outputs = _default_info_subject_default_outputs, + executable = _default_info_subject_executable, + runfiles_manifest = _default_info_subject_runfiles_manifest, + # keep sorted end +) diff --git a/lib/private/dict_subject.bzl b/lib/private/dict_subject.bzl index 48d9463..f155a17 100644 --- a/lib/private/dict_subject.bzl +++ b/lib/private/dict_subject.bzl @@ -39,10 +39,13 @@ def _dict_subject_new(actual, meta, container_name = "dict", key_plural_name = " # buildifier: disable=uninitialized public = struct( + # keep sorted start contains_exactly = lambda *a, **k: _dict_subject_contains_exactly(self, *a, **k), contains_at_least = lambda *a, **k: _dict_subject_contains_at_least(self, *a, **k), contains_none_of = lambda *a, **k: _dict_subject_contains_none_of(self, *a, **k), + get = lambda *a, **k: _dict_subject_get(self, *a, **k), keys = lambda *a, **k: _dict_subject_keys(self, *a, **k), + # keep sorted end ) self = struct( actual = actual, @@ -152,6 +155,25 @@ def _dict_subject_contains_none_of(self, none_of): actual = "actual: {{\n{}\n}}".format(format_dict_as_lines(self.actual)), ) +def _dict_subject_get(self, key, *, factory): + """Gets `key` from the actual dict wrapped in a subject. + + Args: + self: implicitly added. + key: ([`object`]) the key to fetch. + factory: ([`callable`]) subject factory function, with the signature + of `def factory(value, *, meta)`, and returns the wrapped value. + + Returns: + The return value of the `factory` arg. + """ + if key not in self.actual: + fail("KeyError: '{key}' not found in {expr}".format( + key = key, + expr = self.meta.current_expr(), + )) + return factory(self.actual[key], meta = self.meta.derive("get({})".format(key))) + def _dict_subject_keys(self): """Returns a `CollectionSubject` for the dict's keys. diff --git a/lib/private/expect.bzl b/lib/private/expect.bzl index e568a54..ab90fd9 100644 --- a/lib/private/expect.bzl +++ b/lib/private/expect.bzl @@ -23,6 +23,7 @@ load(":expect_meta.bzl", "ExpectMeta") load(":file_subject.bzl", "FileSubject") load(":int_subject.bzl", "IntSubject") load(":str_subject.bzl", "StrSubject") +load(":struct_subject.bzl", "StructSubject") load(":target_subject.bzl", "TargetSubject") def _expect_new_from_env(env): @@ -78,6 +79,7 @@ def _expect_new(env, meta): that_file = lambda *a, **k: _expect_that_file(self, *a, **k), that_int = lambda *a, **k: _expect_that_int(self, *a, **k), that_str = lambda *a, **k: _expect_that_str(self, *a, **k), + that_struct = lambda *a, **k: _expect_that_struct(self, *a, **k), that_target = lambda *a, **k: _expect_that_target(self, *a, **k), where = lambda *a, **k: _expect_where(self, *a, **k), # keep sorted end @@ -120,18 +122,19 @@ def _expect_that_bool(self, value, expr = "boolean"): meta = self.meta.derive(expr = expr), ) -def _expect_that_collection(self, collection, expr = "collection"): +def _expect_that_collection(self, collection, expr = "collection", **kwargs): """Creates a subject for asserting collections. Args: self: implicitly added. collection: The collection (list or depset) to assert. expr: ([`str`]) the starting "value of" expression to report in errors. + **kwargs: Additional kwargs to pass onto CollectionSubject.new Returns: [`CollectionSubject`] object. """ - return CollectionSubject.new(collection, self.meta.derive(expr)) + return CollectionSubject.new(collection, self.meta.derive(expr), **kwargs) def _expect_that_depset_of_files(self, depset_files): """Creates a subject for asserting a depset of files. @@ -206,6 +209,18 @@ def _expect_that_str(self, value): """ return StrSubject.new(value, self.meta.derive("string")) +def _expect_that_struct(self, value): + """Creates a subject for asserting a `struct`. + + Args: + self: implicitly added. + value: ([`struct`]) the value to check against. + + Returns: + [`StructSubject`] object. + """ + return StructSubject.new(value, self.meta.derive("string")) + def _expect_that_target(self, target): """Creates a subject for asserting a `Target`. @@ -256,6 +271,7 @@ def _expect_where(self, **details): # We use this name so it shows up nice in docs. # buildifier: disable=name-conventions Expect = struct( + # keep sorted start new_from_env = _expect_new_from_env, new = _expect_new, that_action = _expect_that_action, @@ -266,6 +282,8 @@ Expect = struct( that_file = _expect_that_file, that_int = _expect_that_int, that_str = _expect_that_str, + that_struct = _expect_that_struct, that_target = _expect_that_target, where = _expect_where, + # keep sorted end ) diff --git a/lib/private/expect_meta.bzl b/lib/private/expect_meta.bzl index 8ce9f1e..efe59fc 100644 --- a/lib/private/expect_meta.bzl +++ b/lib/private/expect_meta.bzl @@ -36,7 +36,7 @@ def _expect_meta_new(env, exprs = [], details = [], format_str_kwargs = None): The `env` object basically provides a way to interact with things outside of the truth assertions framework. This allows easier testing of the framework itself and decouples it from a particular test framework (which - makes it usuable by by rules_testing's analysis_test and skylib's + makes it usable by by rules_testing's analysis_test and skylib's analysistest) The `env` object requires the following attribute: @@ -51,7 +51,7 @@ def _expect_meta_new(env, exprs = [], details = [], format_str_kwargs = None): provider and returns [`bool`]. This is used to implement `Provider in target` operations. * get_provider: (callable) it accepts two positional args, target and - provider and returns the provder value. This is used to implement + provider and returns the provider value. This is used to implement `target[Provider]`. Args: @@ -77,6 +77,7 @@ def _expect_meta_new(env, exprs = [], details = [], format_str_kwargs = None): ctx = env.ctx, env = env, add_failure = lambda *a, **k: _expect_meta_add_failure(self, *a, **k), + current_expr = lambda *a, **k: _expect_meta_current_expr(self, *a, **k), derive = lambda *a, **k: _expect_meta_derive(self, *a, **k), format_str = lambda *a, **k: _expect_meta_format_str(self, *a, **k), get_provider = lambda *a, **k: _expect_meta_get_provider(self, *a, **k), @@ -233,7 +234,7 @@ def _expect_meta_add_failure(self, problem, actual): if detail ]) if details: - details = "where...\n" + details + details = "where... (most recent context last)\n" + details msg = """\ in test: {test} value of: {expr} @@ -242,13 +243,25 @@ value of: {expr} {details} """.format( test = self.ctx.label, - expr = ".".join(self._exprs), + expr = _expect_meta_current_expr(self), problem = problem, actual = actual, details = details, ) _expect_meta_call_fail(self, msg) +def _expect_meta_current_expr(self): + """Get a string representing the current expression. + + Args: + self: implicitly added. + + Returns: + [`str`] A string representing the current expression, e.g. + "foo.bar(something).baz()" + """ + return ".".join(self._exprs) + def _expect_meta_call_fail(self, msg): """Adds a failure to the test run. diff --git a/lib/private/matching.bzl b/lib/private/matching.bzl index 6093488..9bd6610 100644 --- a/lib/private/matching.bzl +++ b/lib/private/matching.bzl @@ -79,6 +79,37 @@ def _match_file_path_matches(pattern): match = lambda f: _match_parts_in_order(f.path, parts), ) +def _match_file_basename_equals(value): + """Match that a `File.basename` string equals `value`. + + Args: + value: ([`str`]) the basename to match. + + Returns: + [`Matcher`] instance + """ + return struct( + desc = "<file basename equals '{}'>".format(value), + match = lambda f: f.basename == value, + ) + +def _match_file_extension_in(values): + """Match that a `File.extension` string is any of `values`. + + See also: `file_path_matches` for matching extensions that + have multiple parts, e.g. `*.tar.gz` or `*.so.*`. + + Args: + values: ([`list`] of [`str`]) the extensions to match. + + Returns: + [`Matcher`] instance + """ + return struct( + desc = "<file extension is any of {}>".format(repr(values)), + match = lambda f: f.extension in values, + ) + def _match_is_in(values): """Match that the to-be-matched value is in a collection of other values. @@ -183,6 +214,9 @@ def _match_parts_in_order(string, parts): return False return True +def _is_matcher(obj): + return hasattr(obj, "desc") and hasattr(obj, "match") + # For the definition of a `Matcher` object, see `_match_custom`. matching = struct( # keep sorted start @@ -190,11 +224,14 @@ matching = struct( custom = _match_custom, equals_wrapper = _match_equals_wrapper, file_basename_contains = _match_file_basename_contains, + file_basename_equals = _match_file_basename_equals, file_path_matches = _match_file_path_matches, + file_extension_in = _match_file_extension_in, is_in = _match_is_in, never = _match_never, str_endswith = _match_str_endswith, str_matches = _match_str_matches, str_startswith = _match_str_startswith, + is_matcher = _is_matcher, # keep sorted end ) diff --git a/lib/private/ordered.bzl b/lib/private/ordered.bzl index c9a0ed9..dec2662 100644 --- a/lib/private/ordered.bzl +++ b/lib/private/ordered.bzl @@ -31,7 +31,7 @@ def _ordered_incorrectly_new(format_problem, format_actual, meta): Args: format_problem: (callable) accepts no args and returns string (the reported problem description). - format_actual: (callable) accepts not args and returns tring (the + format_actual: (callable) accepts not args and returns string (the reported actual description). meta: ([`ExpectMeta`]) used to report the failure. diff --git a/lib/private/struct_subject.bzl b/lib/private/struct_subject.bzl new file mode 100644 index 0000000..7822341 --- /dev/null +++ b/lib/private/struct_subject.bzl @@ -0,0 +1,108 @@ +# Copyright 2023 The Bazel Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""# StructSubject + +A subject for arbitrary structs. This is most useful when wrapping an ad-hoc +struct (e.g. a struct specific to a particular function). Such ad-hoc structs +are usually just plain data objects, so they don't need special functionality +that writing a full custom subject allows. If a struct would benefit from +custom accessors or asserts, write a custom subject instead. + +This subject is usually used as a helper to a more formally defined subject that +knows the shape of the struct it needs to wrap. For example, a `FooInfoSubject` +implementation might use it to handle `FooInfo.struct_with_a_couple_fields`. + +Note the resulting subject object is not a direct replacement for the struct +being wrapped: + * Structs wrapped by this subject have the attributes exposed as functions, + not as plain attributes. This matches the other subject classes and defers + converting an attribute to a subject unless necessary. + * The attribute name `actual` is reserved. + + +## Example usages + +To use it as part of a custom subject returning a sub-value, construct it using +`subjects.struct()` like so: + +```starlark +load("@rules_testing//lib:truth.bzl", "subjects") + +def _my_subject_foo(self): + return subjects.struct( + self.actual.foo, + meta = self.meta.derive("foo()"), + attrs = dict(a=subjects.int, b=subjects.str), + ) +``` + +If you're checking a struct directly in a test, then you can use +`Expect.that_struct`. You'll still have to pass the `attrs` arg so it knows how +to map the attributes to the matching subject factories. + +```starlark +def _foo_test(env): + actual = env.expect.that_struct( + struct(a=1, b="x"), + attrs = dict(a=subjects.int, b=subjects.str) + ) + actual.a().equals(1) + actual.b().equals("x") +``` +""" + +def _struct_subject_new(actual, *, meta, attrs): + """Creates a `StructSubject`, which is a thin wrapper around a [`struct`]. + + Args: + actual: ([`struct`]) the struct to wrap. + meta: ([`ExpectMeta`]) object of call context information. + attrs: ([`dict`] of [`str`] to [`callable`]) the functions to convert + attributes to subjects. The keys are attribute names that must + exist on `actual`. The values are functions with the signature + `def factory(value, *, meta)`, where `value` is the actual attribute + value of the struct, and `meta` is an [`ExpectMeta`] object. + + Returns: + [`StructSubject`] object, which is a struct with the following shape: + * `actual` attribute, the underlying struct that was wrapped. + * A callable attribute for each `attrs` entry; it takes no args + and returns what the corresponding factory from `attrs` returns. + """ + attr_accessors = {} + for name, factory in attrs.items(): + if not hasattr(actual, name): + fail("Struct missing attribute: '{}' (from expression {})".format( + name, + meta.current_expr(), + )) + attr_accessors[name] = _make_attr_accessor(actual, name, factory, meta) + + public = struct(actual = actual, **attr_accessors) + return public + +def _make_attr_accessor(actual, name, factory, meta): + # A named function is used instead of a lambda so stack traces are easier to + # grok. + def attr_accessor(): + return factory(getattr(actual, name), meta = meta.derive(name + "()")) + + return attr_accessor + +# buildifier: disable=name-conventions +StructSubject = struct( + # keep sorted start + new = _struct_subject_new, + # keep sorted end +) diff --git a/lib/private/truth_common.bzl b/lib/private/truth_common.bzl index c7e6b60..1916901 100644 --- a/lib/private/truth_common.bzl +++ b/lib/private/truth_common.bzl @@ -1,3 +1,17 @@ +# Copyright 2023 The Bazel Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Common code used by truth.""" load("@bazel_skylib//lib:types.bzl", "types") @@ -16,6 +30,8 @@ def _informative_str(value): value_str = str(value) if not value_str: return "<empty string ∅>" + elif "\n" in value_str: + return '"""{}""" <sans triple-quotes; note newlines and whitespace>'.format(value_str) elif value_str != value_str.strip(): return '"{}" <sans quotes; note whitespace within>'.format(value_str) else: @@ -84,7 +100,7 @@ def maybe_sorted(container, allow_sorting = True): Args: container: ([`list`] | (or other object convertible to list)) allow_sorting: ([`bool`]) whether to sort even if it can be sorted. This - is primarly so that callers can avoid boilerplate when they have + is primarily so that callers can avoid boilerplate when they have a "should it be sorted" arg, but also always convert to a list. Returns: diff --git a/lib/private/util.bzl b/lib/private/util.bzl new file mode 100644 index 0000000..fc003f9 --- /dev/null +++ b/lib/private/util.bzl @@ -0,0 +1,35 @@ +"""Shared private utilities.""" + +def _do_nothing_impl(ctx): + _ = ctx # @unused + return [] + +do_nothing = rule(implementation = _do_nothing_impl) + +def get_test_name_from_function(func): + """Derives a suitable test name from a function. + + This can be used for better test feedback. + + Args: + func: (callable) A test implementation or setup function. + + Returns: + (str) The name of the given function, suitable as a test name. + """ + + # Starlark currently stringifies a function as "<function NAME>", so we use + # that knowledge to parse the "NAME" portion out. If this behavior ever + # changes, we'll need to update this. + # TODO(bazel-team): Expose a ._name field on functions to avoid this. + func_name = str(func) + func_name = func_name.partition("<function ")[-1] + func_name = func_name.rpartition(">")[0] + func_name = func_name.partition(" ")[0] + + # Strip leading/trailing underscores so that test functions can + # have private names. This better allows unused tests to be flagged by + # buildifier (indicating a bug or code to delete) + return func_name.strip("_") + +get_function_name = get_test_name_from_function diff --git a/lib/test_suite.bzl b/lib/test_suite.bzl new file mode 100644 index 0000000..d26c02f --- /dev/null +++ b/lib/test_suite.bzl @@ -0,0 +1,64 @@ +"""# Test suite + +Aggregates multiple Starlark tests in a single test_suite. +""" + +load("//lib/private:util.bzl", "get_test_name_from_function") +load("//lib:unit_test.bzl", "unit_test") + +def test_suite(name, *, tests = [], basic_tests = [], test_kwargs = {}): + """Instantiates given test macros/implementations and gathers their main targets into a `test_suite`. + + Use this function to wrap all tests into a single target. + + ``` + def simple_test_suite(name): + test_suite( + name = name, + tests = [ + your_test, + your_other_test, + ] + ) + ``` + + Then, in your `BUILD` file, simply load the macro and invoke it to have all + of the targets created: + + ``` + load("//path/to/your/package:tests.bzl", "simple_test_suite") + simple_test_suite(name = "simple_test_suite") + ``` + + Args: + name: (str) The name of the suite + tests: (list of callables) Test macros functions that + define a test. The signature is `def setup(name, **test_kwargs)`, + where (positional) `name` is name of the test target that must be + created, and `**test_kwargs` are the additional arguments from the + test suite's `test_kwargs` arg. The name of the function will + become the name of the test. + basic_tests: (list of callables) Test implementation functions + (functions that implement a test's asserts). Each callable takes a + single positional arg, `env`, which is information about the test + environment (see analysis_test docs). The name of the function will + become the name of the test. + test_kwargs: (dict) Additional kwargs to pass onto each test (both + regular and basic test callables). + """ + test_targets = [] + + for setup_func in tests: + test_name = get_test_name_from_function(setup_func) + setup_func(name = test_name, **test_kwargs) + test_targets.append(test_name) + + for impl in basic_tests: + test_name = get_test_name_from_function(impl) + unit_test(name = test_name, impl = impl, **test_kwargs) + test_targets.append(test_name) + + native.test_suite( + name = name, + tests = test_targets, + ) diff --git a/lib/truth.bzl b/lib/truth.bzl index 95f1fdd..3072f65 100644 --- a/lib/truth.bzl +++ b/lib/truth.bzl @@ -44,11 +44,18 @@ def foo_test(env, target): load("//lib/private:bool_subject.bzl", "BoolSubject") load("//lib/private:collection_subject.bzl", "CollectionSubject") +load("//lib/private:default_info_subject.bzl", "DefaultInfoSubject") load("//lib/private:depset_file_subject.bzl", "DepsetFileSubject") +load("//lib/private:dict_subject.bzl", "DictSubject") load("//lib/private:expect.bzl", "Expect") +load("//lib/private:file_subject.bzl", "FileSubject") load("//lib/private:int_subject.bzl", "IntSubject") load("//lib/private:label_subject.bzl", "LabelSubject") +load("//lib/private:runfiles_subject.bzl", "RunfilesSubject") +load("//lib/private:str_subject.bzl", "StrSubject") +load("//lib/private:target_subject.bzl", "TargetSubject") load("//lib/private:matching.bzl", _matching = "matching") +load("//lib/private:struct_subject.bzl", "StructSubject") # Rather than load many symbols, just load this symbol, and then all the # asserts will be available. @@ -63,8 +70,15 @@ subjects = struct( # keep sorted start bool = BoolSubject.new, collection = CollectionSubject.new, + default_info = DefaultInfoSubject.new, depset_file = DepsetFileSubject.new, + dict = DictSubject.new, + file = FileSubject.new, int = IntSubject.new, label = LabelSubject.new, + runfiles = RunfilesSubject.new, + str = StrSubject.new, + struct = StructSubject.new, + target = TargetSubject.new, # keep sorted end ) diff --git a/lib/unit_test.bzl b/lib/unit_test.bzl new file mode 100644 index 0000000..ddbf4d7 --- /dev/null +++ b/lib/unit_test.bzl @@ -0,0 +1,46 @@ +# Copyright 2022 The Bazel Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""# Unit test + +Support for testing generic Starlark code, i.e. code that doesn't require +the analysis phase or instantiate rules. +""" + +# We have to load the private impl to avoid a circular dependency +load("//lib/private:analysis_test.bzl", "analysis_test") + +_TARGET = Label("//lib:_stub_target_for_unit_tests") + +def unit_test(name, impl, attrs = {}): + """Creates a test for generic Starlark code (i.e. non-rule/macro specific). + + Unless you need custom attributes passed to the test, you probably don't need + this and can, instead, pass your test function directly to `test_suite.tests`. + + See also: analysis_test, for testing analysis time behavior, such as rules. + + Args: + name: (str) the name of the test + impl: (callable) the function implementing the test's asserts. It takes + a single position arg, `env`, which is information about the + test environment (see analysis_test docs). + attrs: (dict of str to str) additional attributes to make available to + the test. + """ + analysis_test( + name = name, + impl = lambda env, target: impl(env), + target = _TARGET, + attrs = attrs, + ) diff --git a/lib/utils.bzl b/lib/utils.bzl deleted file mode 100644 index ee41485..0000000 --- a/lib/utils.bzl +++ /dev/null @@ -1,37 +0,0 @@ -# Copyright 2022 The Bazel Authors. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Utility functions to use in analysis tests.""" - -def find_action(env, artifact): - """Finds the action generating the artifact. - - Args: - env: The testing environment - artifact: a File or a string - Returns: - The action""" - - if type(artifact) == type(""): - basename = env.target.label.package + "/" + artifact.format( - name = env.target.label.name, - ) - else: - basename = artifact.short_path - - for action in env.actions: - for file in action.actual.outputs.to_list(): - if file.short_path == basename: - return action - return None diff --git a/tests/BUILD b/tests/BUILD index 8049732..8341db6 100644 --- a/tests/BUILD +++ b/tests/BUILD @@ -16,6 +16,7 @@ load("@bazel_skylib//:bzl_library.bzl", "bzl_library") load("@bazel_skylib//rules:build_test.bzl", "build_test") load(":analysis_test_tests.bzl", "analysis_test_test_suite") load(":truth_tests.bzl", "truth_test_suite") +load(":unit_test_tests.bzl", "unit_test_test_suite") licenses(["notice"]) @@ -43,9 +44,15 @@ analysis_test_test_suite(name = "analysis_test_test_suite") truth_test_suite(name = "truth_tests") +unit_test_test_suite(name = "unit_test_test_suite") + build_test( name = "build_tests", targets = [ "//lib:util_bzl", + "//lib:unit_test_bzl", + "//lib:analysis_test_bzl", + "//lib:test_suite_bzl", + "//lib:truth_bzl", ], ) diff --git a/tests/analysis_test_tests.bzl b/tests/analysis_test_tests.bzl index 61350b0..2592a81 100644 --- a/tests/analysis_test_tests.bzl +++ b/tests/analysis_test_tests.bzl @@ -209,11 +209,57 @@ def _inspect_output_dirs_fake_rule(ctx): inspect_output_dirs_fake_rule = rule(implementation = _inspect_output_dirs_fake_rule) +######################################## +####### common_attributes_test ####### +######################################## + +def _test_common_attributes(name): + native.filegroup(name = name + "_subject") + _toolchain_template_vars(name = name + "_toolchain_template_vars") + analysis_test( + name = name, + impl = _test_common_attributes_impl, + target = name + "_subject", + attr_values = dict( + features = ["some-feature"], + tags = ["taga", "tagb"], + visibility = ["//visibility:private"], + toolchains = [name + "_toolchain_template_vars"], + # An empty list means "compatible with everything" + target_compatible_with = [], + ), + ) + +def _test_common_attributes_impl(env, target): + _ = target # @unused + ctx = env.ctx + expect = env.expect + + expect.that_collection(ctx.attr.tags).contains_at_least(["taga", "tagb"]) + + expect.that_collection(ctx.attr.features).contains_exactly(["some-feature"]) + + expect.that_collection(ctx.attr.visibility).contains_exactly([ + Label("//visibility:private"), + ]) + + expect.that_collection(ctx.attr.target_compatible_with).contains_exactly([]) + + expanded = ctx.expand_make_variables("cmd", "$(key)", {}) + expect.that_str(expanded).equals("value") + +def _toolchain_template_vars_impl(ctx): + _ = ctx # @unused + return [platform_common.TemplateVariableInfo({"key": "value"})] + +_toolchain_template_vars = rule(implementation = _toolchain_template_vars_impl) + def analysis_test_test_suite(name): test_suite( name = name, tests = [ test_change_setting, + _test_common_attributes, test_failure_testing, test_change_setting_with_failure, test_inspect_actions, diff --git a/tests/default_info_subject/BUILD.bazel b/tests/default_info_subject/BUILD.bazel new file mode 100644 index 0000000..99a29af --- /dev/null +++ b/tests/default_info_subject/BUILD.bazel @@ -0,0 +1,17 @@ +# Copyright 2023 The Bazel Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +load(":default_info_subject_tests.bzl", "default_info_subject_test_suite") + +default_info_subject_test_suite(name = "default_info_subject_tests") diff --git a/tests/default_info_subject/default_info_subject_tests.bzl b/tests/default_info_subject/default_info_subject_tests.bzl new file mode 100644 index 0000000..e6cfc10 --- /dev/null +++ b/tests/default_info_subject/default_info_subject_tests.bzl @@ -0,0 +1,126 @@ +# Copyright 2023 The Bazel Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for DefaultInfoSubject.""" + +load("//lib:analysis_test.bzl", "analysis_test") +load("//lib:test_suite.bzl", "test_suite") +load("//lib:truth.bzl", "matching", "subjects") +load("//lib:util.bzl", "util") +load("//tests:test_util.bzl", "test_util") + +_tests = [] + +def _default_info_subject_test(name): + util.helper_target( + _simple, + name = name + "_subject", + ) + analysis_test( + name = name, + target = name + "_subject", + impl = _default_info_subject_test_impl, + ) + +def _default_info_subject_test_impl(env, target): + fake_meta = test_util.fake_meta(env) + actual = subjects.default_info( + target[DefaultInfo], + meta = fake_meta, + ) + + actual.runfiles().contains_predicate( + matching.str_matches("default_runfile.txt"), + ) + test_util.expect_no_failures(env, fake_meta, "check default runfiles success") + + actual.runfiles().contains_predicate( + matching.str_matches("not-present.txt"), + ) + test_util.expect_failures( + env, + fake_meta, + "check default runfiles failure", + "not-present.txt", + ) + + actual.data_runfiles().contains_predicate( + matching.str_matches("data_runfile.txt"), + ) + test_util.expect_no_failures(env, fake_meta, "check data runfiles success") + + actual.data_runfiles().contains_predicate( + matching.str_matches("not-present.txt"), + ) + test_util.expect_failures( + env, + fake_meta, + "check data runfiles failure", + "not-present.txt", + ) + + actual.default_outputs().contains_predicate( + matching.file_path_matches("default_output.txt"), + ) + test_util.expect_no_failures(env, fake_meta, "check executable success") + + actual.default_outputs().contains_predicate( + matching.file_path_matches("not-present.txt"), + ) + test_util.expect_failures( + env, + fake_meta, + "check executable failure", + "not-present.txt", + ) + + actual.executable().path().contains("subject") + test_util.expect_no_failures(env, fake_meta, "check executable success") + + actual.executable().path().contains("not-present") + test_util.expect_failures( + env, + fake_meta, + "check executable failure", + "not-present", + ) + actual.runfiles_manifest().path().contains("MANIFEST") + test_util.expect_no_failures(env, fake_meta, "check runfiles_manifest success") + +_tests.append(_default_info_subject_test) + +def default_info_subject_test_suite(name): + test_suite( + name = name, + tests = _tests, + ) + +def _simple_impl(ctx): + executable = ctx.actions.declare_file(ctx.label.name) + ctx.actions.write(executable, "") + return [DefaultInfo( + files = depset([ctx.file.default_output]), + default_runfiles = ctx.runfiles([ctx.file.default_runfile, executable]), + data_runfiles = ctx.runfiles([ctx.file.data_runfile]), + executable = executable, + )] + +_simple = rule( + implementation = _simple_impl, + attrs = { + "default_output": attr.label(default = "default_output.txt", allow_single_file = True), + "default_runfile": attr.label(default = "default_runfile.txt", allow_single_file = True), + "data_runfile": attr.label(default = "data_runfile.txt", allow_single_file = True), + }, +) diff --git a/tests/matching/BUILD.bazel b/tests/matching/BUILD.bazel new file mode 100644 index 0000000..3464e38 --- /dev/null +++ b/tests/matching/BUILD.bazel @@ -0,0 +1,3 @@ +load(":matching_tests.bzl", "matching_test_suite") + +matching_test_suite(name = "matching_tests") diff --git a/tests/matching/matching_tests.bzl b/tests/matching/matching_tests.bzl new file mode 100644 index 0000000..6ef67e3 --- /dev/null +++ b/tests/matching/matching_tests.bzl @@ -0,0 +1,98 @@ +"""Tests for matchers.""" + +load("//lib:test_suite.bzl", "test_suite") +load("//lib:truth.bzl", "matching") + +_tests = [] + +def _file(path): + _, _, basename = path.rpartition("/") + _, _, extension = basename.rpartition(".") + return struct( + path = path, + basename = basename, + extension = extension, + ) + +def _verify_matcher(env, matcher, match_true, match_false): + # Test positive match + env.expect.where(matcher = matcher.desc, value = match_true).that_bool( + matcher.match(match_true), + expr = "matcher.match(value)", + ).equals(True) + + # Test negative match + env.expect.where(matcher = matcher.desc, value = match_false).that_bool( + matcher.match(match_false), + expr = "matcher.match(value)", + ).equals(False) + +def _contains_test(env): + _verify_matcher( + env, + matching.contains("x"), + match_true = "YYYxZZZ", + match_false = "zzzzz", + ) + +_tests.append(_contains_test) + +def _file_basename_equals_test(env): + _verify_matcher( + env, + matching.file_basename_equals("bar.txt"), + match_true = _file("foo/bar.txt"), + match_false = _file("foo/bar.md"), + ) + +_tests.append(_file_basename_equals_test) + +def _file_extension_in_test(env): + _verify_matcher( + env, + matching.file_extension_in(["txt", "rst"]), + match_true = _file("foo.txt"), + match_false = _file("foo.py"), + ) + +_tests.append(_file_extension_in_test) + +def _is_in_test(env): + _verify_matcher( + env, + matching.is_in(["a", "b"]), + match_true = "a", + match_false = "z", + ) + +_tests.append(_is_in_test) + +def _str_matchers_test(env): + _verify_matcher( + env, + matching.str_matches("f*b"), + match_true = "foobar", + match_false = "nope", + ) + + _verify_matcher( + env, + matching.str_endswith("123"), + match_true = "abc123", + match_false = "123xxx", + ) + + _verify_matcher( + env, + matching.str_startswith("true"), + match_true = "truechew", + match_false = "notbuck", + ) + +_tests.append(_str_matchers_test) + +def matching_test_suite(name): + test_suite( + name = name, + basic_tests = _tests, + ) diff --git a/tests/struct_subject/BUILD.bazel b/tests/struct_subject/BUILD.bazel new file mode 100644 index 0000000..17c9864 --- /dev/null +++ b/tests/struct_subject/BUILD.bazel @@ -0,0 +1,3 @@ +load(":struct_subject_tests.bzl", "struct_subject_test_suite") + +struct_subject_test_suite(name = "struct_subject_tests") diff --git a/tests/struct_subject/struct_subject_tests.bzl b/tests/struct_subject/struct_subject_tests.bzl new file mode 100644 index 0000000..58d18ff --- /dev/null +++ b/tests/struct_subject/struct_subject_tests.bzl @@ -0,0 +1,53 @@ +# Copyright 2023 The Bazel Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for StructSubject""" + +load("//lib:truth.bzl", "subjects") +load("//lib:test_suite.bzl", "test_suite") +load("//tests:test_util.bzl", "test_util") + +_tests = [] + +def _struct_subject_test(env): + fake_meta = test_util.fake_meta(env) + actual = subjects.struct( + struct(n = 1, x = "foo"), + meta = fake_meta, + attrs = dict( + n = subjects.int, + x = subjects.str, + ), + ) + actual.n().equals(1) + test_util.expect_no_failures(env, fake_meta, "struct.n()") + + actual.n().equals(99) + test_util.expect_failures( + env, + fake_meta, + "struct.n() failure", + "expected: 99", + ) + + actual.x().equals("foo") + test_util.expect_no_failures(env, fake_meta, "struct.foo()") + + actual.x().equals("not-foo") + test_util.expect_failures(env, fake_meta, "struct.foo() failure", "expected: not-foo") + +_tests.append(_struct_subject_test) + +def struct_subject_test_suite(name): + test_suite(name = name, basic_tests = _tests) diff --git a/tests/test_util.bzl b/tests/test_util.bzl new file mode 100644 index 0000000..837f23c --- /dev/null +++ b/tests/test_util.bzl @@ -0,0 +1,96 @@ +# Copyright 2023 The Bazel Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Utilities for testing rules_testing code.""" + +# buildifier: disable=bzl-visibility +load("//lib/private:expect_meta.bzl", "ExpectMeta") +load("//lib:truth.bzl", "matching") + +def _fake_meta(real_env): + """Create a fake ExpectMeta object for testing. + + The fake ExpectMeta object copies a real ExpectMeta object, except: + * Failures are only recorded and don't cause a failure in `real_env`. + * `failures` attribute is added; this is a list of failures seen. + * `reset` attribute is added; this clears the failures list. + + Args: + real_env: A real env object from the rules_testing framework. + + Returns: + struct, a fake ExpectMeta object. + """ + failures = [] + fake_env = struct( + ctx = real_env.ctx, + fail = lambda msg: failures.append(msg), + failures = failures, + ) + meta_impl = ExpectMeta.new(fake_env) + meta_impl_kwargs = { + attr: getattr(meta_impl, attr) + for attr in dir(meta_impl) + if attr not in ("to_json", "to_proto") + } + fake_meta = struct( + failures = failures, + reset = lambda: failures.clear(), + **meta_impl_kwargs + ) + return fake_meta + +def _expect_no_failures(env, fake_meta, case): + """Check that a fake meta object had no failures. + + NOTE: This clears the list of failures after checking. This is done + so that an earlier failure is only reported once. + + Args: + env: Real `Expect` object to perform asserts. + fake_meta: A fake meta object that had failures recorded. + case: str, a description of the case that was tested. + """ + env.expect.that_collection( + fake_meta.failures, + expr = case, + ).contains_exactly([]) + fake_meta.reset() + +def _expect_failures(env, fake_meta, case, *errors): + """Check that a fake meta object has matching error strings. + + NOTE: This clears the list of failures after checking. This is done + so that an earlier failure is only reported once. + + Args: + env: Real `Expect` object to perform asserts. + fake_meta: A fake meta object that had failures recorded. + case: str, a description of the case that was tested. + *errors: list of strings. These are patterns to match, as supported + by `matching.str_matches` (e.g. `*`-style patterns) + """ + env.expect.that_collection( + fake_meta.failures, + expr = case, + ).contains_at_least_predicates( + [matching.str_matches(e) for e in errors], + ) + fake_meta.reset() + +test_util = struct( + fake_meta = _fake_meta, + expect_no_failures = _expect_no_failures, + expect_failures = _expect_failures, +) diff --git a/tests/truth_tests.bzl b/tests/truth_tests.bzl index d5fce52..ee942f4 100644 --- a/tests/truth_tests.bzl +++ b/tests/truth_tests.bzl @@ -180,7 +180,7 @@ def _bool_subject_test(env, _target): fake_env, ["expected any of:", "None", "39", "actual: True"], env = env, - msg = "check is_in mismatchd values", + msg = "check is_in mismatched values", ) _end(env, fake_env) @@ -806,6 +806,107 @@ def _collection_not_contains_predicate_test(env, _target): _suite.append(collection_not_contains_predicate_test) +def collection_offset_test(name): + analysis_test(name, impl = _collection_offset_test, target = "truth_tests_helper") + +def _collection_offset_test(env, _target): + fake_env = _fake_env(env) + subject = truth.expect(fake_env).that_collection(["a", "b", "c"]) + + offset_value = subject.offset(0, factory = lambda v, meta: v) + ut_asserts.true(env, offset_value == "a", "unexpected offset value at 0") + + offset_value = subject.offset(-1, factory = lambda v, meta: v) + ut_asserts.true(env, offset_value == "c", "unexpected offset value at -1") + + subject.offset(1, factory = subjects.str).equals("not-b") + + _assert_failure( + fake_env, + [".offset(1)"], + env = env, + msg = "offset error message context not found", + ) + + _end(env, fake_env) + +_suite.append(collection_offset_test) + +def _collection_transform_test(name): + analysis_test(name, impl = _collection_transform_test_impl, target = "truth_tests_helper") + +def _collection_transform_test_impl(env, target): + _ = target # @unused + fake_env = _fake_env(env) + starter = truth.expect(fake_env).that_collection(["alan", "bert", "cari"]) + + actual = starter.transform( + "values that contain a", + filter = lambda v: "a" in v, + ) + actual.contains("not-present") + _assert_failure( + fake_env, + [ + "transform()", + "0: alan", + "1: cari", + "transform: values that contain a", + ], + env = env, + msg = "transform with lambda filter", + ) + + actual = starter.transform(filter = matching.contains("b")) + actual.contains("not-present") + _assert_failure( + fake_env, + [ + "0: bert", + "transform: filter=<contains b>", + ], + env = env, + msg = "transform with matcher filter", + ) + + def contains_c(v): + return "c" in v + + actual = starter.transform(filter = contains_c) + actual.contains("not-present") + _assert_failure( + fake_env, + [ + "0: cari", + "transform: filter=contains_c(...)", + ], + env = env, + msg = "transform with named function filter", + ) + + actual = starter.transform( + "v.upper(); match even offsets", + map_each = lambda v: "{}-{}".format(v[0], v[1].upper()), + loop = enumerate, + ) + actual.contains("not-present") + _assert_failure( + fake_env, + [ + "transform()", + "0: 0-ALAN", + "1: 1-BERT", + "2: 2-CARI", + "transform: v.upper(); match even offsets", + ], + env = env, + msg = "transform with all args", + ) + + _end(env, fake_env) + +_suite.append(_collection_transform_test) + def execution_info_test(name): analysis_test(name, impl = _execution_info_test, target = "truth_tests_helper") @@ -894,6 +995,14 @@ def _dict_subject_test(env, _target): fake_env = _fake_env(env) subject = truth.expect(fake_env).that_dict({"a": 1, "b": 2, "c": 3}) + def factory(value, *, meta): + return struct(value = value, meta = meta) + + actual = subject.get("a", factory = factory) + + truth.expect(env).that_int(actual.value).equals(1) + truth.expect(env).that_collection(actual.meta._exprs).contains("get(a)") + subject.contains_exactly({"a": 1, "b": 2, "c": 3}) _assert_no_failures(fake_env, env = env) @@ -1067,46 +1176,6 @@ def _label_subject_test(env, target): _suite.append(label_subject_test) -def matchers_contains_test(name): - analysis_test(name, impl = _matchers_contains_test, target = "truth_tests_helper") - -def _matchers_contains_test(env, _target): - fake_env = _fake_env(env) - ut_asserts.true(env, matching.contains("x").match("YYYxZZZ")) - ut_asserts.false(env, matching.contains("x").match("zzzzz")) - _end(env, fake_env) - -_suite.append(matchers_contains_test) - -def matchers_str_matchers_test(name): - analysis_test(name, impl = _matchers_str_matchers_test, target = "truth_tests_helper") - -def _matchers_str_matchers_test(env, _target): - fake_env = _fake_env(env) - - ut_asserts.true(env, matching.str_matches("f*b").match("foobar")) - ut_asserts.false(env, matching.str_matches("f*b").match("nope")) - - ut_asserts.true(env, matching.str_endswith("123").match("abc123")) - ut_asserts.false(env, matching.str_endswith("123").match("123xxx")) - - ut_asserts.true(env, matching.str_startswith("true").match("truechew")) - ut_asserts.false(env, matching.str_startswith("buck").match("notbuck")) - _end(env, fake_env) - -_suite.append(matchers_str_matchers_test) - -def matchers_is_in_test(name): - analysis_test(name, impl = _matchers_is_in_test, target = "truth_tests_helper") - -def _matchers_is_in_test(env, _target): - fake_env = _fake_env(env) - ut_asserts.true(env, matching.is_in(["a", "b"]).match("a")) - ut_asserts.false(env, matching.is_in(["x", "y"]).match("z")) - _end(env, fake_env) - -_suite.append(matchers_is_in_test) - def runfiles_subject_test(name): analysis_test(name, impl = _runfiles_subject_test, target = "truth_tests_helper") diff --git a/tests/unit_test_tests.bzl b/tests/unit_test_tests.bzl new file mode 100644 index 0000000..97ec99c --- /dev/null +++ b/tests/unit_test_tests.bzl @@ -0,0 +1,28 @@ +"""Tests for unit_test.""" + +load("//lib:unit_test.bzl", "unit_test") +load("//lib:test_suite.bzl", "test_suite") + +def _test_basic(env): + _ = env # @unused + +def _test_with_setup(name): + unit_test( + name = name, + impl = _test_with_setup_impl, + attrs = {"custom_attr": attr.string(default = "default")}, + ) + +def _test_with_setup_impl(env): + env.expect.that_str(env.ctx.attr.custom_attr).equals("default") + +def unit_test_test_suite(name): + test_suite( + name = name, + tests = [ + _test_with_setup, + ], + basic_tests = [ + _test_basic, + ], + ) |