We just migrated caas_server to Bazel. 18 out of 105 tests failed on the first CI run. Not because the code was broken — every single one passes fine under uv run pytest. Bazel’s sandbox model introduces four distinct failure modes that don’t exist in the “normal” Python testing world, and each one breaks your tests in a different way.
This is the taxonomy.
1. The Silent Pass — Config Lost in the Sandbox
This one is the scariest because it doesn’t look like a failure.
The chain:
Your pyproject.toml has asyncio_mode = "auto", which tells pytest to treat all async def test_* functions as async tests automatically. Standard stuff. But Bazel runs each test in an isolated sandbox — a temp directory with only the files you explicitly declare. pyproject.toml is not declared. So it’s not there.
pyproject.toml says: asyncio_mode = "auto"
↓
Bazel sandbox: pyproject.toml doesn't exist
↓
pytest defaults to: asyncio_mode = "strict"
↓
"strict" mode: requires explicit @pytest.mark.asyncio
↓
async tests without it: silently skipped
↓
0 tests collected → exit 0 → Bazel: PASSED ✅
You get green CI. Zero tests ran.
The fix: Inject the config via command-line args, where Bazel can control it:
# bazel/rules/defs.bzl
def py_test(name, deps = [], args = [], **kwargs):
args = ["--override-ini=asyncio_mode=auto"] + args
_py_test(name = name, pytest_main = True, deps = deps, args = args, **kwargs)
This goes in a thin wrapper macro that every py_test uses. Centralized, impossible to forget.
The lesson: Bazel’s sandbox is explicit. If you didn’t declare it, it doesn’t exist. Any config file your test runner reads from the repo root — pyproject.toml, setup.cfg, pytest.ini — is gone. You need to either pass the config via args or add the file to each test’s data attribute.
2. The Infra Test — Docker Not Included
This one’s straightforward but catches a lot of targets.
The chain:
Test does: docker.from_env()
↓
Needs: /var/run/docker.sock
↓
CI runner: no Docker daemon
↓
ConnectionError → FAIL
These are integration/e2e tests that need real infrastructure — Docker daemons, mounted volumes, network access. They work on developer laptops. They were probably never meant to run in unit-test CI.
But bazel test //... runs everything. When the migration adds BUILD files for these test files, they get swept up in the wildcard.
The fix: Tag them out:
py_test(
name = "docker_api_containers_test",
srcs = ["do/new/docker/tests/docker_api_containers_test.py"],
tags = ["manual"],
...
)
manual excludes the target from //... wildcards. You can still run it explicitly with bazel test //path:docker_api_containers_test, but it won’t be picked up by the CI sweep.
12 of our 18 failures were this — Docker API tests, e2e tests, compose tests. All fixed with one line each.
The lesson: Audit your test inventory before migration. If a test needs something the CI environment doesn’t have, tag it manual. You can set up a separate CI pipeline for integration tests that has Docker access.
3. The Conftest Bleed — Sibling Directory Pollution
This one is subtle and took the longest to debug.
Background: pytest uses conftest.py files for shared fixtures. When pytest walks into a directory, it loads any conftest.py it finds. Normally this is fine — conftests in tests/ set up fixtures for that directory’s tests.
The problem: caas_server has pervasive circular imports between its subpackages, so everything is consolidated into a single py_library target with ~100 source files:
py_library(
name = "caas_server",
srcs = [
...
"do/new/docker/tests/conftest.py", # Docker fixtures
"do/new/ops/tests/conftest.py", # Ops fixtures
...
],
)
When an ops test depends on :caas_server, Bazel copies ALL those srcs into the sandbox:
Sandbox contains:
do/new/docker/tests/conftest.py ← imports docker-specific things
do/new/ops/tests/conftest.py ← imports ops-specific things
do/new/ops/tests/attributes_test.py ← the test we want
pytest starts collecting → walks ALL directories
→ enters do/new/docker/tests/
→ loads conftest.py
→ ImportError (docker test deps not available)
→ COLLECTION ERROR before any test runs
Why it works outside Bazel: When you run pytest caas_server/.../ops/tests/, pytest only walks into ops/tests/. It never enters the sibling docker/tests/ directory.
Our first (failed) attempt: --confcutdir=do/new/ops. This doesn’t work because confcutdir controls ancestor conftest discovery (parent → grandparent → root). The docker conftest is a sibling, not an ancestor. Different axis entirely:
confcutdir controls vertical (ancestor): The problem is horizontal (sibling):
repo_root/ do/new/
do/ ├── docker/tests/conftest.py ← SIBLING
new/ └── ops/tests/test_file.py
ops/tests/ ← test here
The actual fix: Tell pytest to skip the sibling directory entirely:
py_test(
name = "attributes_test",
srcs = ["do/new/ops/tests/attributes_test.py"],
args = ["--ignore-glob=*docker/tests*"],
...
)
--ignore-glob prevents pytest from even entering matching directories during collection. The docker conftest is never loaded.
The lesson: When a monolithic py_library bundles multiple test infrastructure files (conftest, fixtures) from different test suites, pytest in Bazel’s sandbox will discover all of them. Use --ignore-glob to scope collection back down.
4. The Missing Extra — pip Extras Don’t Translate
The chain:
Your pyproject.toml declares fakeredis[lua]. The [lua] part is a pip “extra” — it means “also install lupa”, which is a Lua interpreter that lets FakeRedis execute Lua scripts.
In pip/uv world:
uv add fakeredis[lua]
→ resolves: fakeredis + lupa
→ both installed
→ fakeredis.eval() works (Lua scripts run)
In Bazel world:
BUILD.bazel: @pypi//fakeredis
→ Bazel installs fakeredis only
→ "extras" are a pip concept, Bazel doesn't auto-resolve them
→ lupa is NOT installed
FakeRedis handles this gracefully at import time:
try:
import lupa
except ImportError:
self.lua = None # silent fallback
Most tests work fine. But then one test calls redis.eval(lua_script, ...) — which needs Lua — and gets a runtime error. Only one test out of the whole suite fails, and only because it’s the only one using Lua scripts.
The fix: Add the optional dependency explicitly:
py_test(
name = "admission_manager_test",
deps = [
"@pypi//fakeredis",
"@pypi//lupa", # Bazel doesn't resolve pip extras
...
],
)
The lesson: package[extra] in pyproject.toml doesn’t translate to Bazel automatically. When migrating, grep for [...] extras in your dependencies and add the underlying packages explicitly in BUILD files.
The Common Thread
All four failures come from the same root cause: Bazel’s sandbox is explicit and isolated.
| What pip/uv gives you for free | What Bazel requires you to declare |
|---|---|
pyproject.toml config inheritance |
--override-ini args or data attribute |
| “Run these tests, skip those” convention | tags = ["manual"] |
| pytest discovers only what you point at | --ignore-glob to exclude siblings |
package[extra] resolves transitively |
Explicit @pypi// for each sub-package |
None of these are Bazel bugs. They’re the cost of hermeticity. Bazel gives you reproducible, cacheable, parallelizable builds — but the price is that nothing is implicit. Every file, every config, every transitive dependency has to be spelled out.
The migration tax is real, but it’s a one-time cost. And honestly, making your test dependencies explicit isn’t a bad thing — it means you actually know what your tests need to run.