Selective Tests by Component (Multi-Language)
Selective test execution runs only the test suites affected by a change.
The unit of narrowing is a stacks[] entry — and a
“stack” does not have to mean a separate
service or a separate repository. It can be a component inside a
single repo: a frontend, a backend, a shared library, a docs tree, a set
of scripts. This tutorial shows how to split one project into component
stacks — including a mixed-language example (React, Angular,
Python, Go, C++) — so a change to one component runs only that
component’s tests.
Why narrow by component
On a project with several components, a one-line change to the frontend should not run the backend and firmware test suites. Without narrowing, a developer waits for the whole suite on every pull request. With component narrowing, the pull request runs only the affected component’s tests — minutes instead of the full run — while the full suite still runs once at the final promotion (staging → main) as the last gate before release.
How narrowing works
Three deterministic steps drive every pull request:
-
detect-affected — maps each changed file path
to its owning
stacks[]entry by longest-prefix match on that entry’spathsglobs. -
cross-refs walk — expands the affected set along
declared
cross_refsdependency edges (a change to a shared component pulls in the components that depend on it). -
generate-pipeline — emits one CI matrix job per
affected stack; each job runs that stack’s own
test_cmd.
The narrowing is therefore only as fine-grained as your
stacks[] partition. One stack covering the whole repo
narrows to “everything.” Many component stacks narrow to
“just the component(s) you touched.”
The single-stack trap
A common configuration declares a single stack whose paths
cover the entire source tree:
# .gaia/config/project-config.yaml (narrows to nothing useful)
stacks:
- name: app
language: typescript
paths:
- "src/**"
- "lib/**"
- "docs/**"
- "tests/**"
test_cmd: "npm test" # the WHOLE suite, every time
Here detect-affected always returns ["app"]
— one stack out of one — so the “narrowed” run is
the full suite. The machinery works; there is simply nothing to narrow
to. The fix is to split app into components.
Model each component as a stack
Give each component its own stacks[] entry with a
non-overlapping paths list and its own test_cmd.
Components can share a language or mix languages — the
language field only sets the default tooling/persona; the
narrowing is driven entirely by paths.
# .gaia/config/project-config.yaml (single repo, four components)
stacks:
- name: web
language: typescript
paths: [ "apps/web/**" ]
test_cmd: "npm --prefix apps/web test"
- name: api
language: python
paths: [ "services/api/**" ]
test_cmd: "pytest services/api"
- name: shared
language: typescript
paths: [ "packages/shared/**" ]
test_cmd: "npm --prefix packages/shared test"
- name: docs
language: markdown
paths: [ "docs/**", "*.md" ]
test_cmd: "markdownlint docs"
Now a change under apps/web/ resolves to
["web"] and CI runs only npm --prefix apps/web test.
A docs-only change resolves to ["docs"] and runs only the
markdown lint — the application suites are skipped entirely.
Multi-language example (React, Angular, Python, Go, C++)
Components are language-agnostic, so a single repository can hold a React web app, an Angular admin console, a Python API, a Go worker, and a C++ firmware module — each narrowing independently:
# .gaia/config/project-config.yaml (mixed-language monorepo)
stacks:
# ---- frontend components ----
- name: web-react
language: typescript
paths: [ "apps/web/**" ]
test_cmd: "npm --prefix apps/web run test:ci"
- name: admin-angular
language: angular
paths: [ "apps/admin/**" ]
test_cmd: "npm --prefix apps/admin run test -- --watch=false"
# ---- backend components ----
- name: api-python
language: python
paths: [ "services/api/**" ]
test_cmd: "pytest services/api -q"
- name: worker-go
language: go
paths: [ "services/worker/**" ]
test_cmd: "go test ./services/worker/..."
# ---- firmware component ----
- name: firmware-cpp
language: cpp
paths: [ "firmware/**" ]
test_cmd: "ctest --test-dir firmware/build"
# ---- shared design system used by BOTH frontends ----
- name: design-system
language: typescript
paths: [ "packages/design-system/**" ]
test_cmd: "npm --prefix packages/design-system test"
cross_refs: [ "web-react", "admin-angular" ]
| You change… | Affected set | CI runs |
|---|---|---|
apps/web/src/App.tsx |
["web-react"] |
React tests only |
apps/admin/src/app.component.ts |
["admin-angular"] |
Angular tests only |
services/api/routes/users.py |
["api-python"] |
pytest (API) only |
services/worker/main.go |
["worker-go"] |
go test (worker) only |
firmware/src/driver.cpp |
["firmware-cpp"] |
ctest (firmware) only |
packages/design-system/Button.tsx |
["design-system", "web-react", "admin-angular"] |
design-system + BOTH frontends (via cross_refs) |
Dependencies between components: cross_refs
Components are rarely independent. When component B imports component A, a
change to A must also test B — otherwise B’s narrow run could
pass while A’s change silently breaks it. Declare the dependency
with cross_refs on the depended-upon stack:
- name: design-system
paths: [ "packages/design-system/**" ]
test_cmd: "npm --prefix packages/design-system test"
cross_refs: [ "web-react", "admin-angular" ] # both frontends depend on it
The cross-refs walk is transitive: if web-react in turn had
dependents, they would be pulled in too. Model the real import graph;
when in doubt, add the edge — a missed edge is a false-green, an
extra edge merely runs one more suite.
Per-component test commands
Each stack’s test_cmd is the exact command CI runs for
that component, executed from the repository root. It must select
only that component’s tests — the narrowing is only
as good as the command’s scoping:
- React / Angular: scope to the workspace, e.g.
npm --prefix apps/web testor an Nx/Turbo target likenx test web. - Python: point pytest at the package directory, e.g.
pytest services/api. - Go: scope the package pattern, e.g.
go test ./services/worker/.... - C++: target the component’s test binary/dir, e.g.
ctest --test-dir firmware/build.
If a test runner cannot select by directory, organise tests so each component’s tests live under their own path (or carry a tag the command can filter on). A flat test pile where any component’s tests are interleaved cannot be narrowed reliably.
Before / after
| Change | Single stack (whole repo) | Component stacks |
|---|---|---|
| One React component | Full suite (all languages) | React tests only |
| One Python route | Full suite | pytest (API) only |
| Docs typo | Full suite | markdown lint only |
| Shared design token | Full suite | design-system + dependent frontends |
| Promotion staging → main | Full suite | Full suite (unchanged — last gate before release) |
Setup checklist
- List your repo’s components and the directory each owns.
- Add one
stacks[]entry per component with a non-overlappingpathslist and atest_cmdthat runs only that component’s tests. - Organise tests so each component’s tests are selectable by path (or tag).
- Add
cross_refsedges for every “B depends on A” relationship (declared on A, listing its dependents). - Validate with
/gaia-config-validate, then open a small single-component pull request and confirm CI ran only that component’s suite. - Keep the full suite on the staging → main promotion — it is the final gate and is intentionally not narrowed.
What to read next:
Selective Test Execution for
the per-trigger test_policy and safety rails, and
Shape: Multi-Repo / Per-Service when
components live in separate repositories.