When would you choose ProjectBuilder over TestKit for testing a Gradle plugin, and what are the trade-offs?
answer
- in-process vs out-of-process
- config phase only vs real execution
- white-box model assertions vs BuildResult/TaskOutcome
- fast/introspective vs realistic/slow
- broad ProjectBuilder + focused TestKit
basics
~10 sUse ProjectBuilder for fast, in-process unit tests that check configuration wiring (tasks/extensions registered). Use TestKit when you need to run a real build out-of-process and assert on execution, task outcomes, or build output.
solid answer
~40 sProjectBuilder and TestKit cover complementary layers. **ProjectBuilder** builds an in-memory `Project` in the test JVM; it's white-box and fast (milliseconds), and you assert directly on the model — `tasks.findByName`, `extensions`, `configurations`. It only exercises the **configuration phase**, so it can't verify task actions, lifecycle, dependency resolution against real repos, or configuration cache. **TestKit** (`GradleRunner`) runs a real Gradle build in a separate process against a temp project, returning a `BuildResult` with output and `TaskOutcome`s; it's black-box and slow but realistic, and is the only way to verify execution-phase behaviour, up-to-date/incremental, and cross-version compatibility. The trade-off is **speed and introspection vs realism**. A good plugin suite uses ProjectBuilder broadly for cheap wiring coverage and a smaller set of TestKit tests for end-to-end behaviour, keeping CI fast while still validating real builds.
code
kotlin · 13 lines// ProjectBuilder: fast wiring check
val project = ProjectBuilder.builder().build()
project.plugins.apply("com.example.greeting")
assertNotNull(project.tasks.findByName("greet"))
// TestKit: real run, asserts outcome
val result = GradleRunner.create()
.withProjectDir(tempDir)
.withArguments("greet")
.withPluginClasspath()
.build()
assertEquals(TaskOutcome.SUCCESS, result.task(":greet")?.outcome)
assertTrue(result.output.contains("Hello"))go deeper
State that ProjectBuilder is fast/in-process for wiring and TestKit runs a real build.
Map each concern (registration vs execution/outcomes/caching) to the right tool and explain the speed-vs-realism trade-off.
Reason about the test pyramid for a plugin: broad ProjectBuilder coverage plus focused TestKit, including cross-version testing.
Set a team-wide testing policy balancing CI cost, flake risk, and realism; decide what must be TestKit-covered versus what ProjectBuilder coverage suffices for.
## The two test layers Gradle gives plugin authors two officially supported testing approaches, and they answer different questions. ### ProjectBuilder — in-process unit test - Class: `org.gradle.testfixtures.ProjectBuilder`. - Builds a real `Project` **inside the test JVM**. - White-box: you can call any `Project` API and internal-ish containers (`tasks`, `plugins`, `extensions`, `configurations`). - Exercises only the **configuration phase** — i.e. the code that runs when your `Plugin<Project>.apply()` executes. - Pros: millisecond-fast, no daemon, easy debugging in the IDE, direct assertions on the model. - Cons: no execution phase (task `@TaskAction`/`doLast` don't run), lifecycle hooks like `afterEvaluate` don't fire unless forced, no real dependency resolution, no configuration-cache validation, and it can drift from genuine build behaviour because it bypasses parts of the real lifecycle. ### TestKit — out-of-process functional test - Entry point: `GradleRunner.create()` from `gradleTestKit()`. - Writes a temporary build (build script + sources) to a temp dir and runs a **real Gradle build in a separate process**. - Black-box: you assert on `BuildResult.output` and `result.task(":foo").outcome` (`SUCCESS`, `UP_TO_DATE`, `FAILED`, `SKIPPED`). - Pros: real lifecycle, real task execution, incremental/up-to-date behaviour, cross-Gradle-version testing via `withGradleVersion`, configuration-cache validation. - Cons: slow (process + daemon startup), harder to debug, only observes external behaviour. ## Decision guide Choose **ProjectBuilder** when: - You're verifying that applying the plugin **registers** the right tasks/extensions/configurations. - You're testing pure configuration logic (extension defaults, conventions, conditional task creation). - You want a large number of fast tests. Choose **TestKit** when: - You need a task to actually **run** and produce outputs. - You're testing incrementality, caching, up-to-date checks, or configuration-cache compatibility. - You need to validate behaviour across Gradle versions. - You're testing the build from a user's perspective (the public contract). ## A balanced strategy The pragmatic split is a **broad base of ProjectBuilder tests** (cheap wiring coverage) plus a **focused set of TestKit tests** (the critical end-to-end paths). This mirrors the test pyramid: many fast unit tests, fewer slow functional tests. Relying on ProjectBuilder alone risks shipping a plugin whose tasks are wired but broken at execution; relying on TestKit alone makes CI slow and tests harder to pinpoint.
- Why can't ProjectBuilder verify that a task is UP-TO-DATE on a second run?Up-to-date checks are an execution-phase concept driven by Gradle's task execution and input/output snapshotting across runs. ProjectBuilder never executes tasks, so there is no first-run output to compare against — only TestKit's real builds expose TaskOutcome.
- Which tool would you use to test configuration-cache compatibility, and why?TestKit, run with `--configuration-cache`. Configuration cache serializes the configured task graph in a real build process; ProjectBuilder doesn't participate in that machinery at all.
saying these in an interview costs you the question
- Saying ProjectBuilder and TestKit are interchangeable.
- Claiming ProjectBuilder can assert on task outcomes (SUCCESS/UP_TO_DATE).
- Using only TestKit and accepting a slow, hard-to-debug suite when wiring tests would do.