C++ Style
The C++ coding style for MBO Works repositories. It sits on top of two
machine-enforced config files and adds the human conventions below. When in doubt
the config files win; this document explains and extends them so a contributor (or
an AI assistant) can follow them without reverse-engineering the tooling.
Toolchain (machine-enforced)
- C++20, compiled with
clang. Compilation with GCC is kept working on a
best-effort basis. clang-formatwith.clang-formatformats all C++ code. Run
it; do not hand-format against it. CI rejects any reformatting diff.clang-tidywith.clang-tidy(WarningsAsErrors: '*') runs via the
opt-inclang-tidypre-commit hook (pre-commit run clang-tidy --all-files --hook-stage manual,
which shells out totools/clang_tidy.sh) against acompile_commands.json
you generate with./compile_commands-update.sh. It is report-only
(never--fix) and needs a hermetic clang-tidy (>= clang-22 for this C++23 code); it skips cleanly
when either is missing. It is not run bytrunk(trunk pinned clang-tidy 16, which mis-parses
C++23 and auto-applied build-breaking fixes - do not re-add it there). In CI the dedicated
clang-tidyjob owns it: it builds the compile DB (compile_commands-update.sh, hermetic clang)
and runs this hook, report-only (continue-on-error) until the finding sweep lands, then a hard
gate. A branch lints only the sources it changed;mainlints the whole tree. The enabled set is
broad:abseil-*,bugprone-*,cppcoreguidelines-*,google-*,misc-*,modernize-*,
performance-*,portability-*,readability-*.
What .clang-format decides (do not fight it)
- Google base style, column limit 120.
AlignAfterOpenBracket: AlwaysBreak+BinPackParameters: false: when arguments
do not fit, each goes on its own line at one indent (not aligned to the paren).PointerAlignment: Left(int* p),QualifierAlignment: Left(const int).IntegerLiteralSeparatordecimal every 3: write1'000'000.RemoveSemicolon,InsertBraces(always brace bodies),SeparateDefinitionBlocks.InsertTrailingCommasis deliberately OFF (it would force every aggregate to
never-bin-pack). That is the lever behind the trailing-comma rule below: a manual
trailing comma opts a single aggregate into one-element-per-line.
Naming (enforced by .clang-tidy readability-identifier-naming)
- Types / classes / structs / enums / aliases / functions:
CamelCase(LimitedMap). - Variables / parameters / data members / namespaces:
lower_case(flag_count). - Private data members:
lower_casewith a trailing_(flags_). - constexpr / enum constants / global + static constants:
k+CamelCase(kMaxDepth). - Macros:
UPPER_CASE, prefixedMBO_(MBO_...).
Code organization
- All exported code lives under the top library directory (in mbo:
mbo/). - Each subdirectory uses namespace
mbo::{dir}(andmbo::{dir}::{sub}).- Internal-only code:
mbo::{dir}::{sub}_internal. No namespace component is ever
justinternal. - Exported-but-detail code may use a
detailsub-namespace (fully qualify it, since
the baredetailname can collide).
- Internal-only code:
- Header guards:
{PATH}_{FILE}_(path + filename, uppercased, non-alphanumerics ->
_, trailing_). A file may not start with a sibling directory's basename +_:
foo/bar.handfoo_bar.hwould both yieldFOO_BAR_H_. - Forward-declared symbols must be fully implemented in the same source file (except
theImpl-in-header / implementation-in-.ccpattern). - Macros: avoid them. An exported macro (defined in a header for other code to use) is
prefixedMBO_(private implMBO_PRIVATE_...). A macro local to one translation unit- a
.cc/ test file, or a header helper you#undefright after its last use - may stay
unprefixed (stillUPPER_CASE);#undefa local macro as soon after its last use as
possible.
- a
- C-library names: use the
std::form; the unqualified global alias is optional. A
<cstddef>/<cXXX>header is only required to declaresize_t,int64_t,memcpy,
... innamespace std; whether it also injects the bare global name is
implementation-defined, so we never rely on it.- In headers, always fully qualify (
std::size_t); no namespace-levelusing. - In an implementation file (
.cc), ausing std::size_t;(or otherusing std::...)
is fine where it reads better - bring the name in explicitly rather than leaning on the
global alias. Be consistent within a file, but readability outranks consistency.
- In headers, always fully qualify (
- Library flags are prefixed by their path/namespace (e.g.
--mbo_log_timing_min_duration
inmbo/log). Application entry-point binaries (glob,diff,mope) may use bare flag
names (--depth,--algorithm,--template).
Formatting conventions on top of clang-format
clang-format picks a layout per line; these habits steer it toward the readable one.
-
No comment at the end of a long line. Put the comment on its own line above
the element. A trailing// ...that pushes a line past 120 makes clang-format
explode the element across several lines.// -exec run in the matched entry's directory {.name = "-execdir", .kind = Kind::kAction, .arity = -1},
-
Trailing comma on the last field of a complex aggregate breaks it one field per
line. BecauseInsertTrailingCommasis off, the comma is your per-aggregate opt-in.
Use it for long/complex initializers; a short one that reads on a single line stays.const Drop drop{ .line = line, .layer = Source::kProject, .safety = Safety::kSecurity, };
In a long array of struct literals - a registry-style table such as
kGlobalsor
kDescriptors- put the trailing comma on every element, even the short ones that
would fit on a single line, so clang-format expands the whole table uniformly, one
field per line. A consistent table you scroll through reads better than a mix of
one-liners and exploded rows packed to save height. This stays a deliberate, per-table
choice made element by element:InsertTrailingCommasis off (we do not always want
trailing commas), so clang-format never forces it for you. -
Force a line break with a comment rather than let clang-format cram a value at the right
margin. A long argument - especially a raw string such as a protoR"pb(...)pb"- otherwise
gets packed onto the call line and shoved against the 120 column, unreadable. A trailing
comment makes clang-format keep the element on its own line. Mark it// NL("new line"):EXPECT_THAT( // NL message, EqualsProto(R"pb(name: "n" value: 1)pb"));
When there is a relevant reason, keep the prefix and add it:
// NL: <short reason>, preferred
over a bare// NL. Always keep theNLprefix - do not drop to a bare//or an unprefixed
comment - for two reasons: it marks the comment as load-bearing for layout, so a reader knows
that removing it re-crams the line; and the consistent marker is machine-checkable, so a
pre-commit rule can verify these lines stay broken.Reserve
// clang-format off/onfor a genuine table or hand-aligned expression that
clang-format cannot lay out -// NLonly inserts breaks, so it cannot keep an over-120
line whole or stop a reflow (e.g. a one-line-per-case test table, or a multi-clause
requires(...)). Do not use it to hand-place ordinary layout, and keep theoff/onpair
tight and adjacent so theonis never forgotten: everything between the two loses every
formatting guarantee above.
Idioms
-
A
cc_librarytarget is named<thing>_cc(file_cc,diff_options_cc,hash_cc), so a
label reads as "the C++ library for<thing>" and never collides with the directory,
header, or binary of the same name.cc_testkeeps its_testsuffix, and acc_binary
keeps the plain program name (mope,diff). Enforced by thecc-target-namingpre-commit
hook (tools/check_cc_target_naming.py), so the
convention cannot drift back the way four extras targets did. -
Every
cc_libraryhas a test in its own package depending on it. This is the
Testing section's "all exported code must be tested" stated as a build rule, and it is
enforced by thecheck-cc-library-testedpre-commit hook
(tools/check_cc_library_tested.py). Two things
deliberately do not count: transitive coverage (a test of a library that merely
deps yours) and a non-test dependent (acc_binary, abashtest) - neither exercises
your unit directly. Same-package is part of the rule too: the test belongs next to the
code it covers. A library with genuinely no testable surface goes into the tool's
_ALLOWLISTwith its reason recorded there (today's single entry is
//mbo/container:limited_set_benchmark_cc, the benchmark harness, which makes no
correctness claim a test could check). -
Never range-iterate an inline braced-init-list; iterate a NAMED
constexpr std::array. A loop
over{a, b, c}hides what the set IS behind the mechanics of visiting it, gives the reader nothing
to grep for, and puts the data at the point of use so the next case that needs the same set copies it
instead of sharing it. Build the set withstd::to_array<T>({...})(a trailing comma on the last
element, soclang-formatbreaks it one per line - see the Formatting section) and give it ak
name that says what the set is, not what the loop does.static constexprinside a function when it
is used once; at namespace scope in the anonymous namespace when more than one case needs it.
Deducingstd::arrayfromto_arrayalso keeps the element type in exactly one place, and an
explicit type stops a literal from being deduced asconst char*wherestd::string_viewwas meant.// POSIX precedence: LC_ALL overrides LC_CTYPE, which overrides LANG. static constexpr std::array kLocaleVars = std::to_array<std::string_view>({ "LC_ALL", "LC_CTYPE", "LANG", }); for (const std::string_view var : kLocaleVars) { // not: for (... : {"LC_ALL", "LC_CTYPE", "LANG"})
Enforced by the
no-braced-init-list-looppre-commit hook, so it cannot drift back. This is about a
RANGE-FOR over a literal list; passing a braced list as an argument (ElementsAre, astd::vector
initializer, an aggregate) is untouched by the rule. -
Prefer range-based loops. Use an index only when the index is part of the operation. Do not
scan backward or repeatedly rescan preceding elements; carry the needed state forward. -
Comparison functions name their parameters
lhsandrhs- a sort comparator, an
operator==/operator<=>, any two-things-of-one-type predicate. Nevera/b(which
readability-identifier-lengthrejects at under 2 characters) and notx/yeither:lhs/rhs
names the ROLE, solhs.name < rhs.namereads as the ordering it implements.absl::c_sort(topics, [](const HelpTopic& lhs, const HelpTopic& rhs) { return lhs.name < rhs.name; });
-
A defaulted move constructor and move assignment are explicitly
noexcept.
Type(Type&&) noexcept = default;andType& operator=(Type&&) noexcept = default;. A
defaulted move is only implicitlynoexceptwhen every base and member move is, so one
member that is not silently makes the whole type's move throwing - and the standard library
then quietly downgrades:std::vectorreallocation copies instead of moving
(move_if_noexcept), andstd::swapand friends lose their strong guarantee. Spelling
noexceptout states the intent and turns a violation into a compile error rather than a
silent performance loss. The same applies to a defaulted destructor's implicitnoexcept,
which needs no annotation. -
An initialised array constant uses
std::to_array, never a hand-counted size.
static constexpr auto kExamples = std::to_array<DocPair>({...});, not
static constexpr std::array<DocPair, 6> kExamples = {...}. The literal size is a second
statement of something the initialiser already says, and the two drift: adding an entry and
forgetting the count is a compile error at best and a silently truncated or
default-padded array at worst.to_arraydeduces the extent, so there is nothing to keep in
sync. Keep an explicit extent only where the size is part of the CONTRACT rather than a count
of what was written - a fixed-size buffer, a spec-mandated width (std::array<uint64_t, 8>for
BLAKE2b's state), or a function parameter type, where the extent IS the type. -
Pass
absl::Statusby value, notconst&: it is a tagged pointer, and
.clang-tidy performance-unnecessary-value-paramallowlists it (withabsl::StatusOr
andstd::string_view).StatusOr<T>'s cost depends onT. -
Prefer container algorithms from
absl/algorithm/container.h(absl::c_contains,
c_any_of,c_find,c_equal,c_sort, ...) or C++20std::rangesover hand-rolled
loops: range-based (no begin/end), well-named, constexpr-friendly. A loop that returns
on the first match is anabsl::c_any_ofwith a lambda; a reserve-then-push copy into a
std::vectoris range constructionstd::vector<T>(src.begin(), src.end()). Keep a raw
loop only when it builds a non-trivial structure no algorithm expresses cleanly. -
Container choice: prefer the Abseil containers over the bare
std::ones. The reason is
in their favor, not againststd::: the Abseil variants are faster, support transparent
(heterogeneous) lookup - find in astd::string-keyed map with astd::string_view, no
temporarystd::string- andflat_hash_*stores elements inline for lower memory.- Never
std::unordered_*- no<unordered_map>/<unordered_set>include, and none
ofunordered_map/unordered_set/unordered_multimap/unordered_multiset. Use
absl::flat_hash_map/flat_hash_set, or thenode_hash_map/node_hash_setvariants
when you need pointer/reference stability. This holds in tests too - the one exception is a
container-compatibility test that deliberately exercises astd::unordered_*input. - Avoid
std::map/std::set(and themultivariants) in library code; prefer
absl::btree_map/absl::btree_set, which keep the same ordered interface but are
cache-friendlier. In tests,std::map/std::setare fine. - Small compile-time tables:
mbo::container::LimitedMap/LimitedSet. - A type-detection trait may name a
std::container type freely - it must, to recognize it.
- Never
-
A read-only string parameter is
std::string_view(by value), notconst std::string&.
This is pretty much always: astd::string_viewbinds to astd::string, a string literal,
achar*, or another view with no allocation and no.c_str()dance, so theconst&only
narrows what callers may pass. Keepconst std::string&(orstd::stringby value) only
when the body genuinely needs astd::string- it calls.c_str()for a C API, stores or
moves the argument, or passes it to something that itself wantsconst std::string&. -
A by-value
std::string_viewfollows ordinary const-correctness:constwhen you never
mutate the view, non-constonly when you do. The characters it views are alreadyconst,
but the view itself is a normal local:constdocuments that you never reslice or reassign it,
andmisc-const-correctness(enabled) flags a never-mutated view that is notconst. Drop the
constexactly when you mutate the view in place -remove_prefix,remove_suffix, or
reassignment. (This concerns the view; notstd::string_view::size_type, which is asize_t,
norabsl::Span<const std::string_view>, whereconstis the span's element type.)
Corollary: when a loop view needs trimming, iterate a mutable (non-const) by-value view and
mutate it in place - neverconstthe loop view and then copy it to a mutable local just to
mutate the copy. -
This applies to LOCALS, never to PARAMETERS. A by-value parameter is never top-level
const- notstd::string_view, and not any other simple type (int,bool,
std::size_t, ...). The top-levelconstis invisible to callers (it is not part of the
function type, so it does not even change the signature), it is noise in every declaration,
and on astd::string_viewit forbids exactly what the type is designed for: re-slicing the
parameter in place withremove_prefix/remove_suffixinstead of copying it to a mutable
local first. The characters a view refers to are alreadyconst; the view itself is meant to
be movable over them.std::string Write(std::string_view name, std::string_view content); // yes std::string Write(const std::string_view name, const std::string_view c); // no: const by value
const std::string_view name = label.empty() ? id : label; // read-only: const for (std::string_view line : absl::StrSplit(text, '\n')) { // mutated: non-const, trim in place if (!line.empty() && line.back() == '\r') line.remove_suffix(1); } for (const std::string_view raw : absl::StrSplit(text, '\n')) { // no: const + copy-to-mutate std::string_view line = raw; if (!line.empty() && line.back() == '\r') line.remove_suffix(1); }
-
Do not overload
const T*to express "optional" or to conflate omission with value. A
raw pointer mixes nullability, the pointed-at value, and ownership/lifetime into one type
the caller has to second-guess. For an optional reference use
std::optional<std::reference_wrapper<T>>or, preferably, mbo'smbo::types::OptionalRef<T>
(mbo/types/optional_ref.h) and its related types (e.g.OptionalDataOrRefwhen it may own
a value or refer to one). -
Mark a return value
[[nodiscard]]when silently ignoring it is a bug - an
absl::Status/StatusOr, a parsed result, aConsume..."did it match?" flag, an acquired
handle. The exception is a function designed for its result to be optionally used: a builder
method returning*thisfor chaining, or a mutator that also returns the previous value as a
convenience. We disablemodernize-use-nodiscardin.clang-tidyprecisely because it would
blanket-annotate every const method - apply[[nodiscard]]by judgment instead. -
switch / case: order case labels alphabetically. Where the cases are a uniform
mapping (key -> handler or value), prefer a constexprmbo::container::LimitedMap
lookup over a switch. -
No em-dashes anywhere (code, comments, docs, commit messages). Use a spaced
hyphen-.
Error handling: absl::Status and the MBO status macros
Exception policy
- mbo's own builds are exception-free. The repository-wide Bazel configuration
compiles C++ with-fno-exceptions; production mbo code must not depend on
throwing or catching exceptions for its normal control flow. Use
absl::Status/absl::StatusOrand the status macros below for recoverable
errors.MBO_CONFIG_REQUIREuses fatal logging in this build. - Public headers must remain compatible with exception-enabled consumers. A
downstream project may compile template code with exceptions enabled, and an
operation supplied by its typeTmay throw. Do not hide that behind an
unconditionalnoexcept: derive the exception specification from the invoked
construction, assignment, comparison, or callable operation. The mbo type must
retain a valid state while the exception propagates. - An exception-enabled test target is a narrow compatibility test. A test may
addcopts = ["-fexceptions"]when it must execute a throwing user operation to
verify the public-header contract above. Record that reason next to the target;
do not enable exceptions for ordinary tests or use such a target as evidence
that mbo production code itself may throw. --//mbo/config:require_throws=trueselects the throwing
MBO_CONFIG_REQUIREbehavior only when the consuming compilation also enables
exceptions. With-fno-exceptions, requirements always use fatal logging.
Propagate errors with the macros from mbo/status/status_macros.h
(@mboworks_mbo//mbo/status:status_macros_cc), not a hand-written
if (!x.ok()) return x.status();.
- A "value or error" type IS
absl::StatusOr<T>. Do not hand-roll a struct that bundles
a value (or astd::vector/std::optional) with anabsl::Statusplus an "ok" flag:
absl::StatusOr<T>is exactly that, enforces the not-ok-has-no-value invariant by
construction, composes with the macros below, and matches every other API. MBO_RETURN_IF_ERROR(expr)evaluates aStatusorStatusOrand returns early if
it is not OK. It returns ambo::status::StatusBuilder, which converts to the calling
function'sabsl::Statusorabsl::StatusOr<T>, so it works in both.MBO_ASSIGN_OR_RETURN(Type var, expr)binds the value of aStatusOr<Type>to
var(a new declaration - carry the type - or an existing variable) or returns the
status. After it,varis the value (not aStatusOr); usevar, not*var.MBO_MOVE_TO_OR_RETURN(expr, target)is the variant whose target may contain commas;
the expression comes first so structured bindings work:
MBO_MOVE_TO_OR_RETURN(MakePair(), auto [a, b]);.
absl::StatusOr<Report> Build(std::string_view path) {
MBO_ASSIGN_OR_RETURN(const Config config, LoadConfig(path)); // value, or return status
Report report = Analyze(config);
MBO_RETURN_IF_ERROR(Persist(report)); // Status guard
return report;
}- When you only need the guard but the
StatusOrvalue is large and used later through
*expr, guard on the status to avoid copying the value:MBO_RETURN_IF_ERROR(big.status());
then use*big. - Do not force the macros where they do not fit: a recovery branch (e.g.
absl::IsNotFound(s)-> return a default value), a function that returnsbool/int
rather than a status, or a CLI path that prints a message and returns an exit code. - Pass
absl::Statusby value (see Idioms).
Output, logging, and AbslStringify
-
Never use C strings or
.c_str()except to satisfy a C / system API (e.g. building
char* argv[]forexecvp); comment why at that one boundary. Keepstd::string/
std::string_viewend to end - the round-trip out to aconst char*and back is
clutter, not interop. -
Never write output through C stdio (
printf/fprintf/fputsto thestdout/
stderrFILE*). Use the C++ streamsstd::cout/std::cerr(or the logging library).
The anti-pattern to delete on sight:std::fputs(absl::StrCat(...).c_str(), stderr). -
Format with Abseil:
absl::StrCatfor plain concatenation,absl::StrFormatfor a
formatted string, andabsl::StreamFormatto write a formatted line straight to a
stream with no temporary:std::cerr << absl::StreamFormat("wrote %d entries\n", n);.
A plain<<is fine for a simple string. Choose per line, but be consistent within a
single function (do not mix aStreamFormatand a<<chain in the same function). -
Make your types printable with
AbslStringify, not a hand-rolledoperator<</
ToString. Abseil's string-conversion extension point is a hidden-friend hook:struct Point { int x = 0; int y = 0; template<typename Sink> friend void AbslStringify(Sink& sink, const Point& p) { absl::Format(&sink, "(%d, %d)", p.x, p.y); } };
One hook makes the type work with
absl::StrCat,absl::StrFormat/StreamFormat
%v,absl::StrJoin, and Abseil logging - and gives GoogleTest readable failure output.
absl::Status/StatusOralready stringify; print them with%v.
Concurrency and thread safety
These are the baseline for any multi-threaded code, not extra credit; threading bugs are
silent and data-dependent, so the annotations and the sanitizer are the standing guard.
Most of this repo is single-threaded today, so this section is the standard to apply when
you add multi-threaded code, not a description of current breadth.
- Use
absl::Mutex+absl::MutexLock, notstd::mutex/std::lock_guardor
atomics-as-synchronization. Construct the lock from a reference:absl::MutexLock lock(mu_);
(the pointer constructor is deprecated). - Annotate everything (
absl/base/thread_annotations.h):ABSL_GUARDED_BY(mu_)on
every member the mutex protects, andABSL_LOCKS_EXCLUDED/
ABSL_EXCLUSIVE_LOCKS_REQUIREDon methods. A comment must state exactly which members a
given mutex guards, and call out any shared state deliberately left unguarded together
with the invariant that keeps it safe (e.g. "each index is handed to exactly one worker,
so distinct elements never alias"). - If a type has more than one mutex, document their lock-acquisition order (which is
taken before which) so the ordering that prevents deadlock is explicit. - Enforce the annotations: compile first-party code with clang's
-Wthread-safety
(with-Werroran unguarded access becomes a build failure), and add a
ThreadSanitizer CI job that runs the threaded tests so races are also caught at
runtime. Scope the tsan job to the threaded targets; exclude heavy third-party-linked
ones so tsan does not rebuild them.
Protocol Buffers
- Generated repeated fields and maps are STL-compatible - range-iterate them, do not
index withfield_size()+field(i).field_size()is still fine forreserve()and
== 0emptiness checks; compare two repeated fields withabsl::c_equal. - Edition 2024: string accessors default to
string_type = VIEWand return
std::string_view(so the by-value-string_viewrule applies to them); singular fields
have explicit presence by default, soset_x(0)serializes a0. To mean "absent",
leave the field unset (never call its setter) rather than setting the default. - Build test protos from text, not setters:
mbo::proto::ParseTextProtoOrDie(R"pb(field: 1 nested { k: "v" })pb")
(@mboworks_proto//mbo/proto:parse_text_proto_cc). TheR"pb(...)pb"raw string is what
clang-format leaves alone, so the proto stays readable. Do not imperativelyset_/add_
your way to a fixture. - Assert on protos structurally with
mbo::proto::EqualsProto
(@mboworks_proto//mbo/proto:matchers_cc), which also accepts a text-proto string:
EXPECT_THAT(msg, EqualsProto(R"pb(field: 1)pb"));. Match a subset with
Partially(EqualsProto(...)). Never compare serialized strings.
Testing (GoogleTest / GoogleMock)
All exported code must be tested, at every level (unit, integration, and end-to-end where
it applies). A one-shot manual check or a script you ran once is planning input, never a
substitute for a committed test. Tests use GoogleTest + GoogleMock with these conventions.
Structure
- Always
TEST_Fwith a fixture, never a bareTEST. Even an empty
struct FooTest : ::testing::Test {};is preferred, so shared setup has a home. - One behaviour per test; name the test for the behaviour it asserts.
Bazel test sizing
- Every direct Bazel test rule declares
sizeexplicitly so scheduling and timeout
expectations are reviewable. Project test macros may provide a documented default. - Use
size = "small"for quick unit, golden-file, CLI smoke, fuzz-regression, and
digest-verification tests. Reservemediumor larger sizes for measured runtime needs;
do not leave a fast test in Bazel's implicitmediumclass. tools/check_test_sizes.pyenforces the direct-rule declaration mechanically.
Assertions: gmock matchers, never comparison macros
-
Assert with
EXPECT_THAT/ASSERT_THAT+ a matcher, never a GoogleTest comparison macro.
The prohibited suffixes areEQ,NE,LT,LE,GT,GE,STREQ,STRNE,STRCASEEQ,
STRCASENE,FLOAT_EQ,DOUBLE_EQ, andNEAR, in both theirEXPECT_andASSERT_forms.
Matchers compose and give far better failure messages. There are no exceptions, including string,
floating-point, and multi-line text comparisons. The accepted exception is the boolean
EXPECT_TRUE/EXPECT_FALSE(andASSERT_TRUE/ASSERT_FALSE), which read fine on their own.
Within a single test keep one style - do not mix, say,EXPECT_TRUE(x)and
EXPECT_THAT(y, IsTrue()). -
Name matchers unqualified - never the
::testing::prefix inline. Bring each matcher in with
ausing ::testing::Foo;(orusing ::mbo::testing::Foo;) in the test file's anonymous namespace
and use the bare name in theEXPECT_THAT/ASSERT_THATexpression; a::testing::Foo(...)
written inline in an assertion is the smell to fix by adding theusing. (Fixture utilities such
as::testing::Test/::testing::TempDirare not matchers and keep their qualification.) -
Eqis optional - a readability choice, not a rule.EXPECT_THAT(x, value)auto-wraps a
bare value inEq, so both forms compile. The value ofEXPECT_THATis that the line reads as
a sentence -EXPECT_THAT(foo, Eq(25))is "expect that foo equals 25" - so keepEqwhere it
makes the assertion read that way, and drop it where the value already carries the meaning
(EXPECT_THAT(name, "foo")). Inside composite matchers prefer bare elements
(ElementsAre(1, 2, 3));Optional/Pointeeare the exception and need the innerEq(see
below). Strings sometimes needStrEq(e.g. achar*subject, where bareEqcompares
pointers). For booleans,IsTrue()/IsFalse()usually read better than a baretrue/
falseorEq(true):EXPECT_THAT(found, IsTrue()). -
Multi-line text:
mbo::testing::EqualsText, notEXPECT_EQ. For a multi-line string
(a rendered table, generated--help, file contents) prefer
EXPECT_THAT(actual, EqualsText(golden))(//mbo/testing:matchers_cc): it compares line by line
with unified-diff output, so a mismatch points at the offending line instead of dumping the whole
blob. Text comparison was the last plausible reason to useEXPECT_EQ/ASSERT_EQ;EqualsText
removes that reason. For non-line-oriented strings useEXPECT_THAT(actual, expected)orStrEq
when the subject is a C string.-
Write the golden as a
DropIndent-filtered raw string, not concatenated"...\n"literals.
clang-formatshoves adjacent string literals hard against theEqualsText(bracket (aligned to
the open paren, often at column ~35), which is unreadable and drifts with the call length. Instead
write the expected block as an indented raw string and strip the source indent with
WithDropIndent, whichclang-formatleaves untouched:using ::mbo::testing::EqualsText; using ::mbo::testing::WithDropIndent; EXPECT_THAT(RenderTable(Format::kAligned, header, rows), WithDropIndent(EqualsText(R"out( name size ------ ---- a.txt 3 )out")));
mbo::strings::DropIndent(whichWithDropIndentapplies to the expected text only) drops the
empty first line afterR"out(, strips the first content line's indent from every line, and clears
a whitespace-only last line - so the block reads as the literal expected output. The subject stays
as-is; only de-indent it too (EXPECT_THAT(DropIndent(actual), WithDropIndent(EqualsText(golden))),
//mbo/strings:indent_cc) when the actual is itself an indented literal. Use any raw delimiter
(R"out(,R"md(); a raw string also needs no\n/\\escaping. -
Caveat - trailing whitespace. A raw-string golden cannot carry significant trailing spaces on
a line: thetrim trailing whitespacepre-commit hook strips them, silently changing the golden. If
the expected output has meaningful trailing whitespace (e.g. right-padded columns), fall back to the
concatenated-literal form for that case and accept the paren alignment. -
DropIndentAndSplitreturns the de-indented lines as astd::vector<std::string_view>for when you
would rather match them withElementsAre.
-
-
Floats / doubles: never
Eq/==; useFloatEq/DoubleEq(orNear). -
Optionals:
EXPECT_THAT(opt, Eq(std::nullopt))for empty,Optional(...)for a
value. Nested matchers do not auto-wrap:Optional(Eq("x"))andPointee(Eq("x"))
need the explicit innerEq(a bare value fails to compile there). -
Containers:
ElementsAre(...)/UnorderedElementsAre(...)/SizeIs(n)/
IsEmpty()cover size + order + contents in one matcher, instead of an
ASSERT_EQ(v.size(), n)followed by indexedEXPECT_EQs.ElementsAreauto-wraps
each bare element inEq.- For a longer matcher list, prefer
ElementsAreArray({m1, ..., mN,})with a
trailing comma overElementsAre(m1, ..., mN).ElementsAreis variadic, so
clang-format bin-packs it into an unreadable run;ElementsAreArraytakes a
braced init-list, so the manual trailing comma opts it into one-matcher-per-line
(the same trailing-comma lever as any aggregate, see the Formatting section).
A short list that reads on one line staysElementsAre.
- For a longer matcher list, prefer
-
Size and emptiness: match the container, never extract then match. Use
SizeIs/IsEmptyon the container itself, not.size()/.empty()fed to a
scalar matcher orEXPECT_TRUE- the matcher form prints the container on failure
while the extracted form throws it away. So:EXPECT_THAT(v, SizeIs(3)), notEXPECT_THAT(v.size(), 3)/EXPECT_EQ(v.size(), 3).SizeIscomposes with a matcher for bounds:EXPECT_THAT(v, SizeIs(Le(90))),
notEXPECT_THAT(v.size(), Le(90)).EXPECT_THAT(v, IsEmpty())/EXPECT_THAT(v, Not(IsEmpty())), not
EXPECT_TRUE(v.empty())/EXPECT_FALSE(v.empty())(this is the one place a
.empty()boolean should still become a matcher).
-
Struct elements: fold per-field checks into the element matcher with the
3-arg, namedField("member", &T::member, m)+AllOf, ideally via a small
testing::Matcher<T> FooIs(...)helper, so a mismatch names the field rather than
reporting "whose given field is...".testing::Matcher<ResolvedFlag> FlagIs(const std::string& flag, Source source) { return AllOf(Field("flag", &ResolvedFlag::flag, flag), Field("source", &ResolvedFlag::source, source)); } EXPECT_THAT(Resolve(in), ElementsAre(FlagIs("--color", Source::kUser), FlagIs("--sort", Source::kUser)));
Status matchers (mbo/testing)
EXPECT_THAT(status_or, mbo::testing::IsOk()), or
StatusIs(absl::StatusCode::kInvalidArgument)to match a specific code.IsOkAndHolds(m)matches an OKStatusOrwhose value matchesm- prefer it over
IsOk()followed by dereferencing:EXPECT_THAT(Parse(in), IsOkAndHolds(SizeIs(3))).- Do not
ASSERT_THAT(value, IsOk())and then dereference the sameStatusOr; bind it once with
MBO_ASSERT_OK_AND_ASSIGN, or useIsOkAndHoldswhen it is inspected only once. MBO_ASSERT_OK_AND_ASSIGN(const auto value, MakeThing())asserts OK and binds in one step.MBO_ASSERT_OK_AND_MOVE_TO(MakePair(), auto [a, b])is the move variant whose target may
contain commas (so the expression comes first) - the test mirror ofMBO_MOVE_TO_OR_RETURN,
for structured bindings and move-only types.
Protocol-buffer fixtures and matchers
Build proto test data with mbo::proto::ParseTextProtoOrDie(R"pb(...)pb") and assert with
mbo::proto::EqualsProto / Partially(EqualsProto(...)) - never imperative setters or
serialized-string comparison. See the Protocol Buffers section.
Shell / binary-level tests
- Use mboworks/bashtest (
bazel_dep(name = "mboworks_bashtest")), not a hand-rolled
sh_test:load("@mboworks_bashtest//bashtest:bashtest.bzl", "bashtest"), then a
script thatsources"${mboworks_bashtest}", definestest::name()functions using
theexpect_*assertions, and ends withtest_runner. - Assert on captured output with bashtest's content matchers (>= 0.5.0), never a
hand-rolledgrep.expect_output_contains/expect_output_not_containsfor a
literal substring;expect_matches/expect_not_matchesfor an ERE. They take the
pattern / substring first and the text second, and match via bash's built-in
[[ =~ ]](no subprocess), so - unlikeprintf ... | grep -qE- they cannot misfire
on SIGPIPE underset -o pipefail.expect_eq/expect_nestay for scalar checks
(exit codes, counts);expect_contains/expect_not_containsare array-membership,
not substring. Do not reintroduce a_has-stylegrep -qwrapper.- Anchoring caveat:
expect_matchesmatches the whole text, so^/$anchor
the whole output, not a line (unlikegrep). For a per-line anchor use a real newline:
NL=$'\n', then(^|${NL})Xfor a line start andX($|${NL})for a line end (\nis
not a portable ERE escape, so embed the newline via${NL}).
- Anchoring caveat:
- For a whole-output-per-case golden, prefer a golden-file test over scraping. When a
test wants to lock an entire rendered output (not just probe for substrings), commit one
expected-output file per case and diff against it withdiff_test
(//mbo/diff:diff.bzl), which fails printing the offending diff.
This is the output analogue ofEqualsTextfor C++ and reads far better than a pile of
expect_output_containsprobes. - It runs under macOS bash 3.2: no
mapfile/readarrayor other bash-4 features.
Read lines withwhile IFS= read -r line; do arr+=("$line"); done <<< "$out".