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>
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 @@
     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 @@
 # 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 @@
 
 # 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")
diff --git a/README.md b/README.md
index 9582b1f..6721801 100644
--- a/README.md
+++ b/README.md
@@ -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 @@
 * `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 @@
     # 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 @@
         "//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 @@
             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.
diff --git a/lib/BUILD b/lib/BUILD
index 8d612b3..e2c3af7 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -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 @@
     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 @@
     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(
+    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 @@
         "//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
+        **kwargs: Args passed through to test_suite
     """
-
-    # 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.
-    """
-    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 @@
 )
 
 bzl_library(
+    name = "analysis_test_bzl",
+    srcs = ["analysis_test.bzl"],
+)
+
+bzl_library(
     name = "matching_bzl",
     srcs = ["matching.bzl"],
 )
@@ -58,6 +63,7 @@
         ":int_subject_bzl",
         ":matching_bzl",
         ":truth_common_bzl",
+        ":util_bzl",
     ],
 )
 
@@ -120,6 +126,16 @@
 )
 
 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(
+    name = "struct_subject_bzl",
+    srcs = ["struct_subject.bzl"],
+)
+
+bzl_library(
     name = "target_subject_bzl",
     srcs = ["target_subject.bzl"],
     deps = [
@@ -241,6 +262,12 @@
         ":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(":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 @@
     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 @@
         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 @@
         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 @@
 
     # 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 @@
         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(":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 @@
         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 @@
         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 @@
     """
     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 @@
 # 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 @@
     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 @@
     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 @@
         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 @@
         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 @@
         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 @@
 {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 @@
         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 @@
             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 @@
     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 @@
     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 @@
     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 @@
     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 @@
 
 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 @@
     # 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//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 @@
 
 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 @@
 
 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 @@
         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 @@
 
 _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 @@
     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 @@
 
 _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,
+        ],
+    )