feat(testing): add IsElementOf / IsKeyOf / IsValueOf matchers#181
Merged
Conversation
gmock's `Contains(value)` puts the container on the left and the
element on the right. The opposite orientation — "this value must be
a member of this container" — has no built-in matcher. `AnyOfArray`
is close but parameterizes its inner matchers on the container's
`value_type`, so the subject must be the same (or strictly
implicitly-convertible) type; a `const char*` subject against a
`std::vector<std::string>` fails to compile.
This adds four helpers in `mbo/testing/matchers.h`, each a custom
polymorphic matcher using the outer/Impl pattern already established
by `CapacityIsMatcher` and `WhenTransformedByMatcher`:
* `IsElementOf(container)` — value-on-the-left matcher. Iterates
the container and compares via raw `operator==`, so any
equality-comparable subject works without explicit conversion
(string literal vs `vector<string>`, `string_view` vs
`map<string, ...>` keys, etc.). Container is taken by value to
keep temporaries and initializer lists safe.
* `AllKeys(map)` / `AllValues(map)` — eager projections returning
`std::vector<key_type>` / `std::vector<mapped_type>` for
composition with element-oriented matchers.
* `IsKeyOf(map)` — short-hand for `IsElementOf(AllKeys(map))`.
Tests cover happy path, rvalue/initializer-list containers, empty
containers, heterogeneous element types (string literal, string_view,
string), `std::map`, `std::unordered_map` (hash-order-independent),
`std::multimap` (duplicate keys preserved), and exact
Describe/DescribeNegation/MatchAndExplain strings for matching and
non-matching subjects.
Also drops `clang-format@19.1.6` from `.trunk/trunk.yaml`: pre-commit
already runs clang-format at 19.1.6 via mirrors-clang-format and
covers both Linux and macOS-arm64, while trunk.io's CDN does not host
the macOS-arm64 binary for that version. With pre-commit owning
clang-format, there's no version split and no install failure. CI's
existing pre-commit job still enforces formatting.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Several IsElementOf / IsKeyOf assertions wrapped the left-hand value
in std::string{...} even though IsElementOf is built specifically so
the subject does not need conversion to the container's value_type.
The conversions silently masked the case the matcher was designed
to solve — left-hand string literals should compile and match
against a vector<string> / map<string, ...> directly.
Lines 352, 366, 367 retain std::string{...} on purpose: those tests
exercise all three subject shapes (const char[], string_view, string)
to prove each one works.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two new tests pin down the no-conversion-on-the-left guarantee in
two more shapes:
* IsElementOfVectorOfStrings: const char[] subject vs
std::vector<std::string> elements.
* IsElementOfListOfStringPairs: std::pair<const char*, const char*>
subject vs std::list<std::pair<std::string, std::string>>
elements, relying on pair::operator== to compare componentwise
using string == const char*.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous AllKeysAndValuesFromMap / AllKeysAndValuesFromEmptyMap / AllKeysFromUnorderedMap tests put `AllKeys(m)` and `AllValues(m)` on the left as the subject under test (paired with `ElementsAre` / `IsEmpty` / `UnorderedElementsAre`). That's the very orientation `IsElementOf` exists to avoid. Rewrite each as `EXPECT_THAT(element, IsElementOf(AllKeys(m)))` so the projections appear inside the matcher on the right — the form callers will actually use. Each test now checks both a present and an absent element so the assertion is non-trivial; the empty-map case checks several distinct subjects to make the "nothing is in empty" property concrete. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The earlier round bounced between IsElementOf and ElementsAre / UnorderedElementsAre — different matchers. ElementsAre tests the projection function's exact return value with the container on the left; IsElementOf is what we are actually testing in this PR and puts the element on the left. Replace all AllKeys/AllValues assertions with the IsElementOf form across std::map, std::unordered_map and std::multimap. Each case enumerates every present key (and a representative absent key) for both AllKeys and AllValues. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ions
Three connected changes:
* Add `IsValueOf(map)` matcher parallel to `IsKeyOf(map)`. With
both, callers never need to write `IsElementOf(AllValues(map))`
or `IsElementOf(AllKeys(map))` directly.
* Move `AllKeys` / `AllValues` from `mbo::testing` to the
`mbo::testing::testing_internal` namespace. Their only sensible
use is inside a matcher on the right side of `EXPECT_THAT`; the
public API never wants them as a subject on the left.
* Give `IsElementOfMatcher` configurable description-lead strings.
`IsKeyOf` describes as "is a key of {1, 2}" / "is not a key of
{1, 2}", `IsValueOf` as "is a value of {\"a\", \"b\"}" /
"is not a value of {\"a\", \"b\"}", `IsElementOf` keeps the
existing "is element of {…}" wording. Failure messages now
reflect which projection was applied.
Tests updated to use the new matchers and to pin the exact new
description strings for IsKeyOf / IsValueOf (and their empty-map
edge cases).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two CI fixes:
* Trunk plugin v1.10.0 does not host clang-tidy 19.1.7 on
trunk.io's CDN (HTTP 403 on linux-x86_64 — same kind of gap we
already hit for clang-format on macos-arm64). Drop the version
to 16.0.3, which is the plugin's known_good_version and is
hosted on every platform. Pre-commit has no clang-tidy hook, so
trunk remains the sole runner; only the version moves.
* Apply the two clang-format fixes pre-commit was asking for: a
line-collapse in IsValueOf and a comment-alignment in the
heterogeneous IsElementOf test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GCC 13 + libstdc++ rejects std::pair<const char*, const char*> == std::pair<std::string, std::string> at the compile level, because C++20 narrowed std::pair::operator== to same types only. libc++ on macOS quietly allows it via implicit pair conversion; libstdc++ does not. The matcher's "no caller-side conversion needed" promise broke for the pair-of-string-literals case on Linux as a result. Add a small ElementEqual helper that uses raw operator== when the two sides are directly equality-comparable, and falls back to a component-wise pair comparison otherwise. The recursive pair branch compares `first` and `second` independently using the same helper, so any combination of heterogeneous element types that are individually comparable (e.g. const char* against std::string) keeps working. Wire IsElementOf's MatchAndExplain through ElementEqual instead of raw ==. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Blank line after the empty IsStdPair primary template and collapse the `else if constexpr` onto a single line, per clang-format 19.1.6.
The new IsElementOf / IsKeyOf / IsValueOf matchers warrant a minor version bump. Update the three places that need to move together: * MODULE.bazel: 0.10.0 -> 0.11.0 * CHANGELOG.md: add a 0.11.0 section listing the new matchers * README.md: list the new matchers in the testing/matchers section The `compare-versions` pre-commit hook checks that MODULE.bazel and CHANGELOG.md stay in sync. test-bcr's `module_version: [0.10.0]` matrix entry is intentionally left alone — it tests against the latest Bazel Central Registry release, which is still 0.10.0 until this PR ships. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CHANGELOG.md uses one top-level `#` heading per version, and the `compare-versions` pre-commit hook + `tools/trigger_release.sh` both depend on that exact regex (`^# ([0-9]...)$`) to extract the current version. markdownlint's MD025 (single-h1) would force every version heading to `##`, which would break the version-detection scripts in two places. Disable `single-h1` in .trunk/configs/.markdownlint.yaml. The alternative — bumping every heading to `##` plus updating both scripts — is a much larger and riskier change for a stylistic rule. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fab-Cat
approved these changes
May 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
gmock's
Contains(value)puts the container on the left and the element on the right; the opposite orientation — is this value a member of that container? — has no built-in matcher.AnyOfArrayis the closest fit but parameterizes its inner matchers on the container'svalue_type, soEXPECT_THAT("a", AnyOfArray(vector<string>{...}))fails to compile because the subject must be the same (or strictly implicitly-convertible) type.This adds three public matchers in
mbo/testing/matchers.h, all using the outer/Impl pattern already established byCapacityIsMatcher/WhenTransformedByMatcher:IsElementOf(container)— value-on-the-left matcher. Comparison goes through atesting_internal::ElementEqualhelper that uses rawoperator==when the two sides are directly equality-comparable, and falls back to component-wise comparison forstd::pair. This letsconst char*/std::string_view/std::stringsubjects all match against astd::vector<std::string>(orstd::map<std::string, ...>keys, etc.) without caller-side conversion, and letsstd::pair<const char*, const char*>match againststd::list<std::pair<std::string, std::string>>even on libstdc++ (C++20 narrowedstd::pair::operator==to same-types-only; libc++ accepts heterogeneous via implicit pair conversion, libstdc++ does not).IsKeyOf(map)— convenience matcher: "is this value among the keys ofmap?". Failure messages say"is a key of {1, 2}"/"is not a key of {1, 2}".IsValueOf(map)— parallel toIsKeyOf: "is this value among the mapped values ofmap?". Failure messages say"is a value of {\"a\", \"b\"}"/"is not a value of {\"a\", \"b\"}".AllKeys/AllValues(the eagerstd::vector<key_type>/std::vector<mapped_type>projections) are kept in thetesting_internalnamespace, not exposed publicly. The shapes they enable —IsElementOf(AllKeys(m))andIsElementOf(AllValues(m))— are exactlyIsKeyOf/IsValueOf, and keeping the projections internal prevents them from being misused as left-side subjects (EXPECT_THAT(AllKeys(m), ...)), which would invert the orientation the matcher exists to provide.Description leads
IsElementOfMatchercarries configurable positive / negative description leads so failure messages reflect which projection was applied:IsElementOf(c)→"is element of {…}"/"is not element of {…}"IsKeyOf(m)→"is a key of {…}"/"is not a key of {…}"IsValueOf(m)→"is a value of {…}"/"is not a value of {…}".trunk/trunk.yamladjustmentsclang-format@19.1.6: pre-commit'smirrors-clang-formatalready pins 19.1.6 and works on every platform, while trunk.io's CDN does not host the macOS-arm64 binary for that version. Removing it from trunk eliminates the install failure and the version-split risk; CI's existingpre-commitjob continues to enforce formatting.clang-tidy@19.1.7 → clang-tidy@16.0.3: trunk.io's CDN also doesn't hostclang-tidy-19.1.7-linux-x86_64.tar.gz. 16.0.3 is the plugin'sknown_good_versionand is available on every platform. Pre-commit has no clang-tidy hook, so trunk remains the sole runner; only the version moves.Test plan
bazel test //mbo/testing:matchers_test— new tests pass locally (macOS-arm64, libc++)const char[],std::string_view,std::stringagainststd::vector<std::string>andstd::map<std::string, ...>std::list<std::pair<std::string, std::string>>— exercises theElementEqualpair fallback (relevant on libstdc++)Describe/DescribeNegation/MatchAndExplainstrings asserted forIsElementOf,IsKeyOf, andIsValueOf(including empty-map edge cases)pre-commit,trunk, andtest-gcc(linux + libstdc++)🤖 Generated with Claude Code