skip to content

You maintain a multi-module build where tests need consistent, prod-parity configuration injected (env vars and system properties) across modules. How would you architect this so it stays reproducible, cacheable, and secret-safe?

level: principalimportance: should knowfreq 22%

answer

  1. convention plugin + tasks.withType<Test>.configureEach
  2. provider-sourced stable values
  3. no volatile/absolute-path inputs → portable cache
  4. secrets never in tracked inputs / build scans
  5. env for prod-parity read path

basics

~20 s

Centralize test config in a convention plugin that configures every Test task: stable systemProperty/environment values from providers, no volatile inputs, no real secrets in build inputs. Inject secrets at runtime via env from the CI platform, kept out of tracked inputs.

solid answer

~50 s

I'd factor the injection into a **convention plugin** (precompiled `*.gradle.kts` or a buildSrc/included-build plugin) that configures `tasks.withType<Test>` once and is applied by every module. It would forward a curated, stable set of values via `systemProperty`/`environment`, sourced from `providers.gradleProperty`/`providers.environmentVariable` so it's lazy and configuration-cache safe. Reproducibility: only declared, deterministic values become inputs — no timestamps, random ports, or absolute paths (use path-normalized inputs instead) so the **build cache stays portable** across CI agents. Secret safety: real credentials never get baked into build inputs (they'd land in build scans, cache keys, and logs). Instead the CI platform injects secrets as process env at execution, and I keep those out of the **tracked** input set (untracked/`@Internal`) so they don't poison the cache or leak. Prod-parity: prefer env vars where production reads `System.getenv`, so tests exercise the real read path. This gives one consistent, auditable place to govern test config.

code

kotlin · 7 lines
kotlin
// buildSrc: katajob.test-config.gradle.kts
tasks.withType<Test>().configureEach {
    systemProperty("app.env", providers.gradleProperty("appEnv").getOrElse("test"))
    environment("APP_REGION", providers.gradleProperty("region").getOrElse("local"))
    // Secrets: injected by CI as real env at runtime, NOT declared here as inputs,
    // so they never enter the cache key or a build scan.
}

go deeper

for a junior

Not expected; at most note that test config should be consistent across modules.

for a middle

Suggest centralizing in a shared script/convention and using stable provider-sourced values.

for a senior

Detail cache portability, prod-parity read paths, and keeping secrets out of tracked inputs.

for a principal

Define the governance model: one convention plugin, CI guardrails against volatile/absolute inputs and secret leakage, and platform-injected runtime secrets.

## Goal Across many modules, tests should receive **consistent** configuration that (a) mirrors production read paths, (b) is **reproducible** so caching works, and (c) never leaks **secrets**. Doing this per-module by copy-paste rots quickly. Centralize it. ## 1. Centralize in a convention plugin Write a precompiled script plugin (e.g. `buildSrc/src/main/kotlin/katajob.test-config.gradle.kts`) and apply it everywhere. It configures all `Test` tasks at once: ```kotlin // katajob.test-config.gradle.kts tasks.withType<Test>().configureEach { systemProperty("app.env", providers.gradleProperty("appEnv").getOrElse("test")) environment("APP_REGION", providers.gradleProperty("region").getOrElse("local")) // stable, declared values only } ``` Using `configureEach` keeps it lazy and applies to every test task in the module, including custom ones. ## 2. Reproducible inputs → portable cache The `systemProperties` and `environment` maps are tracked inputs. To keep a **remote** build cache useful across agents: - No `System.currentTimeMillis()`, random ports, or run-specific tokens as tracked inputs. - No absolute paths — use Gradle's path normalization / proper file inputs so the cache key is machine-independent. - Source values from providers (lazy, config-cache safe) rather than mutable project state read at execution. ## 3. Prod-parity read paths If production reads `System.getenv("DATABASE_URL")`, set it via `environment(...)` so the test exercises the same path. If it reads `System.getProperty(...)`, use `systemProperty(...)`. Matching the mechanism prevents 'works in test, not in prod' gaps. ## 4. Secret safety This is the subtle one. **Never** bake real secrets into build inputs: - Tracked inputs feed the **cache key** and may appear in **build scans** and logs. - A secret in `systemProperty("db.password", realSecret)` can leak via scan, console, or a shared cache entry. Instead: let the **CI platform** inject secrets as process environment at execution time, and ensure those values are **not declared as tracked inputs** (untracked/`@Internal`), so they reach the JVM without entering the cache key or scan. For local dev, read from a developer's environment, never commit secrets to `gradle.properties` in VCS. ## 5. Governance With one plugin owning test config you get a single audit point: a reviewer can verify no volatile inputs and no secret leakage, and every module inherits the same behavior. Add a CI check that fails the build if a `Test` task declares an absolute path or an obviously volatile property. ## Summary Convention plugin + provider-sourced stable values + env-for-prod-parity + secrets-as-untracked-runtime-env = consistent, cacheable, leak-free test configuration at scale.

  • Why is putting a real secret in systemProperty dangerous beyond just style?
    Tracked inputs feed the cache key and can surface in build scans and logs, so the secret can leak into shared caches and observability tooling.
  • How do you apply the same test config to every module without duplication?
    A convention/precompiled script plugin in buildSrc or an included build that configures tasks.withType<Test>().configureEach, applied by each module.
  • How do secrets reach the test JVM if they're not declared as inputs?
    The CI platform injects them as real process environment variables at execution time; the fork inherits them via System.getenv without them being tracked Gradle inputs.

saying these in an interview costs you the question

  • Storing real secrets in gradle.properties or systemProperty where they enter inputs/scans
  • Copy-pasting test config per module instead of a convention plugin
  • Letting absolute paths or volatile tokens into tracked inputs and killing remote cache hits

context