From 227369b14e493f98ed9f04bf86e512a7643aecf0 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Tue, 4 Jul 2023 22:51:51 -0700 Subject: [PATCH] feat: Add StructSubject StructSubject is a subject to wrap structs and return their values as other subjects. This makes it easier to test ad-hoc struct values, such as ones returned by helper functions, because a dedicated subject implementation doesn't need to be written. All that needs to be provided are the attribute names and factory functions to handle them. Fixes https://github.com/bazelbuild/rules_testing/issues/53 --- docgen/BUILD | 1 + docs/crossrefs.md | 2 + lib/BUILD | 1 + lib/private/BUILD | 6 + lib/private/expect.bzl | 17 +++ lib/private/struct_subject.bzl | 108 ++++++++++++++++++ lib/private/truth_common.bzl | 16 +++ lib/truth.bzl | 2 + tests/struct_subject/BUILD.bazel | 3 + tests/struct_subject/struct_subject_tests.bzl | 53 +++++++++ tests/test_util.bzl | 96 ++++++++++++++++ 11 files changed, 305 insertions(+) create mode 100644 lib/private/struct_subject.bzl create mode 100644 tests/struct_subject/BUILD.bazel create mode 100644 tests/struct_subject/struct_subject_tests.bzl create mode 100644 tests/test_util.bzl diff --git a/docgen/BUILD b/docgen/BUILD index 774964a..7c11e2a 100644 --- a/docgen/BUILD +++ b/docgen/BUILD @@ -43,6 +43,7 @@ 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", ], tags = ["docs"], 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/lib/BUILD b/lib/BUILD index 6ecb821..1495a2d 100644 --- a/lib/BUILD +++ b/lib/BUILD @@ -46,6 +46,7 @@ bzl_library( "//lib/private:int_subject_bzl", "//lib/private:label_subject_bzl", "//lib/private:matching_bzl", + "//lib/private:struct_subject_bzl", ], ) diff --git a/lib/private/BUILD b/lib/private/BUILD index 7e0235f..bc9963f 100644 --- a/lib/private/BUILD +++ b/lib/private/BUILD @@ -215,6 +215,11 @@ bzl_library( ], ) +bzl_library( + name = "struct_subject_bzl", + srcs = ["struct_subject.bzl"], +) + bzl_library( name = "target_subject_bzl", srcs = ["target_subject.bzl"], @@ -247,6 +252,7 @@ bzl_library( ":file_subject_bzl", ":int_subject_bzl", ":str_subject_bzl", + ":struct_subject_bzl", ":target_subject_bzl", ], ) diff --git a/lib/private/expect.bzl b/lib/private/expect.bzl index 0f0ef5a..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 @@ -207,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`. @@ -257,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, @@ -267,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/struct_subject.bzl b/lib/private/struct_subject.bzl new file mode 100644 index 0000000..3a3c71a --- /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().quals("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..ce249d6 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 "" + elif "\n" in value_str: + return '"""{}""" '.format(value_str) elif value_str != value_str.strip(): return '"{}" '.format(value_str) else: diff --git a/lib/truth.bzl b/lib/truth.bzl index 51e8093..e1736e9 100644 --- a/lib/truth.bzl +++ b/lib/truth.bzl @@ -54,6 +54,7 @@ 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. @@ -75,6 +76,7 @@ subjects = struct( label = LabelSubject.new, runfiles = RunfilesSubject.new, str = StrSubject.new, + struct = StructSubject.new, target = TargetSubject.new, # keep sorted end ) 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, +)