build() {
+    // Immutable copy.
+    return List.copyOf(craftedFlags);
+  }
+
+  private EcjJctFlagBuilderImpl addFlagIfTrue(boolean condition, String flag) {
+    if (condition) {
+      craftedFlags.add(flag);
+    }
+
+    return this;
+  }
+
+  private EcjJctFlagBuilderImpl addVersionIfPresent(String flagPrefix, @Nullable String version) {
+    if (version != null) {
+      craftedFlags.add(flagPrefix);
+      craftedFlags.add(version);
+    }
+
+    return this;
+  }
+}
diff --git a/java-compiler-testing/src/main/java/io/github/ascopes/jct/junit/EcjCompilerTest.java b/java-compiler-testing/src/main/java/io/github/ascopes/jct/junit/EcjCompilerTest.java
new file mode 100644
index 000000000..62532cc0e
--- /dev/null
+++ b/java-compiler-testing/src/main/java/io/github/ascopes/jct/junit/EcjCompilerTest.java
@@ -0,0 +1,140 @@
+/*
+ * Copyright (C) 2022 - 2025, the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.github.ascopes.jct.junit;
+
+import io.github.ascopes.jct.compilers.JctCompilerConfigurer;
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Tags;
+import org.junit.jupiter.api.TestTemplate;
+import org.junit.jupiter.api.condition.DisabledInNativeImage;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ArgumentsSource;
+
+/**
+ * Annotation that can be applied to a JUnit parameterized test to invoke that test case across
+ * multiple ECJ compilers, each configured to a specific version in a range of Java language
+ * versions.
+ *
+ * This will also add the {@code "java-compiler-testing-test"} tag and {@code "ecj-test"}
+ * tags to your test method, meaning you can instruct your IDE or build system to optionally only
+ * run tests annotated with this method for development purposes. As an example, Maven Surefire
+ * could be instructed to only run these tests by passing {@code -Dgroup="ecj-test"} to Maven.
+ *
+ * @author Ashley Scopes
+ * @since TBC
+ */
+@ArgumentsSource(EcjCompilersProvider.class)
+@DisabledInNativeImage
+@Documented
+@ParameterizedTest(name = "for compiler \"{0}\"")
+@Retention(RetentionPolicy.RUNTIME)
+@Tags({
+    @Tag("java-compiler-testing-test"),
+    @Tag("ecj-test")
+})
+@Target({
+    ElementType.ANNOTATION_TYPE,
+    ElementType.METHOD,
+})
+@TestTemplate
+public @interface EcjCompilerTest {
+
+  /**
+   * Minimum version to use (inclusive).
+   *
+   * 
By default, it will use the lowest possible version supported by the compiler. This
+   * varies between versions of the JDK that are in use.
+   *
+   * 
If the version is lower than the minimum supported version, then the minimum supported
+   * version of the compiler will be used instead. This enables writing tests that will work on a
+   * range of JDKs during builds without needing to duplicate the test to satisfy different JDK
+   * supported version ranges.
+   *
+   * @return the minimum version.
+   */
+  int minVersion() default Integer.MIN_VALUE;
+
+  /**
+   * Maximum version to use (inclusive).
+   *
+   * 
By default, it will use the highest possible version supported by the compiler. This
+   * varies between versions of the JDK that are in use.
+   *
+   * 
If the version is higher than the maximum supported version, then the maximum supported
+   * version of the compiler will be used instead. This enables writing tests that will work on a
+   * range of JDKs during builds without needing to duplicate the test to satisfy different JDK
+   * supported version ranges.
+   *
+   * @return the maximum version.
+   */
+  int maxVersion() default Integer.MAX_VALUE;
+
+  /**
+   * Get an array of compiler configurer classes to apply in-order before starting the test.
+   *
+   * 
Each configurer must have a public no-args constructor, and their package must be
+   * open to this module if JPMS modules are in-use, for example:
+   *
+   * 
+   * module mytests {
+   *   requires io.github.ascopes.jct;
+   *   requires org.junit.jupiter.api;
+   *
+   *   opens org.example.mytests to io.github.ascopes.jct;
+   * }
+   * 
+   *
+   * An example of usage:
+   *
+   * 
+   *   public class WerrorConfigurer implements JctCompilerConfigurer<RuntimeException> {
+   *     {@literal @Override}
+   *     public void configure(JctCompiler compiler) {
+   *       compiler.failOnWarnings(true);
+   *     }
+   *   }
+   *
+   *   // ...
+   *
+   *   class SomeTest {
+   *     {@literal @EcjCompilerTest(configurers = WerrorConfigurer.class)}
+   *     void someTest(JctCompiler compiler) {
+   *       // ...
+   *     }
+   *   }
+   * 
+   *
+   * @return an array of classes to run to configure the compiler. These run in the given order.
+   */
+  Class extends JctCompilerConfigurer>>[] configurers() default {};
+
+  /**
+   * The version strategy to use.
+   *
+   * This determines whether the version number being iterated across specifies the
+   * release, source, target, or source and target versions.
+   *
+   * 
The default is to specify the release.
+   *
+   * @return the version strategy to use.
+   */
+  VersionStrategy versionStrategy() default VersionStrategy.RELEASE;
+}
diff --git a/java-compiler-testing/src/main/java/io/github/ascopes/jct/junit/EcjCompilersProvider.java b/java-compiler-testing/src/main/java/io/github/ascopes/jct/junit/EcjCompilersProvider.java
new file mode 100644
index 000000000..4df27e4dd
--- /dev/null
+++ b/java-compiler-testing/src/main/java/io/github/ascopes/jct/junit/EcjCompilersProvider.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2022 - 2025, the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.github.ascopes.jct.junit;
+
+import io.github.ascopes.jct.compilers.JctCompiler;
+import io.github.ascopes.jct.compilers.impl.EcjJctCompilerImpl;
+import org.junit.jupiter.params.support.AnnotationConsumer;
+
+/**
+ * Argument provider for the {@link EcjCompilerTest} annotation.
+ *
+ * @author Ashley Scopes
+ * @since 5.0.0
+ */
+public final class EcjCompilersProvider extends AbstractCompilersProvider
+    implements AnnotationConsumer {
+
+  /**
+   * Initialise the provider.
+   *
+   * This is only visible for testing purposes, users should have no need to
+   * initialise this class directly.
+   */
+  EcjCompilersProvider() {
+    // Visible for testing only.
+  }
+
+  @Override
+  protected JctCompiler initializeNewCompiler() {
+    return new EcjJctCompilerImpl();
+  }
+
+  @Override
+  protected int minSupportedVersion() {
+    return EcjJctCompilerImpl.getEarliestSupportedVersionInt();
+  }
+
+  @Override
+  protected int maxSupportedVersion() {
+    return EcjJctCompilerImpl.getLatestSupportedVersionInt();
+  }
+
+  @Override
+  public void accept(EcjCompilerTest annotation) {
+    var min = annotation.minVersion();
+    var max = annotation.maxVersion();
+    var configurers = annotation.configurers();
+    var versioning = annotation.versionStrategy();
+    configure(min, max, configurers, versioning);
+  }
+}
diff --git a/java-compiler-testing/src/main/java/module-info.java b/java-compiler-testing/src/main/java/module-info.java
index 7b9fefad5..febffd6c5 100644
--- a/java-compiler-testing/src/main/java/module-info.java
+++ b/java-compiler-testing/src/main/java/module-info.java
@@ -78,7 +78,8 @@
  *
  *    class JsonSchemaAnnotationProcessorTest {
  *
- *      {@literal @JavacCompilerTest(minVersion=11, maxVersion=19)}
+ *      {@literal @EcjCompilerTest(minVersion=17)}
+ *      {@literal @JavacCompilerTest(minVersion=17)}
  *      void theJsonSchemaIsCreatedFromTheInputCode(JctCompiler compiler) {
  *
  *        try (var workspace = Workspaces.newWorkspace()) {
@@ -128,6 +129,7 @@
   requires java.management;
   requires me.xdrop.fuzzywuzzy;
   requires org.assertj.core;
+  requires org.eclipse.jdt.core.compiler.batch;
   requires static org.jspecify;
   requires static org.junit.jupiter.api;
   requires static org.junit.jupiter.params;
diff --git a/java-compiler-testing/src/test/java/io/github/ascopes/jct/compilers/JctCompilersTest.java b/java-compiler-testing/src/test/java/io/github/ascopes/jct/compilers/JctCompilersTest.java
index c89636de4..3bd0c3c17 100644
--- a/java-compiler-testing/src/test/java/io/github/ascopes/jct/compilers/JctCompilersTest.java
+++ b/java-compiler-testing/src/test/java/io/github/ascopes/jct/compilers/JctCompilersTest.java
@@ -17,6 +17,7 @@
 
 import static org.assertj.core.api.Assertions.assertThat;
 
+import io.github.ascopes.jct.compilers.impl.EcjJctCompilerImpl;
 import io.github.ascopes.jct.compilers.impl.JavacJctCompilerImpl;
 import io.github.ascopes.jct.fixtures.UtilityClassTestTemplate;
 import org.junit.jupiter.api.DisplayName;
@@ -53,4 +54,22 @@ void newPlatformCompilerReturnsTheExpectedInstance() {
           .satisfies(constructed -> assertThat(compiler).isSameAs(constructed));
     }
   }
+
+  @DisplayName(".newEcjCompiler() creates an EcjJctCompilerImpl instance")
+  @Test
+  void newEcjCompilerReturnsTheExpectedInstance() {
+    try (var ecjJctCompilerImplMock = Mockito.mockConstruction(EcjJctCompilerImpl.class)) {
+      // When
+      var compiler = JctCompilers.newEcjCompiler();
+
+      // Then
+      assertThat(compiler)
+          .isInstanceOf(EcjJctCompilerImpl.class);
+
+      assertThat(ecjJctCompilerImplMock.constructed())
+          .singleElement()
+          // Nested assertion to swap expected/actual args.
+          .satisfies(constructed -> assertThat(compiler).isSameAs(constructed));
+    }
+  }
 }
diff --git a/java-compiler-testing/src/test/java/io/github/ascopes/jct/compilers/impl/EcjJctCompilerImplTest.java b/java-compiler-testing/src/test/java/io/github/ascopes/jct/compilers/impl/EcjJctCompilerImplTest.java
new file mode 100644
index 000000000..002833292
--- /dev/null
+++ b/java-compiler-testing/src/test/java/io/github/ascopes/jct/compilers/impl/EcjJctCompilerImplTest.java
@@ -0,0 +1,145 @@
+/*
+ * Copyright (C) 2022 - 2025, the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.github.ascopes.jct.compilers.impl;
+
+import static io.github.ascopes.jct.fixtures.Fixtures.someInt;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mockConstruction;
+import static org.mockito.Mockito.mockStatic;
+
+import org.eclipse.jdt.internal.compiler.classfmt.ClassFileConstants;
+import org.eclipse.jdt.internal.compiler.tool.EclipseCompiler;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * {@link EcjJctCompilerImpl} tests.
+ *
+ * @author Ashley Scopes
+ */
+@DisplayName("EcjJctCompilerImpl tests")
+class EcjJctCompilerImplTest {
+
+  EcjJctCompilerImpl compiler;
+
+  @BeforeEach
+  void setUp() {
+    compiler = new EcjJctCompilerImpl();
+  }
+
+  @DisplayName("Compilers have the expected JSR-199 compiler factory")
+  @Test
+  void compilersHaveTheExpectedCompilerFactory() {
+    // When
+    var actualCompiler = compiler.getCompilerFactory().createCompiler();
+
+    // Then
+    assertThat(actualCompiler).isInstanceOf(EclipseCompiler.class);
+  }
+
+  @DisplayName("Compilers have the expected flag builder factory")
+  @Test
+  void compilersHaveTheExpectedFlagBuilderFactory() {
+    // Given
+    try (var flagBuilderMock = mockConstruction(EcjJctFlagBuilderImpl.class)) {
+      // When
+      var flagBuilder = compiler.getFlagBuilderFactory().createFlagBuilder();
+
+      // Then
+      assertThat(flagBuilderMock.constructed()).hasSize(1);
+      assertThat(flagBuilder).isSameAs(flagBuilderMock.constructed().get(0));
+    }
+  }
+
+  @DisplayName("Compilers have the expected default release string")
+  @Test
+  void compilersHaveTheExpectedDefaultRelease() {
+    // Given
+    try (var compilerClassMock = mockStatic(EcjJctCompilerImpl.class)) {
+      var latestSupportedInt = someInt(17, 21);
+      compilerClassMock
+          .when(EcjJctCompilerImpl::getLatestSupportedVersionInt)
+          .thenReturn(latestSupportedInt);
+
+      // When
+      var defaultRelease = compiler.getDefaultRelease();
+
+      // Then
+      compilerClassMock
+          .verify(EcjJctCompilerImpl::getLatestSupportedVersionInt);
+
+      assertThat(defaultRelease)
+          .isEqualTo("%d", latestSupportedInt);
+    }
+  }
+
+  @DisplayName("Compilers have the expected default name")
+  @Test
+  void compilersHaveTheExpectedDefaultName() {
+    // Then
+    assertThat(compiler.getName()).isEqualTo("ECJ");
+  }
+
+  @DisplayName("Compilers have no default compiler flags set")
+  @Test
+  void compilersHaveNoDefaultCompilerFlagsSet() {
+    // Then
+    assertThat(compiler.getCompilerOptions()).isEmpty();
+  }
+
+  @DisplayName("Compilers have no default annotation processor flags set")
+  @Test
+  void compilersHaveNoDefaultAnnotationProcessorFlagsSet() {
+    // Then
+    assertThat(compiler.getAnnotationProcessorOptions()).isEmpty();
+  }
+
+  @DisplayName("Compilers have no default annotation processors set")
+  @Test
+  void compilersHaveNoDefaultAnnotationProcessorsSet() {
+    // Then
+    assertThat(compiler.getAnnotationProcessors()).isEmpty();
+  }
+
+  @DisplayName("Compilers have the expected latest release")
+  @Test
+  void latestSupportedVersionReturnsTheExpectedValue() {
+    // Given
+    var expected = (int) ((ClassFileConstants.getLatestJDKLevel() >> 16L)
+        - ClassFileConstants.MAJOR_VERSION_0);
+
+    // When
+    var actual = EcjJctCompilerImpl.getLatestSupportedVersionInt();
+
+    // Then
+    assertThat(expected).isEqualTo(actual);
+  }
+
+  @DisplayName("Compilers have the expected earliest release")
+  @Test
+  void earliestSupportedVersionReturnsTheExpectedValue() {
+    // Given
+    var expected = (int) ((ClassFileConstants.JDK1_8 >> 16L)
+        - ClassFileConstants.MAJOR_VERSION_0);
+
+    // When
+    var actual = EcjJctCompilerImpl.getEarliestSupportedVersionInt();
+
+    // Then
+    assertThat(expected).isEqualTo(actual);
+  }
+}
diff --git a/java-compiler-testing/src/test/java/io/github/ascopes/jct/compilers/impl/EcjJctFlagBuilderImplTest.java b/java-compiler-testing/src/test/java/io/github/ascopes/jct/compilers/impl/EcjJctFlagBuilderImplTest.java
new file mode 100644
index 000000000..ea4995d74
--- /dev/null
+++ b/java-compiler-testing/src/test/java/io/github/ascopes/jct/compilers/impl/EcjJctFlagBuilderImplTest.java
@@ -0,0 +1,541 @@
+/*
+ * Copyright (C) 2022 - 2025, the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.github.ascopes.jct.compilers.impl;
+
+import static io.github.ascopes.jct.fixtures.Fixtures.someBoolean;
+import static io.github.ascopes.jct.fixtures.Fixtures.someRelease;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import io.github.ascopes.jct.compilers.CompilationMode;
+import io.github.ascopes.jct.compilers.DebuggingInfo;
+import io.github.ascopes.jct.fixtures.Fixtures;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.MethodOrderer.OrderAnnotation;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/**
+ * {@link EcjJctFlagBuilderImpl} tests.
+ *
+ * @author Ashley Scopes
+ */
+@DisplayName("EcjJctFlagBuilderImpl tests")
+@TestMethodOrder(OrderAnnotation.class)
+class EcjJctFlagBuilderImplTest {
+
+  EcjJctFlagBuilderImpl flagBuilder;
+
+  @BeforeEach
+  void setUp() {
+    flagBuilder = new EcjJctFlagBuilderImpl();
+  }
+
+  @DisplayName(".verbose(boolean) tests")
+  @Nested
+  class VerboseFlagTest {
+
+    @DisplayName("Setting .verbose(true) adds the '-verbose' flag")
+    @Test
+    void addsFlagIfTrue() {
+      // When
+      flagBuilder.verbose(true);
+
+      // Then
+      assertThat(flagBuilder.build()).contains("-verbose");
+    }
+
+    @DisplayName("Setting .verbose(false) does not add the '-verbose' flag")
+    @Test
+    void doesNotAddFlagIfFalse() {
+      // When
+      flagBuilder.verbose(false);
+
+      // Then
+      assertThat(flagBuilder.build()).doesNotContain("-verbose");
+    }
+
+    @DisplayName(".verbose(...) returns the flag builder")
+    @Test
+    void returnsFlagBuilder() {
+      // Then
+      assertThat(flagBuilder.verbose(someBoolean()))
+          .isSameAs(flagBuilder);
+    }
+  }
+
+  @DisplayName(".previewFeatures(boolean) tests")
+  @Nested
+  class PreviewFeaturesFlagTest {
+
+    @DisplayName("Setting .previewFeatures(true) adds the '--enable-preview' flag")
+    @Test
+    void addsFlagIfTrue() {
+      // When
+      flagBuilder.previewFeatures(true);
+
+      // Then
+      assertThat(flagBuilder.build()).contains("--enable-preview");
+    }
+
+    @DisplayName("Setting .previewFeatures(false) does not add the '--enable-preview'  flag")
+    @Test
+    void doesNotAddFlagIfFalse() {
+      // When
+      flagBuilder.previewFeatures(false);
+
+      // Then
+      assertThat(flagBuilder.build()).doesNotContain("--enable-preview");
+    }
+
+    @DisplayName(".previewFeatures(...) returns the flag builder")
+    @Test
+    void returnsFlagBuilder() {
+      // Then
+      assertThat(flagBuilder.previewFeatures(someBoolean()))
+          .isSameAs(flagBuilder);
+    }
+  }
+
+  @DisplayName(".showWarnings(boolean) tests")
+  @Nested
+  class ShowWarningsFlagTest {
+
+    @DisplayName("Setting .showWarnings(true) does not add the '-nowarn' flag")
+    @Test
+    void doesNotAddFlagIfTrue() {
+      // When
+      flagBuilder.showWarnings(true);
+
+      // Then
+      assertThat(flagBuilder.build()).doesNotContain("-nowarn");
+    }
+
+    @DisplayName("Setting .showWarnings(false) adds the '-nowarn'  flag")
+    @Test
+    void addsFlagIfFalse() {
+      // When
+      flagBuilder.showWarnings(false);
+
+      // Then
+      assertThat(flagBuilder.build()).contains("-nowarn");
+    }
+
+    @DisplayName(".showWarnings(...) returns the flag builder")
+    @Test
+    void returnsFlagBuilder() {
+      // Then
+      assertThat(flagBuilder.showWarnings(someBoolean()))
+          .isSameAs(flagBuilder);
+    }
+  }
+
+  @DisplayName(".failOnWarnings(boolean) tests")
+  @Nested
+  class FailOnWarningsFlagTest {
+
+    @DisplayName("Setting .failOnWarnings(true) adds the '--failOnWarning' flag")
+    @Test
+    void addsFlagIfTrue() {
+      // When
+      flagBuilder.failOnWarnings(true);
+
+      // Then
+      assertThat(flagBuilder.build()).contains("--failOnWarning");
+    }
+
+    @DisplayName("Setting .failOnWarnings(false) does not add the '-Werror'  flag")
+    @Test
+    void doesNotAddFlagIfFalse() {
+      // When
+      flagBuilder.failOnWarnings(false);
+
+      // Then
+      assertThat(flagBuilder.build()).doesNotContain("-Werror");
+    }
+
+    @DisplayName(".failOnWarnings(...) returns the flag builder")
+    @Test
+    void returnsFlagBuilder() {
+      // Then
+      assertThat(flagBuilder.failOnWarnings(someBoolean()))
+          .isSameAs(flagBuilder);
+    }
+  }
+
+  @DisplayName(".compilationMode(CompilationMode) tests")
+  @Nested
+  class CompilationModeFlagTest {
+
+    @DisplayName(".compilationMode(COMPILATION_ONLY) adds -proc:none")
+    @Test
+    void compilationOnlyAddsProcNone() {
+      // When
+      flagBuilder.compilationMode(CompilationMode.COMPILATION_ONLY);
+
+      // Then
+      assertThat(flagBuilder.build()).containsExactly("-proc:none");
+    }
+
+    @DisplayName(".compilationMode(ANNOTATION_PROCESSING_ONLY) adds -proc:only")
+    @Test
+    void annotationProcessingOnlyAddsProcOnly() {
+      // When
+      flagBuilder.compilationMode(CompilationMode.ANNOTATION_PROCESSING_ONLY);
+
+      // Then
+      assertThat(flagBuilder.build()).containsExactly("-proc:only");
+    }
+
+    @DisplayName(".compilationMode(COMPILATION_AND_ANNOTATION_PROCESSING) adds nothing")
+    @Test
+    void compilationAndAnnotationProcessingAddsNothing() {
+      // When
+      flagBuilder.compilationMode(CompilationMode.COMPILATION_AND_ANNOTATION_PROCESSING);
+
+      // Then
+      assertThat(flagBuilder.build()).isEmpty();
+    }
+
+    @DisplayName(".compilationMode(...) returns the flag builder")
+    @EnumSource(CompilationMode.class)
+    @ParameterizedTest(name = "for compilationMode = {0}")
+    void returnsFlagBuilder(CompilationMode mode) {
+      // Then
+      assertThat(flagBuilder.compilationMode(mode))
+          .isSameAs(flagBuilder);
+    }
+  }
+
+  @DisplayName(".showDeprecationWarnings(boolean) tests")
+  @Nested
+  class ShowDeprecationWarningsFlagTest {
+
+    @DisplayName("Setting .showDeprecationWarnings(true) adds the '-deprecation' flag")
+    @Test
+    void addsFlagIfTrue() {
+      // When
+      flagBuilder.showDeprecationWarnings(true);
+
+      // Then
+      assertThat(flagBuilder.build()).contains("-deprecation");
+    }
+
+    @DisplayName("Setting .showDeprecationWarnings(false) does not add the '-deprecation'  flag")
+    @Test
+    void doesNotAddFlagIfFalse() {
+      // When
+      flagBuilder.showDeprecationWarnings(false);
+
+      // Then
+      assertThat(flagBuilder.build()).doesNotContain("-deprecation");
+    }
+
+    @DisplayName(".showDeprecationWarnings(...) returns the flag builder")
+    @Test
+    void returnsFlagBuilder() {
+      // Then
+      assertThat(flagBuilder.showDeprecationWarnings(someBoolean()))
+          .isSameAs(flagBuilder);
+    }
+  }
+
+  @DisplayName(".release(String) tests")
+  @Nested
+  class ReleaseFlagTest {
+
+    @DisplayName("Setting .release(String) adds the '--release ' flag")
+    @ValueSource(strings = {"8", "11", "17"})
+    @ParameterizedTest(name = "Setting .release(String) adds the \"--release {0}\" flag")
+    void addsFlagIfPresent(String version) {
+      // When
+      flagBuilder.release(version);
+
+      // Then
+      assertThat(flagBuilder.build()).containsSequence("--release", version);
+    }
+
+    @DisplayName("Setting .release(null) does not add the '--release' flag")
+    @Test
+    void doesNotAddFlagIfNotPresent() {
+      // When
+      flagBuilder.release(null);
+
+      // Then
+      assertThat(flagBuilder.build()).doesNotContain("--release");
+    }
+
+    @DisplayName(".release(...) returns the flag builder")
+    @Test
+    void returnsFlagBuilder() {
+      // Then
+      assertThat(flagBuilder.release(someRelease()))
+          .isSameAs(flagBuilder);
+    }
+  }
+
+  @DisplayName(".source(String) tests")
+  @Nested
+  class SourceFlagTest {
+
+    @DisplayName("Setting .source(String) adds the '-source ' flag")
+    @ValueSource(strings = {"8", "11", "17"})
+    @ParameterizedTest(name = "Setting .source(String) adds the \"-source {0}\" flag")
+    void addsFlagIfPresent(String version) {
+      // When
+      flagBuilder.source(version);
+
+      // Then
+      assertThat(flagBuilder.build()).containsSequence("-source", version);
+    }
+
+    @DisplayName("Setting .source(null) does not add the '-source' flag")
+    @Test
+    void doesNotAddFlagIfNotPresent() {
+      // When
+      flagBuilder.source(null);
+
+      // Then
+      assertThat(flagBuilder.build()).doesNotContain("-source");
+    }
+
+
+    @DisplayName(".source(...) returns the flag builder")
+    @Test
+    void returnsFlagBuilder() {
+      // Then
+      assertThat(flagBuilder.source(someRelease()))
+          .isSameAs(flagBuilder);
+    }
+  }
+
+  @DisplayName(".target(String) tests")
+  @Nested
+  class TargetFlagTest {
+
+    @DisplayName("Setting .target(String) adds the '-target ' flag")
+    @ValueSource(strings = {"8", "11", "17"})
+    @ParameterizedTest(name = "Setting .target(String) adds the \"-target {0}\" flag")
+    void addsFlagIfPresent(String version) {
+      // When
+      flagBuilder.target(version);
+
+      // Then
+      assertThat(flagBuilder.build()).containsSequence("-target", version);
+    }
+
+    @DisplayName("Setting .target(null) does not add the '-target' flag")
+    @Test
+    void doesNotAddFlagIfNotPresent() {
+      // When
+      flagBuilder.target(null);
+
+      // Then
+      assertThat(flagBuilder.build()).doesNotContain("-target");
+    }
+
+    @DisplayName(".target(...) returns the flag builder")
+    @Test
+    void returnsFlagBuilder() {
+      // Then
+      assertThat(flagBuilder.target(someRelease()))
+          .isSameAs(flagBuilder);
+    }
+  }
+
+  @DisplayName(".debuggingInfo(Set) tests")
+  @Nested
+  class DebuggingInfoTest {
+
+    @DisplayName("Setting .debuggingInfo with an empty set adds the '-g:none' flag")
+    @Test
+    void emptySetAddsGnoneFlag() {
+      // When
+      flagBuilder.debuggingInfo(DebuggingInfo.none());
+
+      // Then
+      assertThat(flagBuilder.build()).containsOnlyOnce("-g:none");
+    }
+
+    @DisplayName("Setting .debuggingInfo with some values set adds the '-g:xxx' flags")
+    @CsvSource({
+        " LINES,  -g:lines",
+        "SOURCE, -g:source",
+        "  VARS,   -g:vars",
+    })
+    @ParameterizedTest(name = "expect {0} to set flag {1}")
+    void correctFlagsAreSet(DebuggingInfo flag, String flagString) {
+      // When
+      flagBuilder.debuggingInfo(DebuggingInfo.just(flag));
+
+      // Then
+      assertThat(flagBuilder.build()).containsExactly(flagString);
+    }
+
+    @DisplayName("Setting .debuggingInfo with all values set adds the '-g:xxx' flags")
+    @Test
+    void allAddsValues() {
+      // When
+      flagBuilder.debuggingInfo(DebuggingInfo.all());
+
+      // Then
+      assertThat(flagBuilder.build())
+          .doesNotContain("-g", "-g:none")
+          .containsOnlyOnce("-g:lines", "-g:source", "-g:vars");
+    }
+  }
+
+  @DisplayName(".parameterInfoEnabled(boolean) tests")
+  @Nested
+  class ParameterInfoEnabledTest {
+
+    @DisplayName("Setting .parameterInfoEnabled(true) adds the '-parameters' flag")
+    @Test
+    void trueAddsFlag() {
+      // When
+      flagBuilder.parameterInfoEnabled(true);
+
+      // Then
+      assertThat(flagBuilder.build()).containsOnlyOnce("-parameters");
+    }
+
+    @DisplayName("Setting .parameterInfoEnabled(false) does not add the '-parameters' flag")
+    @Test
+    void falseDoesNotAddFlag() {
+      // When
+      flagBuilder.parameterInfoEnabled(false);
+
+      // Then
+      assertThat(flagBuilder.build()).doesNotContain("-parameters");
+    }
+  }
+
+  @DisplayName(".addAnnotationProcessorOptions(List) tests")
+  @Nested
+  class AnnotationProcessorOptionsTest {
+
+    @DisplayName("Setting .annotationProcessorOptions(List) adds the options")
+    @Test
+    void addsAnnotationProcessorOptions() {
+      // Given
+      var options = Stream
+          .generate(Fixtures::someText)
+          .limit(5)
+          .collect(Collectors.toList());
+
+      // When
+      flagBuilder.annotationProcessorOptions(options);
+
+      // Then
+      assertThat(flagBuilder.build())
+          .containsSequence(options.stream()
+              .map("-A"::concat)
+              .collect(Collectors.toList()));
+    }
+
+    @DisplayName(".annotationProcessorOptions(...) returns the flag builder")
+    @Test
+    void returnsFlagBuilder() {
+      // Given
+      var options = Stream
+          .generate(Fixtures::someText)
+          .limit(5)
+          .collect(Collectors.toList());
+
+      // Then
+      assertThat(flagBuilder.annotationProcessorOptions(options))
+          .isSameAs(flagBuilder);
+    }
+  }
+
+  @DisplayName(".compilerOptions(List) tests")
+  @Nested
+  class CompilerOptionsTest {
+
+    @DisplayName("Setting .compilerOptions(List) adds the options")
+    @Test
+    void addsCompilerOptions() {
+      // Given
+      var options = Stream
+          .generate(Fixtures::someText)
+          .limit(5)
+          .collect(Collectors.toList());
+
+      // When
+      flagBuilder.compilerOptions(options);
+
+      // Then
+      assertThat(flagBuilder.build())
+          .containsSequence(options);
+    }
+
+    @DisplayName(".compilerOptions(...) returns the flag builder")
+    @Test
+    void returnsFlagBuilder() {
+      // Given
+      var options = Stream
+          .generate(Fixtures::someText)
+          .limit(5)
+          .collect(Collectors.toList());
+
+      // Then
+      assertThat(flagBuilder.compilerOptions(options))
+          .isSameAs(flagBuilder);
+    }
+  }
+
+  @Order(Integer.MAX_VALUE - 1)
+  @DisplayName("The flag builder adds multiple flags correctly")
+  @Test
+  void addsMultipleFlagsCorrectly() {
+    // When
+    var flags = flagBuilder
+        .compilerOptions(List.of("--foo", "--bar"))
+        .release("15")
+        .annotationProcessorOptions(List.of("--baz", "--bork"))
+        .build();
+
+    // Then
+    assertThat(flags)
+        .containsExactly("--foo", "--bar", "--release", "15", "-A--baz", "-A--bork");
+  }
+
+  @Order(Integer.MAX_VALUE)
+  @DisplayName("The flag builder produces an immutable list as the result")
+  @Test
+  void resultIsImmutable() {
+    // When
+    var flags = flagBuilder
+        .compilerOptions(List.of("--foo", "--bar"))
+        .release("15")
+        .annotationProcessorOptions(List.of("--baz", "--bork"))
+        .build();
+
+    // Then
+    assertThatThrownBy(() -> flags.add("something"))
+        .isInstanceOf(UnsupportedOperationException.class);
+  }
+}
diff --git a/java-compiler-testing/src/test/java/io/github/ascopes/jct/integration/compilation/BasicLegacyCompilationIntegrationTest.java b/java-compiler-testing/src/test/java/io/github/ascopes/jct/integration/compilation/BasicLegacyCompilationIntegrationTest.java
index 7a4d47fb7..6c343a3c3 100644
--- a/java-compiler-testing/src/test/java/io/github/ascopes/jct/integration/compilation/BasicLegacyCompilationIntegrationTest.java
+++ b/java-compiler-testing/src/test/java/io/github/ascopes/jct/integration/compilation/BasicLegacyCompilationIntegrationTest.java
@@ -19,6 +19,7 @@
 
 import io.github.ascopes.jct.compilers.JctCompiler;
 import io.github.ascopes.jct.integration.AbstractIntegrationTest;
+import io.github.ascopes.jct.junit.EcjCompilerTest;
 import io.github.ascopes.jct.junit.JavacCompilerTest;
 import io.github.ascopes.jct.workspaces.PathStrategy;
 import io.github.ascopes.jct.workspaces.Workspaces;
@@ -34,6 +35,7 @@
 class BasicLegacyCompilationIntegrationTest extends AbstractIntegrationTest {
 
   @DisplayName("I can compile a 'Hello, World!' program using a RAM directory")
+  @EcjCompilerTest
   @JavacCompilerTest
   void helloWorldJavacRamDirectory(JctCompiler compiler) {
     try (var workspace = Workspaces.newWorkspace(PathStrategy.RAM_DIRECTORIES)) {
@@ -54,6 +56,7 @@ void helloWorldJavacRamDirectory(JctCompiler compiler) {
   }
 
   @DisplayName("I can compile a 'Hello, World!' program using a temp directory")
+  @EcjCompilerTest
   @JavacCompilerTest
   void helloWorldJavacTempDirectory(JctCompiler compiler) {
     try (var workspace = Workspaces.newWorkspace(PathStrategy.TEMP_DIRECTORIES)) {
diff --git a/java-compiler-testing/src/test/java/io/github/ascopes/jct/integration/compilation/BasicModuleCompilationIntegrationTest.java b/java-compiler-testing/src/test/java/io/github/ascopes/jct/integration/compilation/BasicModuleCompilationIntegrationTest.java
index 06cd8bf1f..26c7b2a73 100644
--- a/java-compiler-testing/src/test/java/io/github/ascopes/jct/integration/compilation/BasicModuleCompilationIntegrationTest.java
+++ b/java-compiler-testing/src/test/java/io/github/ascopes/jct/integration/compilation/BasicModuleCompilationIntegrationTest.java
@@ -19,6 +19,7 @@
 
 import io.github.ascopes.jct.compilers.JctCompiler;
 import io.github.ascopes.jct.integration.AbstractIntegrationTest;
+import io.github.ascopes.jct.junit.EcjCompilerTest;
 import io.github.ascopes.jct.junit.JavacCompilerTest;
 import io.github.ascopes.jct.workspaces.PathStrategy;
 import io.github.ascopes.jct.workspaces.Workspaces;
@@ -33,6 +34,7 @@
 class BasicModuleCompilationIntegrationTest extends AbstractIntegrationTest {
 
   @DisplayName("I can compile a 'Hello, World!' module program using a RAM disk")
+  @EcjCompilerTest(minVersion = 9)
   @JavacCompilerTest(minVersion = 9)
   void helloWorldRamDisk(JctCompiler compiler) {
     try (var workspace = Workspaces.newWorkspace(PathStrategy.RAM_DIRECTORIES)) {
@@ -61,6 +63,7 @@ void helloWorldRamDisk(JctCompiler compiler) {
   }
 
   @DisplayName("I can compile a 'Hello, World!' module program using a temporary directory")
+  @EcjCompilerTest(minVersion = 9)
   @JavacCompilerTest(minVersion = 9)
   void helloWorldUsingTempDirectory(JctCompiler compiler) {
     try (var workspace = Workspaces.newWorkspace(PathStrategy.TEMP_DIRECTORIES)) {
diff --git a/java-compiler-testing/src/test/java/io/github/ascopes/jct/integration/compilation/BasicMultiModuleCompilationIntegrationTest.java b/java-compiler-testing/src/test/java/io/github/ascopes/jct/integration/compilation/BasicMultiModuleCompilationIntegrationTest.java
index 9dbf34106..22e611598 100644
--- a/java-compiler-testing/src/test/java/io/github/ascopes/jct/integration/compilation/BasicMultiModuleCompilationIntegrationTest.java
+++ b/java-compiler-testing/src/test/java/io/github/ascopes/jct/integration/compilation/BasicMultiModuleCompilationIntegrationTest.java
@@ -20,6 +20,7 @@
 
 import io.github.ascopes.jct.compilers.JctCompiler;
 import io.github.ascopes.jct.integration.AbstractIntegrationTest;
+import io.github.ascopes.jct.junit.EcjCompilerTest;
 import io.github.ascopes.jct.junit.JavacCompilerTest;
 import io.github.ascopes.jct.workspaces.PathStrategy;
 import io.github.ascopes.jct.workspaces.Workspaces;
@@ -34,6 +35,7 @@
 class BasicMultiModuleCompilationIntegrationTest extends AbstractIntegrationTest {
 
   @DisplayName("I can compile a single module using multi-module layout using a RAM disk")
+  @EcjCompilerTest(minVersion = 9)
   @JavacCompilerTest(minVersion = 9)
   void singleModuleInMultiModuleLayoutRamDisk(JctCompiler compiler) {
     try (var workspace = Workspaces.newWorkspace(PathStrategy.RAM_DIRECTORIES)) {
@@ -62,6 +64,7 @@ void singleModuleInMultiModuleLayoutRamDisk(JctCompiler compiler) {
   }
 
   @DisplayName("I can compile a single module using multi-module layout using a temp directory")
+  @EcjCompilerTest(minVersion = 9)
   @JavacCompilerTest(minVersion = 9)
   void singleModuleInMultiModuleLayoutTempDirectory(JctCompiler compiler) {
     try (var workspace = Workspaces.newWorkspace(PathStrategy.TEMP_DIRECTORIES)) {
diff --git a/java-compiler-testing/src/test/java/io/github/ascopes/jct/integration/compilation/CompilingSpecificClassesIntegrationTest.java b/java-compiler-testing/src/test/java/io/github/ascopes/jct/integration/compilation/CompilingSpecificClassesIntegrationTest.java
index c73f7f61e..dffae5409 100644
--- a/java-compiler-testing/src/test/java/io/github/ascopes/jct/integration/compilation/CompilingSpecificClassesIntegrationTest.java
+++ b/java-compiler-testing/src/test/java/io/github/ascopes/jct/integration/compilation/CompilingSpecificClassesIntegrationTest.java
@@ -19,6 +19,7 @@
 
 import io.github.ascopes.jct.compilers.JctCompiler;
 import io.github.ascopes.jct.integration.AbstractIntegrationTest;
+import io.github.ascopes.jct.junit.EcjCompilerTest;
 import io.github.ascopes.jct.junit.JavacCompilerTest;
 import io.github.ascopes.jct.workspaces.Workspaces;
 import org.junit.jupiter.api.DisplayName;
@@ -32,6 +33,7 @@
 class CompilingSpecificClassesIntegrationTest extends AbstractIntegrationTest {
 
   @DisplayName("Only the classes that I specify get compiled")
+  @EcjCompilerTest
   @JavacCompilerTest
   void onlyTheClassesSpecifiedGetCompiled(JctCompiler compiler) {
     try (var workspace = Workspaces.newWorkspace()) {
diff --git a/java-compiler-testing/src/test/java/io/github/ascopes/jct/integration/compilation/MultiTieredCompilationIntegrationTest.java b/java-compiler-testing/src/test/java/io/github/ascopes/jct/integration/compilation/MultiTieredCompilationIntegrationTest.java
index d46aa0bcf..86c1bbc06 100644
--- a/java-compiler-testing/src/test/java/io/github/ascopes/jct/integration/compilation/MultiTieredCompilationIntegrationTest.java
+++ b/java-compiler-testing/src/test/java/io/github/ascopes/jct/integration/compilation/MultiTieredCompilationIntegrationTest.java
@@ -19,6 +19,7 @@
 
 import io.github.ascopes.jct.compilers.JctCompiler;
 import io.github.ascopes.jct.integration.AbstractIntegrationTest;
+import io.github.ascopes.jct.junit.EcjCompilerTest;
 import io.github.ascopes.jct.junit.JavacCompilerTest;
 import io.github.ascopes.jct.workspaces.Workspaces;
 import org.junit.jupiter.api.DisplayName;
@@ -35,6 +36,7 @@ class MultiTieredCompilationIntegrationTest extends AbstractIntegrationTest {
   @DisplayName(
       "I can compile sources to classes and provide them in the classpath to a second compilation"
   )
+  @EcjCompilerTest
   @JavacCompilerTest
   void compileSourcesToClassesAndProvideThemInClassPathToSecondCompilation(
       JctCompiler compiler
@@ -81,6 +83,7 @@ void compileSourcesToClassesAndProvideThemInClassPathToSecondCompilation(
       "I can compile sources to classes and provide them in the classpath to a second "
           + "compilation within a JAR"
   )
+  @EcjCompilerTest
   @JavacCompilerTest
   void compileSourcesToClassesAndProvideThemInClassPathToSecondCompilationWithinJar(
       JctCompiler compiler
diff --git a/java-compiler-testing/src/test/java/io/github/ascopes/jct/junit/EcjCompilersProviderTest.java b/java-compiler-testing/src/test/java/io/github/ascopes/jct/junit/EcjCompilersProviderTest.java
new file mode 100644
index 000000000..ca3b5a2ec
--- /dev/null
+++ b/java-compiler-testing/src/test/java/io/github/ascopes/jct/junit/EcjCompilersProviderTest.java
@@ -0,0 +1,166 @@
+/*
+ * Copyright (C) 2022 - 2025, the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.github.ascopes.jct.junit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.SoftAssertions.assertSoftly;
+import static org.junit.jupiter.params.support.AnnotationConsumerInitializer.initialize;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.when;
+
+import io.github.ascopes.jct.compilers.JctCompilerConfigurer;
+import io.github.ascopes.jct.compilers.impl.EcjJctCompilerImpl;
+import java.lang.reflect.AnnotatedElement;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtensionContext;
+
+/**
+ * {@link EcjCompilersProvider} tests.
+ */
+@DisplayName("EcjCompilersProvider tests")
+class EcjCompilersProviderTest {
+
+  @DisplayName("Provider uses the user-provided compiler version bounds when valid")
+  @Test
+  void providerUsesTheUserProvidedVersionRangesWhenValid() {
+    // Given
+    try (var ecjMock = mockStatic(EcjJctCompilerImpl.class)) {
+      ecjMock.when(EcjJctCompilerImpl::getEarliestSupportedVersionInt).thenReturn(8);
+      ecjMock.when(EcjJctCompilerImpl::getLatestSupportedVersionInt).thenReturn(17);
+      var annotation = someAnnotation(10, 15);
+      var test = someAnnotatedElement(annotation);
+      var context = mock(ExtensionContext.class);
+
+      // When
+      var consumer = initialize(test, new EcjCompilersProvider());
+      var compilers = consumer.provideArguments(context)
+          .map(args -> (EcjJctCompilerImpl) args.get()[0])
+          .toList();
+
+      // Then
+      assertThat(compilers)
+          .as("compilers that were initialised (%s)", compilers)
+          .hasSize(6);
+
+      assertSoftly(softly -> {
+        for (var i = 0; i < compilers.size(); ++i) {
+          var compiler = compilers.get(i);
+          softly.assertThat(compiler.getName())
+              .as("compilers[%d].getName()", i)
+              .isEqualTo("ECJ (release = Java %d)", 10 + i);
+          softly.assertThat(compiler.getRelease())
+              .as("compilers[%d].getRelease()", i)
+              .isEqualTo("%d", 10 + i);
+        }
+      });
+    }
+  }
+
+  @DisplayName("Provider uses the minimum compiler version that is allowed if exceeded")
+  @Test
+  void providerUsesTheMinCompilerVersionAllowedIfExceeded() {
+    // Given
+    try (var ecjMock = mockStatic(EcjJctCompilerImpl.class)) {
+      ecjMock.when(EcjJctCompilerImpl::getEarliestSupportedVersionInt).thenReturn(8);
+      ecjMock.when(EcjJctCompilerImpl::getLatestSupportedVersionInt).thenReturn(17);
+      var annotation = someAnnotation(1, 15);
+      var test = someAnnotatedElement(annotation);
+      var context = mock(ExtensionContext.class);
+
+      // When
+      var consumer = initialize(test, new EcjCompilersProvider());
+      var compilers = consumer.provideArguments(context)
+          .map(args -> (EcjJctCompilerImpl) args.get()[0])
+          .toList();
+
+      // Then
+      assertThat(compilers)
+          .as("compilers that were initialised (%s)", compilers)
+          .hasSize(8);
+
+      assertSoftly(softly -> {
+        for (var i = 0; i < compilers.size(); ++i) {
+          var compiler = compilers.get(i);
+          softly.assertThat(compiler.getName())
+              .as("compilers[%d].getName()", i)
+              .isEqualTo("ECJ (release = Java %d)", 8 + i);
+          softly.assertThat(compiler.getRelease())
+              .as("compilers[%d].getRelease()", i)
+              .isEqualTo("%d", 8 + i);
+        }
+      });
+    }
+  }
+
+  @DisplayName("Provider uses the maximum compiler version that is allowed if exceeded")
+  @Test
+  void providerUsesTheMaxCompilerVersionAllowedIfExceeded() {
+    // Given
+    try (var ecjMock = mockStatic(EcjJctCompilerImpl.class)) {
+      ecjMock.when(EcjJctCompilerImpl::getEarliestSupportedVersionInt).thenReturn(8);
+      ecjMock.when(EcjJctCompilerImpl::getLatestSupportedVersionInt).thenReturn(17);
+      var annotation = someAnnotation(10, 17);
+      var test = someAnnotatedElement(annotation);
+      var context = mock(ExtensionContext.class);
+
+      // When
+      var consumer = initialize(test, new EcjCompilersProvider());
+      var compilers = consumer.provideArguments(context)
+          .map(args -> (EcjJctCompilerImpl) args.get()[0])
+          .toList();
+
+      // Then
+      assertThat(compilers)
+          .as("compilers that were initialised (%s)", compilers)
+          .hasSize(8);
+
+      assertSoftly(softly -> {
+        for (var i = 0; i < compilers.size(); ++i) {
+          var compiler = compilers.get(i);
+          softly.assertThat(compiler.getName())
+              .as("compilers[%d].getName()", i)
+              .isEqualTo("ECJ (release = Java %d)", 10 + i);
+          softly.assertThat(compiler.getRelease())
+              .as("compilers[%d].getRelease()", i)
+              .isEqualTo("%d", 10 + i);
+        }
+      });
+    }
+  }
+
+  @SafeVarargs
+  final EcjCompilerTest someAnnotation(
+      int min,
+      int max,
+      Class extends JctCompilerConfigurer>>... configurers
+  ) {
+    var annotation = mock(EcjCompilerTest.class);
+    when(annotation.minVersion()).thenReturn(min);
+    when(annotation.maxVersion()).thenReturn(max);
+    when(annotation.configurers()).thenReturn(configurers);
+    when(annotation.versionStrategy()).thenReturn(VersionStrategy.RELEASE);
+    when(annotation.annotationType()).thenAnswer(ctx -> EcjCompilerTest.class);
+    return annotation;
+  }
+
+  AnnotatedElement someAnnotatedElement(EcjCompilerTest annotation) {
+    var element = mock(AnnotatedElement.class);
+    when(element.getDeclaredAnnotation(EcjCompilerTest.class)).thenReturn(annotation);
+    return element;
+  }
+}
diff --git a/pom.xml b/pom.xml
index c8c23a488..7d2f11988 100644
--- a/pom.xml
+++ b/pom.xml
@@ -80,6 +80,14 @@
     https://github.com/ascopes
   
 
+  
+    
+      eclipse-snapshots
+      Eclipse Snapshots
+      https://repo.eclipse.org/content/repositories/eclipse-snapshots
+    
+  
+
   
     https://github.com/ascopes/java-compiler-testing
     scm:git:https://github.com/ascopes/java-compiler-testing
@@ -91,6 +99,7 @@
     
     4.0.0-M1
     4.3.0
+    3.41.0-SNAPSHOT
     1.4.0
     1.0.0
     5.13.4
@@ -122,6 +131,7 @@
 
     
     true
+    INFO
 
     
+        org.eclipse.jdt
+        ecj
+        ${ecj.version}
+