In my previous post I consolidated the lint setup across all 7 packages in the Pedalboard monorepo. The key move there was introducing tools/lint/linters.bzl, a shared macro file that wraps eslint_test and stylelint_test, injects the lint tag automatically, and gives every package a single import to call instead of manually re-wiring the lint aspect. One file, seven packages, zero boilerplate.
That landed, and I immediately looked at the jest_test blocks sitting in each BUILD.bazel. They told the exact same story.
The repetition hiding in plain sight
Every one of the 7 packages has a jest_test target that looks roughly like this:
jest_test(
name = "jest",
tags = ["jest"],
config = "jest.config.js",
auto_configure_reporters = False,
args = ["--reporters=default"],
data = [
":sources",
":test_sources",
"jest.config.js",
"//:jest_config_base",
"//:node_modules/@swc/core",
"//:node_modules/@swc/jest",
# ... plus package-specific deps
],
node_modules = ":node_modules",
)
Six attributes are identical across all packages: tags, config, auto_configure_reporters, args, node_modules, and six entries in data. The only thing that actually varies is the extra package-specific entries in data, things like :node_modules/react, :node_modules/@testing-library/react, :node_modules/stylelint, and so on.
So we're in the same situation as linting was before linters.bzl: shared wiring duplicated across every package, every BUILD.bazel loading from @aspect_rules_jest directly, every future change to the base configuration requiring 7 edits.
The fix is the same shape as the fix for linting.
Creating tools/test/testers.bzl
Mirroring the tools/lint/ structure, the new home for the shared Jest macro is tools/test/testers.bzl:
"""Test runner macros for use across packages.
Exports:
jest_unit_test: a test rule that runs Jest with the project-wide SWC
configuration and common base deps.
Usage: jest_unit_test(name = "jest", data = [...extra deps])
"""
load("@aspect_rules_jest//jest:defs.bzl", "jest_test")
def jest_unit_test(name, data = [], **kwargs):
jest_test(
name = name,
tags = ["jest"],
config = "jest.config.js",
auto_configure_reporters = False,
args = ["--reporters=default"],
data = [
":sources",
":test_sources",
"jest.config.js",
"//:jest_config_base",
"//:node_modules/@swc/core",
"//:node_modules/@swc/jest",
] + data,
node_modules = ":node_modules",
**kwargs
)
The data = [] default means packages with no extra deps (there aren't any in this repo, but still) can call jest_unit_test(name = "jest") and get everything. Packages with extra deps just pass them in. The **kwargs passthrough means any future attribute, say timeout or env, can be forwarded without touching the macro.
There's also a tools/test/BUILD.bazel needed so Bazel recognizes the directory as a package:
# Tools for running Jest tests across packages.
That's it. No targets, just the package marker.
Before and after
Here's what the hooks package BUILD.bazel looks like before:
load("@aspect_rules_jest//jest:defs.bzl", "jest_test")
# ... other loads
jest_test(
name = "jest",
tags = ["jest"],
config = "jest.config.js",
auto_configure_reporters = False,
args = ["--reporters=default"],
data = [
":sources",
":test_sources",
"jest.config.js",
"//:jest_config_base",
"//:node_modules/@swc/core",
"//:node_modules/@swc/jest",
":node_modules/@testing-library/react-hooks",
":node_modules/react",
],
node_modules = ":node_modules",
)
And after:
load("//tools/test:testers.bzl", "jest_unit_test")
# ... other loads
jest_unit_test(
name = "jest",
data = [
":node_modules/@testing-library/react-hooks",
":node_modules/react",
],
)
The @aspect_rules_jest load is gone from the package. The six boilerplate attributes are gone. What remains is exactly what's unique to hooks: its two runtime test deps.
The components package has the most extra deps of any package in the repo, and even there the call is still compact:
jest_unit_test(
name = "jest",
data = [
"//:node_modules/eslint",
":node_modules/react",
":node_modules/react-dom",
":node_modules/@testing-library/react",
":node_modules/@testing-library/jest-dom",
":node_modules/prop-types",
":node_modules/identity-obj-proxy",
":node_modules/@pedalboard/hooks",
],
)
Seven lines of package-specific deps, nothing more.
Does it still work?
After applying the macro across all 7 packages, running pnpm test gives:
//packages/components:jest (cached) PASSED in 2.4s
//packages/eslint-plugin-craftsmanlint:jest (cached) PASSED in 1.6s
//packages/git-hooks:jest (cached) PASSED in 1.3s
//packages/hooks:jest (cached) PASSED in 2.1s
//packages/media-loader:jest (cached) PASSED in 2.4s
//packages/scripts:jest (cached) PASSED in 1.3s
//packages/stylelint-plugin-craftsmanlint:jest (cached) PASSED in 1.7s
Executed 0 out of 7 tests: 7 tests pass.
Every result is (cached). That's Bazel telling you the action fingerprint (the actual test inputs and command) didn't change. The macro is just a different way of writing down the same declaration. The outputs are identical, so the cache still holds, and no tests actually re-executed.
That's actually a nice sanity check. If I'd accidentally dropped a dep or changed the Jest args, the fingerprint would differ, the cache would miss, and the test would re-run (and potentially fail). The fact that everything hit cache means the refactor is genuinely transparent.
Wrapping up
Two shared macro files, zero per-package boilerplate for the shared wiring. tools/lint/linters.bzl handles ESLint and Stylelint. tools/test/testers.bzl now handles Jest. Each BUILD.bazel imports from its respective tools directory and only states what's actually unique to that package.
If you want to change the base Jest configuration, swap out the reporter, adjust SWC options, add a new shared dep — you change it in one place and all 7 packages pick it up. That's the whole point.
The symmetry between tools/lint/ and tools/test/ also means a new contributor (or future me) has a clear mental model: tools/ is where the shared build infrastructure lives, packages/ is where the per-package declarations live. Straightforward.
Related reading:
- Mastering Your Frontend Build with Bazel: Linting: the direct predecessor and inspiration for this consolidation
- Mastering Your Frontend Build with Bazel: Testing: where the Jest/Bazel integration was first set up
- Bazel For a Frontend Monorepo: the foundation
Top comments (0)