>[!warning]- Info
> Author:
[email protected]
> Publish date: 2026/07/02
> Last update: 2026/07/14
> Status: WIP
> Revision: 12
> Visibility: Public
>[!note]- AI Assistance
> This document was drafted with the assistance of [Claude](https://claude.ai) (Anthropic). The technical content reflects work done by the author; Claude helped structure and articulate it.
# Modularizing `chrome/test`
A phased approach to decoupling `chrome/test` from its monolithic `BUILD.gn`, following the Bedrock modularization pattern.
> [!summary] TL;DR
> - **The problem:** `chrome/test/BUILD.gn` is ~11,000 lines — one of the largest files in the tree, aggregating sources from 26 subdirectories directly.
> - **The goal:** Give each logical subdirectory of `chrome/test` its own `BUILD.gn`, mirroring the ~105-file pattern already achieved in `chrome/browser/ui`.
> - **The approach:** Three phases — start with leaf modules (`interaction`, `user_education`, `permissions`…), tackle the large `base/` infrastructure split, then complete remaining specialized directories.
> - **Biggest win:** `chrome/test/base/` — 131 source files, no `BUILD.gn` historically; extracting it unlocks downstream modularization of the entire tree.
## Background
Project Bedrock's primary mandate is fixing the `Browser` god-object in `chrome/browser`. The structural half of that work — giving each subdirectory its own `BUILD.gn` and `OWNERS`, enforcing visibility, breaking circular deps — applies directly to `chrome/test`.
The pattern: instead of one massive `BUILD.gn` reaching into child directories via relative source paths like `"base/testing_profile.cc"`, each subdirectory owns a target and the parent references it via a GN dep.
There are two complementary efforts:
| Effort | What it extracts | Code movement? |
|--------|-----------------|----------------|
| **This work — `chrome/test/` modularization** | Test *support* targets (`InProcessBrowserTest`, `TestingProfile`…) into per-subdirectory `BUILD.gn` files | None — `BUILD.gn` reorganization only |
| **Bedrock team — `chrome/browser/` modularization** | Test *case* source declarations out of `chrome/test/BUILD.gn` into the module's own `:browser_tests` targets | None — declarations move, files stay |
---
## Current State
```
chrome/test/
├── BUILD.gn ← monolith, shrinking
├── base/ ← Phase 2 (current — see progress table)
├── interaction/ ← ✅ done
├── user_education/ ← ✅ done
├── permissions/ ← ✅ done
├── views/ ← ✅ done
├── gpu/ ← ✅ done
├── v8/ ← ✅ done
├── enterprise/ ← Python-only, skip
├── accessibility/ ← standalone tool, skip
├── webapps/ ← test data only, skip
├── delayload/ ← Windows-only, defer
├── android/ ← BUILD.gn exists; 8 APK/Java sources remain in root
├── chromedriver/ ← BUILD.gn exists (standalone binary)
└── (fuzzing, mini_installer, payments, etc.) ← BUILD.gn exists, fully modularized
```
### Progress
| Phase | Status | Notes |
|---|---|---|
| Phase 1 — leaf modules (`interaction`, `user_education`, `permissions`, `views`, `gpu`, `v8`) | ✅ All landed | All temp monolith deps resolved |
| Phase 2a — `base/` thin targets (`:in_process_browser_test`, `:ui_test_utils`, `:test_launcher_utils`) + subdir dep cleanup | ✅ Landed | `chromod/chrome-test-base-0..3` |
| Phase 2b — `:in_process_browser_test_mixin`, `:chrome_test_launcher` | ✅ Landed | |
| Phase 2b — `:testing_profile` thin target | ⏳ In review | `chromod/chrome-test-base-4` |
| Phase 2b — `:testing_profile` full + `:testing_browser_process` | ⏳ In review | `chromod/chrome-test-base-5`; uses `allow_circular_includes_from` — see below |
| Phase 2b — `:testing_profile` Pattern A split (header+impl) | ⏳ In review | `chromod/chrome-test-base-6`; breaks extensions cycle; cros ash cycle deferred |
| Phase 2b — Forward-declare `ScopedCrosSettingsTestHelper` in testing_profile.h | ⏳ In review | `chromod/chrome-test-base-7`; fully removes ash/settings dep from header target |
| Phase 2c — `:profile_lifecycle_test_utils` | ⏳ In review | `chromod/chrome-test-base-8`; BUILD.gn-only; profile_waiter + profile_deletion_observer + profile_destruction_waiter |
| Phase 2c — `:browser_window_waiters` | ⏳ In review | `chromod/chrome-test-base-9`; browser_closed_waiter + browser_created_waiter |
| Phase 2c — remaining `base/` sources | ⬜ Next | See cluster map below |
| Phase 3 — remaining dirs (`android/` leftovers…) | ⬜ | After `base/` complete |
| Phase 3a — Monolith → thin aggregator | ⬜ | After ALL sources extracted |
| Phase 3b — Consumer migration + monolith deletion | ⬜ | After 3a; requires coordination |
> [!tip] Phase 2a thin targets
> `:in_process_browser_test`, `:ui_test_utils`, `:test_launcher_utils` are currently header-only. Their `.cc` implementations remain in the monolith until the pre-existing cycle `//chrome/browser/ui:test_support → //chrome/test:test_support_ui` is broken.
---
## chrome/test/base — Extraction Status
### Already extracted
| Target | Files | Notes |
|--------|-------|-------|
| `:test_launcher_utils` | `test_launcher_utils.h` | Thin header-only; `.cc` pending cycle fix (see below) |
| `:in_process_browser_test` | `in_process_browser_test.h` | Thin header-only (non-android); `.cc` pending cycle fix |
| `:ui_test_utils` | `ui_test_utils.h` | Thin header-only (non-android); `.cc` pending cycle fix |
| `:in_process_browser_test_mixin` | `in_process_browser_test_mixin.{cc,h}` | Full |
| `:chrome_test_launcher` | `chrome_test_launcher.{cc,h}` | Full |
| `:testing_profile` | `testing_profile.h`, `fake_profile_manager.h`, `testing_profile_key.h`, `testing_profile_manager.h` | Header-only; `allow_circular_includes_from` entries in TBP pending removal (see below) |
| `:testing_profile_impl` | `testing_profile.cc`, `fake_profile_manager.cc`, `testing_profile_key.cc`, `testing_profile_manager.cc` | Full impl |
| `:testing_browser_process` | `testing_browser_process.{cc,h}`, `testing_browser_process_platform_part.{cc,h}` | Full; has 3 remaining `allow_circular_includes_from` (see below) |
| `:profile_lifecycle_test_utils` | `profile_{waiter,deletion_observer,destruction_waiter}.{cc,h}` | Full |
| `:browser_window_waiters` | `browser_{closed,created}_waiter.{cc,h}` | Full |
### Phase 2c — cluster map (audited 2026-07-14)
Dep analysis done by reading all remaining `.h`/`.cc` files and cross-referencing with `gn gen --ide=json` project.json for precise header→target resolution.
#### Forced cluster: `:test_suites`
Files: `chrome_test_suite.{cc,h}` + `chrome_unit_test_suite.{cc,h}`
Reason: `chrome_unit_test_suite.h` inherits from `ChromeTestSuite` and includes `chrome_test_suite.h` directly — tightly coupled.
Heavy deps: `//chrome/app:test_support`, `//chrome/browser:browser_process`, `//chrome/browser/ui/webui:configs`, `//chrome/browser/ui:ui`, `//chrome/browser/profiles:profile_manager`, `//chrome/browser/lifetime`, `//chrome/common:constants`, `//chrome/test/base:testing_browser_process`, and more. Biggest lift remaining.
#### Separate targets despite similar names: `chrome_render_view_*`
- `chrome_render_view_host_test_harness` — browser-side, wraps `TestingProfile`; deps: `:testing_profile` + `//content/test:test_support`
- `chrome_render_view_test` — renderer-side, no `Profile`; deps: `//content/test:test_support`, `//services/service_manager/public/cpp:cpp`, `//third_party/blink/public/common:headers`
Different process boundary, different dep profiles → extract as separate targets.
#### Clean standalones (deps fully audited)
| Target | public_deps | impl-only deps |
|--------|-------------|----------------|
| `autocomplete_change_observer` | `//base`, `//components/omnibox/browser:browser` | `//chrome/browser/omnibox:omnibox`, `//chrome/browser/profiles` |
| `find_result_waiter` | `//base`, `//components/find_in_page:find_in_page`, `//ui/gfx` | *(none extra)* |
| `scoped_browser_locale` | `//base` | `//chrome/browser:browser_process` |
| `search_test_utils` | `//base` | `//components/search_engines:search_engines`, `//content/public/test:test_support` |
| `tracing` | `//base` | `//content/public/browser:browser_sources`, `//content/public/test:test_support`, `//services/tracing/public/mojom` |
| `web_feature_histogram_tester` | `//base/test:test_support`, `//third_party/blink/public/mojom:web_feature_mojo_bindings` | *(none extra)* |
| `scoped_metrics_service_for_synthetic_trials` | `//chrome/test/base:testing_browser_process`, `//chrome/test/base:testing_profile`, `//components/metrics:test_support`, `//components/variations:variations` + more | *(none extra)* |
#### Complete existing thin target: `:test_launcher_utils`
Add `test_launcher_utils.cc` — new deps: `//chrome/common:constants`, `//chrome/common:non_code_constants`, `//chrome/common:url_constants`, `//components/os_crypt/common:switches`, `//components/password_manager/core/browser:password_switches`, `//ui/display:display`; aura-only: `//ui/wm:wm`.
#### Blocked: `chrome_test_utils`
`chrome_test_utils.h` includes `chrome/test/base/platform_browser_test.h`, which lives in `test_support_ui`. Pre-fix: extract `platform_browser_test.h` as a thin header-only target (same pattern as `:in_process_browser_test`).
#### Suggested extraction order
1. ✅ `:browser_window_waiters` — CL9
2. Standalones batch: `find_result_waiter`, `scoped_browser_locale`, `search_test_utils`, `tracing`, `web_feature_histogram_tester`, `autocomplete_change_observer`
3. Complete `:test_launcher_utils` (add `.cc`)
4. `chrome_render_view_host_test_harness` + `chrome_render_view_test` (separate)
5. `scoped_metrics_service_for_synthetic_trials`
6. `:test_suites` (heaviest)
7. `chrome_test_utils` (needs `platform_browser_test.h` thin-target pre-fix first)
### Deferred — TBP `allow_circular_includes_from` cleanup
Three entries remain in `testing_browser_process`'s `allow_circular_includes_from` that need structural fixes before removal:
| Entry | Root cause | Fix |
|-------|-----------|-----|
| `testing_profile_impl` | `testing_profile_manager.cc` includes `testing_browser_process.h` | Remove/forward-declare TBP.h in `testing_profile_manager.cc` |
| `extensions:test_support` | `extensions:test_support` sources include `testing_browser_process.h` | TBP Pattern A split (thin header-only + impl) |
| `ash/settings:test_support`, `ash/system_web_apps/test_support` | Same — include `testing_browser_process.h` on cros | TBP Pattern A split |
The Pattern A split for TBP mirrors what was done for `testing_profile` in CL6-CL7: produce a thin `testing_browser_process` target with only the header deps it needs, and a `testing_browser_process_impl` that carries the heavy ash/extensions deps. The three targets that currently include `testing_browser_process.h` can then dep on the thin target directly, eliminating the cycles.
### Phase 2c — remaining `base/` sources to audit and extract
These files still live in `//chrome/test:test_support` sources. Each needs a dep audit before extraction.
| Files | Likely complexity | Notes |
|-------|------------------|-------|
| `find_result_waiter.{cc,h}` | Low | Probably just `//base` + find-in-page deps |
| `scoped_browser_locale.{cc,h}` | Low | Probably just locale deps |
| `scoped_metrics_service_for_synthetic_trials.{cc,h}` | Low | Metrics deps only |
| `chrome_test_utils.{cc,h}` | Low–medium | Used by many tests; check for profile/browser deps |
| `tracing.{cc,h}` | Low–medium | Tracing deps |
| `web_feature_histogram_tester.{cc,h}` | Low–medium | Histogram/metrics deps |
| `search_test_utils.{cc,h}` | Medium | Search service deps |
| `web_ui_mocha_browser_test.{cc,h}` | Medium | WebUI + browser test harness deps |
| `chrome_test_suite.{cc,h}`, `chrome_unit_test_suite.{cc,h}` | High | Test suite infrastructure; many deps |
| `chrome_render_view_host_test_harness.{cc,h}`, `chrome_render_view_test.{cc,h}` | High | Render process deps; may require staging |
| `test_launcher_utils.cc`, `in_process_browser_test.cc`, `ui_test_utils.cc` | Blocked | Blocked by `//chrome/browser/ui:test_support → //chrome/test:test_support_ui` cycle; thin targets already extracted |
### Long-term blocker — `browser/ui:test_support` cycle
The three thin targets (`:test_launcher_utils`, `:in_process_browser_test`, `:ui_test_utils`) have their `.cc` impls stuck in the monolith because moving them would require resolving:
```
//chrome/browser/ui:test_support → //chrome/test:test_support_ui → (would dep on) //chrome/browser/ui
```
Breaking this requires either a Pattern A split of `//chrome/browser/ui:test_support` or a pre-extraction of whatever `//chrome/browser/ui:test_support` needs from `test_support_ui`. Deferred until the Bedrock team's `chrome/browser/ui` modularization creates a natural opportunity.
---
## BUILD.gn Patterns
### New per-directory `BUILD.gn`
```gn
# chrome/test/interaction/BUILD.gn
source_set("test_support") {
testonly = true
sources = [
"interactive_browser_test.cc",
"interactive_browser_test.h",
# ...
]
public_deps = [
"//ui/base/interaction:interaction",
]
deps = [
"//chrome/test/base:in_process_browser_test",
"//chrome/browser/ui",
"//content/public/test:test_support",
]
# testonly=true prevents production code from depending here regardless
# of visibility.
visibility = [
"//chrome/browser/*",
"//chrome/test/*",
]
}
```
### Root `chrome/test/BUILD.gn` after modularization
```gn
static_library("test_support") {
testonly = true
# Before: "interaction/interactive_browser_test.cc", ...
# After: dep on the child target
public_deps = [
"//chrome/test/base:test_support",
"//chrome/test/interaction:test_support",
"//chrome/test/user_education:test_support",
# ...
]
}
```
### Validation commands
```sh
gn check out/linux # desktop
gn check out/cros # ChromeOS (catches ash/extensions include cycles)
gn check out/android # Android
```
Always run all three before uploading — platform-specific deps can hide gn check
failures that only surface in one config.
---
## Handling Circular Dependencies
### Background: dep-graph cycle vs. include cycle
GN cycle detection operates on the **dep graph** (`deps`/`public_deps` entries),
not on `#include` lines. Two targets can mutually include each other's headers at
compile time while keeping the dep graph a DAG — as long as only one dep edge exists
and `allow_circular_includes_from` is declared.
> [!info] Key insight
> `allow_circular_includes_from` is a **header-include permission** — invisible to the dep graph. If target A has B in its direct deps, A can declare `allow_circular_includes_from = ["//B"]` to permit B's sources to `#include` A's headers. GN sees a DAG, `gn check` stays green.
### Case 1 — `interaction:interaction` / `test_support_ui` (include cycle)
`test_support_ui` deps on `interaction:interaction` (via `public_deps`), but
interaction source files include headers still compiled inside `test_support_ui`
(`in_process_browser_test.h`, etc.). A naïve reading suggests a cycle — but it
isn't, because `interaction:interaction` has **no dep edge back** to `test_support_ui`.
```
test_support_ui ──public_deps──► interaction:interaction
test_support_ui ──allow_circular_includes_from──► interaction:interaction
↑ interaction's files may #include test_support_ui headers
```
A related issue: `interaction:interaction` needed `test_switches.h` from `test_support`.
Adding `//chrome/test:test_support` as a dep would close a true dep-graph cycle
(`test_support_ui → interaction → test_support → test_support_ui`). Fix: extract
`test_switches.{h,cc}` into a thin `//chrome/test:test_switches` with zero deps.
> [!warning]
> `allow_circular_includes_from` cannot rescue a **true dep-graph cycle** — GN errors regardless. True cycles require structural fixes (pre-extraction of a thin target, Pattern A split, etc.).
### Case 2 — `testing_profile` / `testing_browser_process` extraction (reverse-dep problem)
When extracting `testing_profile` and `testing_browser_process` into standalone
targets, `gn check` failed on three ash targets and one extensions target whose
source files include `testing_profile.h` or `testing_browser_process.h`:
| Target | Includes |
|---|---|
| `//chrome/browser/ash/settings:test_support` | both `testing_profile.h` and `testing_browser_process.h` |
| `//chrome/browser/ash/system_web_apps/test_support` | `testing_browser_process.h` (×2 files) |
| `//chrome/browser/extensions:test_support` | `testing_browser_process.h` |
Adding a direct dep from these targets to the new targets would create a dep-graph
cycle because `testing_profile` and `testing_browser_process` *already depend on*
those ash/extensions targets.
**Interim fix:** `allow_circular_includes_from` in the extracted targets:
```gn
source_set("testing_profile") {
allow_circular_includes_from = [] # initialize; extended in blocks below
if (enable_extensions_core) {
deps += [ "//chrome/browser/extensions:test_support" ]
# TODO(crbug.com/353332589): Remove with Pattern A split.
allow_circular_includes_from += [ "//chrome/browser/extensions:test_support" ]
}
if (is_chromeos) {
public_deps += [ "//chrome/browser/ash/settings:test_support" ]
# TODO(crbug.com/353332589): Remove with Pattern A split.
allow_circular_includes_from += [ "//chrome/browser/ash/settings:test_support" ]
}
}
source_set("testing_browser_process") {
allow_circular_includes_from = [ "//chrome/test/base:testing_profile" ] # existing
if (enable_extensions_core) {
deps += [
"//chrome/browser/extensions",
"//chrome/browser/extensions:test_support", # new direct dep (was only transitive)
]
# TODO(crbug.com/353332589): Remove with Pattern A split.
allow_circular_includes_from += [ "//chrome/browser/extensions:test_support" ]
}
if (is_chromeos) {
deps += [
"//chrome/browser/ash/policy/core",
"//chrome/browser/media_galleries",
"//chrome/browser/ash/settings:test_support", # new
"//chrome/browser/ash/system_web_apps/test_support", # new
]
# TODO(crbug.com/353332589): Remove with Pattern A split.
allow_circular_includes_from += [
"//chrome/browser/ash/settings:test_support",
"//chrome/browser/ash/system_web_apps/test_support",
]
}
}
```
**Key constraint:** The target listed in `allow_circular_includes_from` must be
a **DIRECT** dep of the declaring target. If it was only transitive before, add it
as a direct dep first.
**CL6 — Pattern A split (`chromod/chrome-test-base-6`):** Splits `testing_profile` into:
- `testing_profile` — thin header-only (`.h` only; on cros, still needs `ash/settings:test_support` because `testing_profile.h` has a `ScopedCrosSettingsTestHelper` by-value member)
- `testing_profile_impl` — full `.cc` + heavy deps (ash, extensions)
Result: `extensions:test_support` can now dep directly on `testing_profile` (header-only) — the extensions `allow_circular_includes_from` entry in `testing_profile` is removed. The cros case (`ash/settings:test_support`) still requires `allow_circular_includes_from` because `testing_profile.h` directly includes from `ash/settings:test_support` (preventing `ash/settings` from deping back).
**Remaining deferred:**
- *cros*: Forward-declare `ScopedCrosSettingsTestHelper` in `testing_profile.h`, use `unique_ptr` for the member → header dep on `ash/settings:test_support` moves to `.cc` → `ash/settings:test_support` can dep on `testing_profile` directly → remove `allow_circular_includes_from` from `testing_profile` and TBP.
- *all platforms*: Remove `testing_profile_manager.cc`'s include of `testing_browser_process.h` → TBP can drop `allow_circular_includes_from = ["testing_profile_impl"]`.
---
## Explicit Dep Audit (required for "done")
After extracting a new module, a required follow-up step is a dep scope audit:
find every external consumer of the new module's headers and narrow `public_deps`
to `deps` wherever safe. Without this step, the new module leaks transitively
through the monolith just as the original sources did.
### Checklist
1. For each header in the new module, run `gn refs` to find all external GN targets.
2. For each depending target: check whether any of its *own* public headers include
the new module's headers. If not, move the new module from `public_deps` to `deps`.
3. Where it must stay in `public_deps`, add a `TODO(crbug.com/353332589)` naming
the specific public header that blocks the cleanup.
### Example: `chrome/test/views`
`test_support_ui`: `//chrome/test/views` moved `public_deps` → `deps`. Safe because
`in_process_browser_test.h` (the public header) includes no views headers.
`test_support`: `//chrome/test/views` stays in `public_deps` — blocked by
`browser_with_test_window_test.h`, which uses `ChromeTestViewsDelegate<>` as a
concrete member field (full definition required). Unblocks when that header is
componentized in Phase 2c.
---
## References
- [Project Bedrock design doc](https://chromium.googlesource.com/chromium/src/+/main/docs/modularization.md)
- `chrome/test/interaction/BUILD.gn` — first CL in this effort; reference for BUILD.gn pattern
- `chrome/test/base/BUILD.gn` — active extraction target
- `chrome/test/BUILD.gn` — monolith, being reduced
- Bug tracker: [crbug.com/353332589](https://crbug.com/353332589)