diff --git a/integration/feature/testquick-javamodule/resources/build.mill b/integration/feature/testquick-javamodule/resources/build.mill new file mode 100644 index 000000000000..f8c7fc83a714 --- /dev/null +++ b/integration/feature/testquick-javamodule/resources/build.mill @@ -0,0 +1,8 @@ +package build +import mill._, javalib._ + +object foo extends JavaModule { + def javacOptions = Seq("-source", "11", "-target", "11") + + object test extends JavaTests with TestModule.Junit4 +} diff --git a/integration/feature/testquick-javamodule/resources/foo/src/Calculator.java b/integration/feature/testquick-javamodule/resources/foo/src/Calculator.java new file mode 100644 index 000000000000..04e8ecf7d35e --- /dev/null +++ b/integration/feature/testquick-javamodule/resources/foo/src/Calculator.java @@ -0,0 +1,15 @@ +package foo; + +public class Calculator { + public int add(int a, int b) { + return a + b; + } + + public int subtract(int a, int b) { + return a - b; + } + + public int multiply(int a, int b) { + return a * b; + } +} diff --git a/integration/feature/testquick-javamodule/resources/foo/src/StringUtils.java b/integration/feature/testquick-javamodule/resources/foo/src/StringUtils.java new file mode 100644 index 000000000000..5aeea432de7b --- /dev/null +++ b/integration/feature/testquick-javamodule/resources/foo/src/StringUtils.java @@ -0,0 +1,11 @@ +package foo; + +public class StringUtils { + public static String reverse(String s) { + return new StringBuilder(s).reverse().toString(); + } + + public static String toUpperCase(String s) { + return s.toUpperCase(); + } +} diff --git a/integration/feature/testquick-javamodule/resources/foo/test/src/CalculatorTest.java b/integration/feature/testquick-javamodule/resources/foo/test/src/CalculatorTest.java new file mode 100644 index 000000000000..0af33ba94feb --- /dev/null +++ b/integration/feature/testquick-javamodule/resources/foo/test/src/CalculatorTest.java @@ -0,0 +1,24 @@ +package foo; + +import org.junit.Test; +import static org.junit.Assert.*; + +public class CalculatorTest { + @Test + public void testAdd() { + Calculator calc = new Calculator(); + assertEquals(5, calc.add(2, 3)); + } + + @Test + public void testSubtract() { + Calculator calc = new Calculator(); + assertEquals(1, calc.subtract(3, 2)); + } + + @Test + public void testMultiply() { + Calculator calc = new Calculator(); + assertEquals(6, calc.multiply(2, 3)); + } +} diff --git a/integration/feature/testquick-javamodule/resources/foo/test/src/StringUtilsTest.java b/integration/feature/testquick-javamodule/resources/foo/test/src/StringUtilsTest.java new file mode 100644 index 000000000000..d0e2fb0452f0 --- /dev/null +++ b/integration/feature/testquick-javamodule/resources/foo/test/src/StringUtilsTest.java @@ -0,0 +1,16 @@ +package foo; + +import org.junit.Test; +import static org.junit.Assert.*; + +public class StringUtilsTest { + @Test + public void testReverse() { + assertEquals("cba", StringUtils.reverse("abc")); + } + + @Test + public void testToUpperCase() { + assertEquals("HELLO", StringUtils.toUpperCase("hello")); + } +} diff --git a/integration/feature/testquick-javamodule/src/TestQuickJavaModuleTests.scala b/integration/feature/testquick-javamodule/src/TestQuickJavaModuleTests.scala new file mode 100644 index 000000000000..87b7a7b41ea2 --- /dev/null +++ b/integration/feature/testquick-javamodule/src/TestQuickJavaModuleTests.scala @@ -0,0 +1,92 @@ +package mill.integration + +import mill.testkit.UtestIntegrationTestSuite + +import utest._ + +object TestQuickJavaModuleTests extends UtestIntegrationTestSuite { + val tests: Tests = Tests { + def filterLines(out: String) = { + out.linesIterator.filter(!_.contains("[info]")).toSet + } + + test("testQuick-runs-all-on-first-run") - integrationTest { tester => + import tester._ + + // First run should execute all tests + val initial = eval("foo.test.testQuick") + assert(initial.isSuccess) + + // Should see both test classes run + val initialOut = initial.out + assert( + initialOut.contains("CalculatorTest") || + initialOut.contains("running") || + initialOut.contains("passed") + ) + } + + test("testQuick-caches-unchanged") - integrationTest { tester => + import tester._ + + // First run + val initial = eval("foo.test.testQuick") + assert(initial.isSuccess) + + // Second run with no changes - should run fewer tests or be cached + val cached = eval("foo.test.testQuick") + assert(cached.isSuccess) + } + + test("testQuick-detects-changes") - integrationTest { tester => + import tester._ + + // First run to establish baseline + val initial = eval("foo.test.testQuick") + assert(initial.isSuccess) + + // Modify Calculator.java - only CalculatorTest should run + modifyFile( + workspacePath / "foo/src/Calculator.java", + _.replace("return a + b", "return a + b + 0") + ) + + val afterChange = eval("foo.test.testQuick") + assert(afterChange.isSuccess) + + // The test should have detected the change and re-run affected tests + assert( + afterChange.out.contains("CalculatorTest") || + afterChange.out.contains("running") || + afterChange.out.contains("1 passed") + ) + } + + test("testQuick-reruns-failed") - integrationTest { tester => + import tester._ + + // First run + val initial = eval("foo.test.testQuick") + assert(initial.isSuccess) + + // Introduce a failing test by breaking the implementation + modifyFile( + workspacePath / "foo/src/Calculator.java", + _.replace("return a + b", "return a - b") // Break add() to make test fail + ) + + // This run should fail + val failing = eval("foo.test.testQuick", check = false) + + // Fix the implementation + modifyFile( + workspacePath / "foo/src/Calculator.java", + _.replace("return a - b", "return a + b") // Fix it back + ) + + // Next run should re-run the previously failed test + val fixed = eval("foo.test.testQuick") + assert(fixed.isSuccess) + } + } +} diff --git a/libs/javalib/api/src/mill/javalib/codesig/CodeSigWorkerApi.scala b/libs/javalib/api/src/mill/javalib/codesig/CodeSigWorkerApi.scala new file mode 100644 index 000000000000..d9815a6fb70e --- /dev/null +++ b/libs/javalib/api/src/mill/javalib/codesig/CodeSigWorkerApi.scala @@ -0,0 +1,95 @@ +package mill.javalib.codesig + +/** + * API for computing bytecode-level method code signatures using Mill's codesig module. + * Used by testQuick for fine-grained change detection at the method level. + */ +trait CodeSigWorkerApi extends AutoCloseable { + + /** + * Computes transitive call graph hashes for compiled class files. + * These hashes capture both the method's own bytecode and all methods it transitively calls. + * + * @param classFiles Paths to compiled .class files + * @param upstreamClasspath Paths to upstream dependency JARs/directories + * @param logDir Optional directory for debug logging + * @param prevHashesOpt Previous hashes for incremental computation + * @return Map from method signature to its transitive call graph hash + */ + def computeCodeSignatures( + classFiles: Seq[os.Path], + upstreamClasspath: Seq[os.Path], + logDir: Option[os.Path], + prevHashesOpt: Option[Map[String, Int]] + ): Map[String, Int] + + /** + * Computes direct method code hashes (without transitive dependencies). + * + * @param classFiles Paths to compiled .class files + * @param upstreamClasspath Paths to upstream dependency JARs/directories + * @return Map from method signature to its direct code hash + */ + def computeMethodHashes( + classFiles: Seq[os.Path], + upstreamClasspath: Seq[os.Path] + ): Map[String, Int] + + override def close(): Unit = { + // noop by default + } +} + +object CodeSigWorkerApi { + + /** + * Extracts class name from a codesig method signature. + * Format: "def package.ClassName#methodName(args)returnType" + * or "package.ClassName.methodName(args)returnType" for static methods + */ + def extractClassName(methodSig: String): Option[String] = { + val sig = methodSig.stripPrefix("def ").stripPrefix("call ").stripPrefix("external ") + val hashIdx = sig.indexOf('#') + val parenIdx = sig.indexOf('(') + + if (hashIdx > 0) { + // Instance method: "package.ClassName#method" + Some(sig.substring(0, hashIdx)) + } else if (parenIdx > 0) { + // Static method: "package.ClassName.method(args)" + val lastDot = sig.lastIndexOf('.', parenIdx) + if (lastDot > 0) Some(sig.substring(0, lastDot)) + else None + } else { + None + } + } + + /** + * Groups method signatures by class name. + */ + def groupByClass(signatures: Map[String, Int]): Map[String, Map[String, Int]] = { + signatures.groupBy { case (sig, _) => + extractClassName(sig).getOrElse("unknown") + } + } + + /** + * Computes a single hash for a class by combining all its method hashes. + */ + def computeClassHash(methodHashes: Map[String, Int]): Int = { + // Use XOR to make hash order-independent + methodHashes.toSeq.sortBy(_._1).foldLeft(0) { case (acc, (sig, hash)) => + acc ^ (sig.hashCode * 31 + hash) + } + } + + /** + * Converts method-level signatures to class-level signatures. + */ + def toClassSignatures(methodSignatures: Map[String, Int]): Map[String, Int] = { + groupByClass(methodSignatures).map { case (className, methods) => + className -> computeClassHash(methods) + } + } +} diff --git a/libs/javalib/codesig-worker/src/mill/javalib/codesig/CodeSigWorker.scala b/libs/javalib/codesig-worker/src/mill/javalib/codesig/CodeSigWorker.scala new file mode 100644 index 000000000000..34ec2343da88 --- /dev/null +++ b/libs/javalib/codesig-worker/src/mill/javalib/codesig/CodeSigWorker.scala @@ -0,0 +1,77 @@ +package mill.javalib.codesig + +import mill.codesig.{CodeSig, Logger} +import mill.codesig.JvmModel.{Desc, MethodSig} + +/** + * Worker implementation that computes method code hash signatures using Mill's codesig module. + * This provides fine-grained bytecode-level change detection for testQuick. + */ +class CodeSigWorker extends CodeSigWorkerApi { + + /** + * Computes transitive call graph hashes for compiled class files. + * + * @param classFiles Paths to compiled .class files + * @param upstreamClasspath Paths to upstream dependency JARs/directories + * @param logDir Optional directory for debug logging + * @param prevHashesOpt Previous hashes for incremental computation + * @return Map from method signature to its transitive call graph hash + */ + override def computeCodeSignatures( + classFiles: Seq[os.Path], + upstreamClasspath: Seq[os.Path], + logDir: Option[os.Path], + prevHashesOpt: Option[Map[String, Int]] + ): Map[String, Int] = { + if (classFiles.isEmpty) return Map.empty + + val analysis = CodeSig.compute( + classFiles = classFiles, + upstreamClasspath = upstreamClasspath, + ignoreCall = defaultIgnoreCall, + logger = new Logger( + logDir.getOrElse(os.temp.dir()), + logDir + ), + prevTransitiveCallGraphHashesOpt = () => prevHashesOpt + ) + + analysis.transitiveCallGraphHashes.toMap + } + + /** + * Computes direct method code hashes (without transitive dependencies). + * + * @param classFiles Paths to compiled .class files + * @param upstreamClasspath Paths to upstream dependency JARs/directories + * @return Map from method signature to its direct code hash + */ + override def computeMethodHashes( + classFiles: Seq[os.Path], + upstreamClasspath: Seq[os.Path] + ): Map[String, Int] = { + if (classFiles.isEmpty) return Map.empty + + val analysis = CodeSig.compute( + classFiles = classFiles, + upstreamClasspath = upstreamClasspath, + ignoreCall = defaultIgnoreCall, + logger = new Logger(os.temp.dir(), None), + prevTransitiveCallGraphHashesOpt = () => None + ) + + analysis.methodCodeHashes.toMap + } + + /** + * Default filter for ignoring certain method calls in the call graph. + * This is simpler than the build.mill version since we're analyzing + * user code, not Mill task definitions. + */ + private def defaultIgnoreCall(callSiteOpt: Option[mill.codesig.JvmModel.MethodDef], calledSig: MethodSig): Boolean = { + // Don't ignore any calls by default for test code analysis + // User code dependencies should all be tracked + false + } +} diff --git a/libs/javalib/package.mill b/libs/javalib/package.mill index 45c676ffc4b0..1c8f4bd3ab3f 100644 --- a/libs/javalib/package.mill +++ b/libs/javalib/package.mill @@ -40,7 +40,8 @@ object `package` extends MillStableScalaModule { `classgraph-worker`, `jarjarabrams-worker`, `spotless-worker`, - `scalameta-worker` + `scalameta-worker`, + `codesig-worker` ) def testForkEnv = { val locale = if (Properties.isMac) "en_US.UTF-8" else "C.utf8" @@ -189,4 +190,12 @@ object `package` extends MillStableScalaModule { def moduleDeps = Seq(build.libs.javalib) def mvnDeps = Seq(Deps.semanticDbShared) } + + /** + * Worker module for computing bytecode-level method signatures using codesig. + * Used by testQuick for fine-grained change detection. + */ + object `codesig-worker` extends MillPublishScalaModule { + def moduleDeps = Seq(api, build.runner.codesig) + } } diff --git a/libs/javalib/src/mill/javalib/JavaModule.scala b/libs/javalib/src/mill/javalib/JavaModule.scala index db6d50f21576..aa75861f8966 100644 --- a/libs/javalib/src/mill/javalib/JavaModule.scala +++ b/libs/javalib/src/mill/javalib/JavaModule.scala @@ -23,6 +23,7 @@ import mill.api.{DefaultTaskModule, ModuleRef, PathRef, Segment, Task, TaskCtx} import mill.javalib.api.CompilationResult import mill.javalib.api.internal.{JavaCompilerOptions, ZincOp} import mill.javalib.bsp.{BspJavaModule, BspModule} +import mill.javalib.codesig.{CodeSigWorkerApi, CodeSigWorkerModule} import mill.javalib.internal.ModuleUtils import mill.javalib.publish.Artifact import mill.util.{JarManifest, Jvm} @@ -70,11 +71,20 @@ trait JavaModule override def jvmWorker: ModuleRef[JvmWorkerModule] = super.jvmWorker + /** + * Module reference for the CodeSig worker, used for computing bytecode-level method signatures. + */ + def codesigWorkerModule: ModuleRef[CodeSigWorkerModule] = ModuleRef(CodeSigWorkerModule) + // Keep in sync with JavaModule.JavaTests0, duplicated due to binary compatibility concerns trait JavaTests extends JavaModule with TestModule { // Run some consistence checks hierarchyChecks() + // Resolve diamond inheritance: JavaModule and TestModule both define this + override def methodCodeHashSignatures: T[Map[String, Int]] = + super[TestModule].methodCodeHashSignatures + override def resources = super[JavaModule].resources override def moduleDeps: Seq[JavaModule] = Seq(outer) override def repositoriesTask: Task[Seq[Repository]] = Task.Anon { @@ -1019,6 +1029,33 @@ trait JavaModule */ def compileClasspath: T[Seq[PathRef]] = Task { compileClasspathTask(CompileFor.Regular)() } + /** + * Computes bytecode-level method code hash signatures for this module's compiled classes. + * These signatures are used by testQuick for fine-grained change detection. + * + * The hash for each method includes: + * - The method's own bytecode + * - All methods it transitively calls (via the call graph) + * + * Returns a Map from fully-qualified class name to its aggregated method hash. + */ + def methodCodeHashSignatures: T[Map[String, Int]] = Task { + val compiled = compile() + val classFiles = os.walk(compiled.classes.path).filter(_.ext == "class") + val upstream = compileClasspath().map(_.path).filter(p => os.exists(p)) + + val worker = codesigWorkerModule().codesigWorker() + val methodSigs = worker.computeCodeSignatures( + classFiles = classFiles, + upstreamClasspath = upstream, + logDir = None, + prevHashesOpt = None + ) + + // Convert method-level signatures to class-level for testQuick + CodeSigWorkerApi.toClassSignatures(methodSigs) + } + /** * All classfiles and resources from upstream modules and dependencies * necessary to compile this module. @@ -1587,6 +1624,10 @@ object JavaModule { // Run some consistence checks hierarchyChecks() + // Resolve diamond inheritance: JavaModule and TestModule both define this + override def methodCodeHashSignatures: T[Map[String, Int]] = + super[TestModule].methodCodeHashSignatures + override def resources = super[JavaModule].resources override def repositoriesTask: Task[Seq[Repository]] = Task.Anon { outer.repositoriesTask() diff --git a/libs/javalib/src/mill/javalib/TestModule.scala b/libs/javalib/src/mill/javalib/TestModule.scala index 3478adddfdab..7bdcdbe89b89 100644 --- a/libs/javalib/src/mill/javalib/TestModule.scala +++ b/libs/javalib/src/mill/javalib/TestModule.scala @@ -38,6 +38,12 @@ trait TestModule */ def testClasspath: T[Seq[PathRef]] = Task { localRunClasspath() } + /** + * Method-level code hash signatures for the module under test. + * Used by [[testQuick]] for fine-grained change detection. + */ + def methodCodeHashSignatures: T[Map[String, Int]] = Task { Map.empty[String, Int] } + /** * The test framework to use to discover and run run tests. * @@ -122,6 +128,122 @@ trait TestModule testTask(testCachedArgs, Task.Anon { Seq.empty[String] })() } + /** + * Runs only tests affected by code changes since the last successful run. + * Uses bytecode-level method signatures (via codesig) to detect which classes changed, + * then runs only tests that depend on those classes plus any previously failed tests. + * + * This provides much faster feedback during development than running all tests, + * while still ensuring that affected tests are executed. + * + * The change detection is at class granularity and includes: + * - Changes to the test class itself + * - Changes to any class in the module dependencies (moduleDeps) + * - Tests that failed in the previous run + * + * @see [[testCached]] for running all tests with caching + * @see [[methodCodeHashSignatures]] for how change detection works + */ + def testQuick: T[(msg: String, results: Seq[TestResult])] = Task(persistent = true) { + val stateFile = Task.dest / "testQuick-state.json" + + // Load previous state + case class TestQuickState( + classHashes: Map[String, Int], + failedTests: Set[String] + ) + + val previousState: TestQuickState = if (os.exists(stateFile)) { + try { + val json = ujson.read(os.read(stateFile)) + TestQuickState( + classHashes = json("classHashes").obj.map { case (k, v) => k -> v.num.toInt }.toMap, + failedTests = json("failedTests").arr.map(_.str).toSet + ) + } catch { + case _: Exception => + Task.log.info("testQuick: Could not load previous state, running all tests") + TestQuickState(Map.empty, Set.empty) + } + } else { + TestQuickState(Map.empty, Set.empty) + } + + // Compute current class signatures + val currentHashes: Map[String, Int] = methodCodeHashSignatures() + + // Get all discovered test classes + val allTests = discoveredTestClasses() + + // Determine changed classes + val changedClasses: Set[String] = currentHashes.collect { + case (cls, hash) if previousState.classHashes.get(cls) != Some(hash) => cls + }.toSet + + // Determine tests to run + val testsToRun: Seq[String] = if (previousState.classHashes.isEmpty) { + // First run - execute all tests + Task.log.info("testQuick: First run - executing all tests") + allTests + } else if (changedClasses.isEmpty && previousState.failedTests.isEmpty) { + // No changes, no failures - nothing to run + Task.log.info("testQuick: No changes detected, no failed tests - skipping") + Seq.empty + } else { + // Run failed tests + tests in changed classes + val affectedTests = allTests.filter { test => + changedClasses.exists(cls => test.startsWith(cls) || cls.startsWith(test.takeWhile(_ != '$'))) + } + val toRun = (affectedTests ++ previousState.failedTests.intersect(allTests.toSet)).distinct + Task.log.info(s"testQuick: Running ${toRun.size} tests (${affectedTests.size} affected by changes, ${previousState.failedTests.size} previously failed)") + toRun + } + + // Run the selected tests + val (msg, results) = if (testsToRun.isEmpty) { + ("No tests to run", Seq.empty[TestResult]) + } else { + val testModuleUtil = new TestModuleUtil( + testUseArgsFile(), + forkArgs(), + testsToRun, // Use our filtered test list as selectors + jvmWorker().scalalibClasspath(), + resources(), + testFramework(), + runClasspath(), + testClasspath(), + testCachedArgs(), + Seq(testsToRun), // testForkGrouping with our tests + jvmWorker().testrunnerEntrypointClasspath(), + allForkEnv(), + testSandboxWorkingDir(), + forkWorkingDir(), + testReportXml(), + javaHome().map(_.path), + testParallelism(), + testLogLevel(), + propagateEnv(), + jvmWorker().internalWorker() + ) + testModuleUtil.runTests() + } + + // Determine failed tests from results + val currentFailedTests: Set[String] = results + .filter(r => r.status == "Failure" || r.status == "Error") + .map(_.fullyQualifiedName) + .toSet + + // Save state for next run + val newState = ujson.Obj( + "classHashes" -> ujson.Obj.from(currentHashes.map { case (k, v) => k -> ujson.Num(v) }), + "failedTests" -> ujson.Arr.from(currentFailedTests.map(ujson.Str(_))) + ) + os.write.over(stateFile, ujson.write(newState, indent = 2)) + + TestModule.handleResults(msg, results, Task.ctx(), testReportXml()) + } + /** * How the test classes in this module will be split. * Test classes from different groups are ensured to never diff --git a/libs/javalib/src/mill/javalib/codesig/CodeSigWorkerModule.scala b/libs/javalib/src/mill/javalib/codesig/CodeSigWorkerModule.scala new file mode 100644 index 000000000000..7acba73c06cc --- /dev/null +++ b/libs/javalib/src/mill/javalib/codesig/CodeSigWorkerModule.scala @@ -0,0 +1,44 @@ +package mill.javalib.codesig + +import mainargs.Flag +import mill.{Command, T, Task} +import mill.api.{PathRef, Discover, ExternalModule} +import mill.javalib.{CoursierModule, Dep, OfflineSupportModule} +import mill.util.Jvm + +/** + * Trait for modules that provide a CodeSig worker for computing bytecode-level + * method signatures. Used by testQuick for fine-grained change detection. + */ +trait CodeSigWorkerModule extends CoursierModule with OfflineSupportModule { + + def codesigWorkerClasspath: T[Seq[PathRef]] = Task { + defaultResolver().classpath(Seq( + Dep.millProjectModule("mill-libs-javalib-codesig-worker") + )) + } + + override def prepareOffline(all: Flag): Command[Seq[PathRef]] = Task.Command { + ( + super.prepareOffline(all)() ++ + codesigWorkerClasspath() + ).distinct + } + + private def codesigWorkerClassloader: Task.Worker[ClassLoader] = Task.Worker { + Jvm.createClassLoader( + classPath = codesigWorkerClasspath().map(_.path), + parent = getClass().getClassLoader() + ) + } + + def codesigWorker: Task.Worker[CodeSigWorkerApi] = Task.Worker { + codesigWorkerClassloader() + .loadClass("mill.javalib.codesig.CodeSigWorker") + .getConstructor().newInstance().asInstanceOf[CodeSigWorkerApi] + } +} + +object CodeSigWorkerModule extends ExternalModule with CodeSigWorkerModule { + override def millDiscover: Discover = Discover[this.type] +} diff --git a/website/docs/modules/ROOT/pages/javalib/testing.adoc b/website/docs/modules/ROOT/pages/javalib/testing.adoc index fcec615c0795..954c67a4f3a2 100644 --- a/website/docs/modules/ROOT/pages/javalib/testing.adoc +++ b/website/docs/modules/ROOT/pages/javalib/testing.adoc @@ -30,6 +30,65 @@ include::partial$example/javalib/testing/5-test-grouping.adoc[] include::partial$example/javalib/testing/6-test-group-parallel.adoc[] +== Selective Testing with testQuick + +Mill provides a `testQuick` task that intelligently runs only the tests affected by code +changes since the last successful test run. This can significantly speed up your development +workflow by avoiding re-running tests for code that hasn't changed. + +=== How It Works + +The `testQuick` task uses bytecode-level analysis (via Mill's codesig module) to detect which +classes have changed. It then runs: + +1. Tests for classes that have changed since the last successful run +2. Tests that failed in the previous run (to verify fixes) +3. All tests if no previous state exists (first run) + +=== Usage + +[source,bash] +---- +./mill foo.test.testQuick +---- + +The task maintains state between runs in a JSON file (`testQuick-state.json`) in its +destination directory. This state includes: + +* Method-level code signatures for change detection +* Results from the previous test run + +=== Example Workflow + +[source,bash] +---- +# Initial run - all tests execute +./mill foo.test.testQuick + +# Make a change to src/foo/Calculator.java +# Only tests related to Calculator run +./mill foo.test.testQuick + +# Fix a failing test +# The previously-failed test runs again to verify the fix +./mill foo.test.testQuick +---- + +=== Comparison with test + +|=== +| Task | Behavior + +| `test` +| Always runs all tests in the module + +| `testQuick` +| Runs only tests affected by changes since last successful run +|=== + +The `testQuick` task uses the same test framework and configuration as the regular `test` +task - it just filters which test classes to run based on detected changes. + == Github Actions Test Reports If you use Github Actions for CI, you can use https://github.com/mikepenz/action-junit-report in