Bazel Test Targets: Hermetic, Cacheable Testing at Scale
Bazel is a build and test tool originally developed at Google to handle monorepos at extreme scale. Its core guarantees are hermeticity (tests can't accidentally read the network or undeclared files) and caching (identical inputs produce identical cached outputs). This makes Bazel test results fully reproducible and shareable across machines.
Core Concepts
Before diving into test targets, three concepts to understand:
Hermeticity: Bazel sandboxes test execution. Tests can only read files you explicitly declare as dependencies. This eliminates "works on my machine" failures caused by implicit filesystem dependencies.
Content-addressed caching: Bazel hashes every input (source files, dependencies, tools, flags) to produce a cache key. If the key matches a previous run, the result is served from cache — locally or remotely — without execution.
Determinism: Given the same inputs, Bazel produces the same outputs. This is what makes caching correct.
BUILD Files
Bazel projects are organized into packages, each with a BUILD or BUILD.bazel file. You declare test targets in these files.
For a JavaScript/TypeScript monorepo using rules_js:
# packages/utils/BUILD.bazel
load("@aspect_rules_js//js:defs.bzl", "js_library")
load("@aspect_rules_jest//jest:defs.bzl", "jest_test")
js_library(
name = "utils",
srcs = glob(["src/**/*.ts"]),
deps = [
"//:node_modules/@types/node",
],
)
jest_test(
name = "test",
srcs = glob(["src/**/*.test.ts"]),
data = [
":utils",
"//:node_modules/jest",
"jest.config.js",
],
)For Go:
# services/auth/BUILD.bazel
load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
go_library(
name = "auth",
srcs = glob(["*.go"], exclude = ["*_test.go"]),
importpath = "github.com/my-org/my-repo/services/auth",
)
go_test(
name = "auth_test",
srcs = glob(["*_test.go"]),
embed = [":auth"],
)For Python:
# services/api/BUILD.bazel
load("@rules_python//python:defs.bzl", "py_library", "py_test")
py_library(
name = "api",
srcs = glob(["src/**/*.py"]),
deps = ["//packages/db:db_lib"],
)
py_test(
name = "api_test",
srcs = glob(["tests/**/*.py"]),
deps = [":api", "//packages/test-utils:test_utils"],
)Running Tests
# Run a specific test target
bazel test //services/auth:auth_test
# Run all tests in a package
bazel test //services/auth/...
# Run all tests in the entire repo
bazel test //...
# Run with verbose output
bazel test //... --test_output=all
# Run a specific test case
bazel test //services/auth:auth_test --test_filter=TestLoginSeeing What Needs to Run (Affected Tests)
Bazel computes what needs to run by analyzing the dependency graph and the build graph:
# List all test targets that would run
bazel query 'tests(//...)'
# Find tests affected by a specific file change
bazel query "rdeps(//..., //packages/utils:utils)" --output=labelFor CI, use bazel query with git diff to find targets affected by changed files:
# Get changed files
CHANGED=$(git diff --name-only HEAD origin/main)
# Convert to Bazel labels
for file in $CHANGED; do
bazel query "attr('srcs', '$file', //...)" 2>/dev/null
doneOr use tools like bazel-diff or bazel-affected that automate this.
Local Caching
Bazel caches build and test outputs in ~/.cache/bazel by default. The cache key is a content hash of all inputs. If you run the same test twice without changing inputs:
bazel test //services/auth:auth_test
# → BUILD and test execute, result cached
bazel test //services/auth:auth_test
# → (cached) result replayed instantlyRemote Caching
For team sharing and CI, configure a remote cache:
# .bazelrc
build --remote_cache=grpcs://your-remote-cache.example.com
build --google_credentials=/path/to/service-account.jsonCommon remote cache backends:
- Google Cloud Storage — via
bazel-remoteor native GCS backend - Amazon S3 — via
bazel-remoteproxy - BuildBuddy — managed Bazel remote cache + UI (free tier available)
- EngFlow — enterprise Bazel remote execution
With remote caching, if a developer ran the tests and pushed results to the remote cache, CI will replay those results instead of re-running. For large teams, this is transformative.
Remote Execution
Beyond caching, Bazel supports remote execution — running tests on a fleet of workers:
# .bazelrc
build --remote_executor=grpcs://your-remote-executor.example.comRemote execution allows you to test with more parallelism than any single machine can provide, and results are still cached remotely.
Test Sharding
For slow test suites, Bazel supports sharding — splitting tests across multiple workers:
# BUILD.bazel
go_test(
name = "integration_test",
srcs = ["integration_test.go"],
shard_count = 4, # Split into 4 parallel shards
)bazel test //... --test_sharding_strategy=explicitBazel assigns test cases to shards and aggregates results.
Test Timeouts
Each test target can declare its own timeout:
go_test(
name = "auth_test",
srcs = ["auth_test.go"],
timeout = "short", # 60s
# Options: short (60s), moderate (300s), long (900s), eternal (3600s)
)Tests that exceed their timeout are killed and marked as failures.
Test Tags
Tags let you categorize and selectively run tests:
go_test(
name = "e2e_test",
srcs = ["e2e_test.go"],
tags = ["e2e", "manual"], # "manual" = excluded from //...
)# Run only unit tests
bazel test //... --test_tag_filters=unit
# Exclude e2e tests
bazel test //... --test_tag_filters=-e2e
# Run manually-tagged targets explicitly
bazel test //services/auth:e2e_testThe manual tag is special: targets with it are excluded from wildcard patterns like //... unless explicitly named.
Coverage
bazel coverage //...
bazel coverage //services/auth/...
# Generate HTML report
genhtml bazel-out/_coverage/_coverage_report.dat --output-directory coverage/CI Integration
GitHub Actions with BuildBuddy remote cache:
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Mount Bazel cache
uses: actions/cache@v4
with:
path: ~/.cache/bazel
key: bazel-${{ runner.os }}-${{ hashFiles('WORKSPACE', '.bazelrc') }}
- name: Run tests
run: |
bazel test //... \
--remote_cache=grpcs://remote.buildbuddy.io \
--remote_header=x-buildbuddy-api-key=${{ secrets.BUILDBUDDY_API_KEY }}.bazelrc Configuration
.bazelrc centralizes Bazel flags:
# .bazelrc
# Use --config=ci in CI
build:ci --remote_cache=grpcs://your-cache.example.com
build:ci --remote_upload_local_results=true
# Always
build --jobs=auto
test --test_output=errors
test --flaky_test_attempts=2# CI usage
bazel test //... --config=ciCommon Pitfalls
Undeclared dependencies: If a test reads a file not in its data or deps, it fails in sandbox mode but might work locally without sandboxing. Always use --sandbox_debug to diagnose.
Non-hermetic tests: Tests that call external APIs or read from $HOME fail under hermeticity. Use mocks or declare the resources explicitly.
Missing BUILD files: New directories without a BUILD file are invisible to Bazel. Every directory with sources needs a BUILD.bazel.
Stale cache after refactor: If you rename a target, old cache entries are orphaned. Not a problem for correctness, but disk usage grows. Run bazel clean periodically.
Summary
Bazel brings Google-scale testing practices to any monorepo:
- Declare dependencies explicitly in
BUILD.bazelfiles - Hermeticity guarantees reproducibility — no hidden file system reads
- Local + remote caching eliminates re-running unchanged tests
- Test sharding parallelizes slow suites across workers
- Tags let you categorize and selectively run test subsets
The learning curve is steeper than Nx or Turborepo, but the guarantees are stronger. For large teams with many packages and complex dependency graphs, Bazel's correctness and cache sharing make it worth the investment.