Bazel Test Targets: Hermetic, Cacheable Testing at Scale

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=TestLogin

Seeing 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=label

For 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
done

Or 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 instantly

Remote 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.json

Common remote cache backends:

  • Google Cloud Storage — via bazel-remote or native GCS backend
  • Amazon S3 — via bazel-remote proxy
  • 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.com

Remote 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=explicit

Bazel 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_test

The 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=ci

Common 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:

  1. Declare dependencies explicitly in BUILD.bazel files
  2. Hermeticity guarantees reproducibility — no hidden file system reads
  3. Local + remote caching eliminates re-running unchanged tests
  4. Test sharding parallelizes slow suites across workers
  5. 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.

Read more

Start now free