aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRichard Levasseur <rlevasseur@google.com>2023-07-04 22:51:51 -0700
committerRichard Levasseur <rlevasseur@google.com>2023-07-07 16:21:33 -0700
commit227369b14e493f98ed9f04bf86e512a7643aecf0 (patch)
treee32d99cafa0715162dab66a7c591c10d2c07b538
parent3694f0e1f59e29f72fae4c3f4e6e84e35b04036b (diff)
downloadbazelbuild-rules_testing-227369b14e493f98ed9f04bf86e512a7643aecf0.tar.gz
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
-rw-r--r--docgen/BUILD1
-rw-r--r--docs/crossrefs.md2
-rw-r--r--lib/BUILD1
-rw-r--r--lib/private/BUILD6
-rw-r--r--lib/private/expect.bzl17
-rw-r--r--lib/private/struct_subject.bzl108
-rw-r--r--lib/private/truth_common.bzl16
-rw-r--r--lib/truth.bzl2
-rw-r--r--tests/struct_subject/BUILD.bazel3
-rw-r--r--tests/struct_subject/struct_subject_tests.bzl53
-rw-r--r--tests/test_util.bzl96
11 files changed, 305 insertions, 0 deletions
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
@@ -216,6 +216,11 @@ bzl_library(
)
bzl_library(
+ name = "struct_subject_bzl",
+ srcs = ["struct_subject.bzl"],
+)
+
+bzl_library(
name = "target_subject_bzl",
srcs = ["target_subject.bzl"],
deps = [
@@ -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 "<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:
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,
+)