refactor: migrate :src:components Groovy tests to Kotlin
diff --git a/src/components/build.gradle.kts b/src/components/build.gradle.kts
index 7595fb9..1e5052a 100644
--- a/src/components/build.gradle.kts
+++ b/src/components/build.gradle.kts
@@ -90,6 +90,7 @@
     testImplementation(testFixtures(projects.src.testkitWiremock))
     testFixturesImplementation(testFixtures(projects.src.core))
     testFixturesImplementation("junit:junit")
+    testImplementation("io.mockk:mockk")
     testFixturesImplementation("org.spockframework:spock-core")
 }
 
diff --git a/src/components/src/main/java/org/apache/jmeter/assertions/MD5HexAssertion.java b/src/components/src/main/java/org/apache/jmeter/assertions/MD5HexAssertion.java
index ae350c8..c8cc943 100644
--- a/src/components/src/main/java/org/apache/jmeter/assertions/MD5HexAssertion.java
+++ b/src/components/src/main/java/org/apache/jmeter/assertions/MD5HexAssertion.java
@@ -27,6 +27,7 @@
 import org.apache.jmeter.testelement.property.StringProperty;
 import org.apache.jmeter.util.JMeterUtils;
 import org.apache.jorphan.util.JOrphanUtils;
+import org.jetbrains.annotations.VisibleForTesting;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -90,7 +91,8 @@
         return getPropertyAsString(MD5HexAssertion.MD5HEX_KEY);
     }
 
-    private static String md5Hex(byte[] bytes) {
+    @VisibleForTesting
+    static String md5Hex(byte[] bytes) {
         byte[] md5Result = {};
 
         try {
diff --git a/src/components/src/main/java/org/apache/jmeter/extractor/BoundaryExtractor.java b/src/components/src/main/java/org/apache/jmeter/extractor/BoundaryExtractor.java
index 1438293..ac64abe 100644
--- a/src/components/src/main/java/org/apache/jmeter/extractor/BoundaryExtractor.java
+++ b/src/components/src/main/java/org/apache/jmeter/extractor/BoundaryExtractor.java
@@ -34,6 +34,7 @@
 import org.apache.jmeter.threads.JMeterVariables;
 import org.apache.jmeter.util.Document;
 import org.apache.jmeter.util.JMeterUtils;
+import org.jetbrains.annotations.VisibleForTesting;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -236,7 +237,8 @@
         return result.getResponseDataAsString(); // Bug 36898
     }
 
-    private static List<String> extract(
+    @VisibleForTesting
+    static List<String> extract(
             String leftBoundary, String rightBoundary, int matchNumber, Stream<String> previousResults) {
         boolean allItems = matchNumber <= 0;
         return previousResults
@@ -257,7 +259,8 @@
      * @param inputString   text in which to look for the fragments
      * @return list where the found text fragments will be placed
      */
-    private static List<String> extract(String leftBoundary, String rightBoundary, int matchNumber, String inputString) {
+    @VisibleForTesting
+    static List<String> extract(String leftBoundary, String rightBoundary, int matchNumber, String inputString) {
         if (StringUtils.isBlank(inputString)) {
             return Collections.emptyList();
         }
diff --git a/src/components/src/main/java/org/apache/jmeter/visualizers/backend/graphite/PickleGraphiteMetricsSender.java b/src/components/src/main/java/org/apache/jmeter/visualizers/backend/graphite/PickleGraphiteMetricsSender.java
index 0f6ae38..99267ae 100644
--- a/src/components/src/main/java/org/apache/jmeter/visualizers/backend/graphite/PickleGraphiteMetricsSender.java
+++ b/src/components/src/main/java/org/apache/jmeter/visualizers/backend/graphite/PickleGraphiteMetricsSender.java
@@ -24,6 +24,7 @@
 import java.util.List;
 
 import org.apache.commons.pool2.impl.GenericKeyedObjectPool;
+import org.jetbrains.annotations.VisibleForTesting;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -86,6 +87,11 @@
         this.prefix = prefix;
     }
 
+    @VisibleForTesting
+    List<MetricTuple> getMetrics() {
+        return metrics;
+    }
+
     /*
      * (non-Javadoc)
      *
@@ -160,7 +166,8 @@
     }
 
     /** See: https://graphite.readthedocs.io/en/1.0.0/feeding-carbon.html */
-    private static String convertMetricsToPickleFormat(List<MetricTuple> metrics) {
+    @VisibleForTesting
+    static String convertMetricsToPickleFormat(List<MetricTuple> metrics) {
         StringBuilder pickled = new StringBuilder(metrics.size() * 75);
         pickled.append(MARK).append(LIST);
 
diff --git a/src/components/src/main/java/org/apache/jmeter/visualizers/backend/graphite/TextGraphiteMetricsSender.java b/src/components/src/main/java/org/apache/jmeter/visualizers/backend/graphite/TextGraphiteMetricsSender.java
index e24bb68..a653fe5 100644
--- a/src/components/src/main/java/org/apache/jmeter/visualizers/backend/graphite/TextGraphiteMetricsSender.java
+++ b/src/components/src/main/java/org/apache/jmeter/visualizers/backend/graphite/TextGraphiteMetricsSender.java
@@ -23,6 +23,7 @@
 import java.util.List;
 
 import org.apache.commons.pool2.impl.GenericKeyedObjectPool;
+import org.jetbrains.annotations.VisibleForTesting;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -70,6 +71,11 @@
         this.prefix = prefix;
     }
 
+    @VisibleForTesting
+    List<MetricTuple> getMetrics() {
+        return metrics;
+    }
+
     /* (non-Javadoc)
      * @see org.apache.jmeter.visualizers.backend.graphite.GraphiteMetricsSender#addMetric(long, java.lang.String, java.lang.String, java.lang.String)
      */
diff --git a/src/components/src/main/java/org/apache/jmeter/visualizers/backend/influxdb/InfluxDBRawBackendListenerClient.java b/src/components/src/main/java/org/apache/jmeter/visualizers/backend/influxdb/InfluxDBRawBackendListenerClient.java
index 57be7a8..2d18049 100644
--- a/src/components/src/main/java/org/apache/jmeter/visualizers/backend/influxdb/InfluxDBRawBackendListenerClient.java
+++ b/src/components/src/main/java/org/apache/jmeter/visualizers/backend/influxdb/InfluxDBRawBackendListenerClient.java
@@ -26,6 +26,7 @@
 import org.apache.jmeter.samplers.SampleResult;
 import org.apache.jmeter.visualizers.backend.BackendListenerClient;
 import org.apache.jmeter.visualizers.backend.BackendListenerContext;
+import org.jetbrains.annotations.VisibleForTesting;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -76,6 +77,16 @@
         influxDBMetricsManager = sender;
     }
 
+    @VisibleForTesting
+    String getMeasurement() {
+        return measurement;
+    }
+
+    @VisibleForTesting
+    InfluxdbMetricsSender getInfluxDBMetricsManager() {
+        return influxDBMetricsManager;
+    }
+
     @Override
     public void setupTest(BackendListenerContext context) throws Exception {
         initInfluxDBMetricsManager(context);
@@ -119,7 +130,8 @@
         influxDBMetricsManager.addMetric(measurement, tags, fields, timestamp);
     }
 
-    private static String createTags(SampleResult sampleResult) {
+    @VisibleForTesting
+    static String createTags(SampleResult sampleResult) {
         boolean isError = sampleResult.getErrorCount() != 0;
         String status = isError ? TAG_KO : TAG_OK;
         // remove surrounding quotes and spaces from sample label
@@ -131,7 +143,8 @@
                 + ",threadName=" + threadName;
     }
 
-    private static String createFields(SampleResult sampleResult) {
+    @VisibleForTesting
+    static String createFields(SampleResult sampleResult) {
         long duration = sampleResult.getTime();
         long latency = sampleResult.getLatency();
         long connectTime = sampleResult.getConnectTime();
diff --git a/src/components/src/test/kotlin/org/apache/jmeter/assertions/CompareAssertionTest.kt b/src/components/src/test/kotlin/org/apache/jmeter/assertions/CompareAssertionTest.kt
index 333970c..c33bac5 100644
--- a/src/components/src/test/kotlin/org/apache/jmeter/assertions/CompareAssertionTest.kt
+++ b/src/components/src/test/kotlin/org/apache/jmeter/assertions/CompareAssertionTest.kt
@@ -17,60 +17,65 @@
 
 package org.apache.jmeter.assertions
 
-import java.nio.charset.StandardCharsets
-
-import org.apache.commons.lang3.StringUtils
 import org.apache.jmeter.samplers.SampleResult
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.MethodSource
 
-import spock.lang.Specification
-import spock.lang.Unroll
+class CompareAssertionTest {
+    val sut = CompareAssertion()
 
-@Unroll
-class CompareAssertionSpec extends Specification {
+    data class AssertCase(
+        val compareContent: Boolean,
+        val compareTime: Long,
+        val content: String,
+        val elapsed: Long,
+        val skip: String?,
+        val isFailure: Boolean
+    )
 
-    def sut = new CompareAssertion()
-
-    def "Result is simple assertionResult when only one response is given"() {
-        given:
-            sut.setName("myName")
-            sut.iterationStart(null)
-        when:
-            def assertionResult = sut.getResult(null)
-        then:
-            assertionResult.getName() == "myName"
-    }
-
-    def "content '#content' with compareContent==#compareContent, skip=#skip, elapsed=#elapsed and compareTime==#compareTime"() {
-        given:
-            sut.setName("myName")
-            def firstResponse = simpleResult("OK", 100)
-            sut.iterationStart(null)
-            sut.getResult(firstResponse)
-            sut.setCompareContent(compareContent)
-            sut.setCompareTime(compareTime)
-            if (skip != null) {
-                def subst = new SubstitutionElement()
-                subst.setRegex(skip)
-                sut.setStringsToSkip(Arrays.asList(subst))
+    companion object {
+        fun simpleResult(data: String, elapsed: Long) =
+            SampleResult(0, elapsed).apply {
+                setResponseData(data, Charsets.UTF_8.name())
+                sampleEnd()
             }
-        when:
-            def assertionResult = sut.getResult(simpleResult(content, elapsed))
-        then:
-            assertionResult.isFailure() == isFailure
-        where:
-            compareContent | compareTime | content        | elapsed | skip        | isFailure
-            true           | -1          | "OK"           | 100     | null        | false
-            true           | -1          | "different"    | 100     | null        | true
-            false          | -1          | "different"    | 100     | null        | false
-            false          | -1          | "different OK" | 100     | "d\\w+\\s"  | false
-            true           | 10          | "OK"           | 120     | null        | true
-            true           | 10          | "OK"           | 110     | null        | false
+
+        @JvmStatic
+        fun assertCases() = listOf(
+            AssertCase(true, -1, "OK", 100, null, false),
+            AssertCase(true, -1, "different", 100, null, true),
+            AssertCase(false, -1, "different", 100, null, false),
+            AssertCase(false, -1, "different OK", 100, "d\\w+\\s", false),
+            AssertCase(true, 10, "OK", 120, null, true),
+            AssertCase(true, 10, "OK", 110, null, false),
+        )
     }
 
-    private SampleResult simpleResult(String data, long elapsed) {
-        def result = new SampleResult(0L, elapsed)
-        result.setResponseData(data, StandardCharsets.UTF_8.name())
-        result.sampleEnd()
-        return result
+    @Test
+    fun `Result is simple assertionResult when only one response is given`() {
+        sut.name = "myName"
+        sut.iterationStart(null)
+        val assertionResult = sut.getResult(null)
+        assertEquals("myName", assertionResult.name)
+    }
+
+    @ParameterizedTest
+    @MethodSource("assertCases")
+    fun isFailure(case: AssertCase) {
+        sut.name = "myName"
+        val firstResponse = simpleResult("OK", 100)
+        sut.iterationStart(null)
+        sut.getResult(firstResponse)
+        sut.isCompareContent = case.compareContent
+        sut.compareTime = case.compareTime
+        case.skip?.let {
+            val subst = SubstitutionElement()
+            subst.regex = it
+            sut.stringsToSkip = listOf(subst)
+        }
+        val assertionResult = sut.getResult(simpleResult(case.content, case.elapsed))
+        assertEquals(case.isFailure, assertionResult.isFailure)
     }
 }
diff --git a/src/components/src/test/kotlin/org/apache/jmeter/assertions/MD5HexAssertionTest.kt b/src/components/src/test/kotlin/org/apache/jmeter/assertions/MD5HexAssertionTest.kt
index 4923af6..56740ce 100644
--- a/src/components/src/test/kotlin/org/apache/jmeter/assertions/MD5HexAssertionTest.kt
+++ b/src/components/src/test/kotlin/org/apache/jmeter/assertions/MD5HexAssertionTest.kt
@@ -17,65 +17,64 @@
 
 package org.apache.jmeter.assertions
 
-import java.nio.charset.StandardCharsets
-
-import org.apache.commons.lang3.StringUtils
 import org.apache.jmeter.samplers.SampleResult
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.MethodSource
 
-import spock.lang.Specification
-import spock.lang.Unroll
+class MD5HexAssertionTest {
+    val sut = MD5HexAssertion()
 
-@Unroll
-class MD5HexAssertionSpec extends Specification {
+    data class MD5HexAssertionCase(
+        val sampleData: String,
+        val allowedHex: String? = null,
+        val success: Boolean,
+    )
 
-    def sut = new MD5HexAssertion()
-
-    def "unset allowable hash with empty response fails"() {
-        when:
-            def result = sut.getResult(sampleResult(""))
-        then:
-            result.isFailure()
-            StringUtils.isNotBlank(result.getFailureMessage())
+    companion object {
+        @JvmStatic
+        fun md5Cases() = listOf(
+            // success
+            MD5HexAssertionCase("anything", "f0e166dc34d14d6c228ffac576c9a43c", true),
+            MD5HexAssertionCase("anything", "F0e166Dc34D14d6c228ffac576c9a43c", true),
+            // failure
+            MD5HexAssertionCase("", "", false),
+            MD5HexAssertionCase("anything", "anything", false),
+            MD5HexAssertionCase("anything", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false),
+        )
     }
 
-    def "incorrect hash #allowedHex causes result failure"() {
-        given:
-            sut.setAllowedMD5Hex(allowedHex)
-        when:
-            def result = sut.getResult(sampleResult("anything"))
-        then:
-            result.isFailure()
-            StringUtils.isNotBlank(result.getFailureMessage())
-        where:
-            allowedHex << ["", "anything", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]
+    @ParameterizedTest
+    @MethodSource("md5Cases")
+    fun md5HexAssertion(case: MD5HexAssertionCase) {
+        sut.allowedMD5Hex = case.allowedHex
+        val result = sut.getResult(sampleResult(case.sampleData))
+
+        if (case.success) {
+            assertFalse(result.isFailure, ".isFailure()")
+            assertFalse(result.isError, ".isError()")
+            assertNull(result.failureMessage, "result.failureMessage")
+        } else {
+            assertTrue(result.isFailure, ".isFailure()")
+            assertTrue(result.failureMessage.isNotBlank(), "result.failureMessage should not be blank for failure case")
+        }
     }
 
-    def "example MD5s - '#sampleData' == '#hash'"() {
-        given:
-            sut.setAllowedMD5Hex(hash)
-        when:
-            def result = sut.getResult(sampleResult(sampleData))
-        then:
-            !result.isFailure()
-            !result.isError()
-            result.getFailureMessage() == null
-        where:
-            sampleData | hash
-            "anything" | "f0e166dc34d14d6c228ffac576c9a43c"
-            "anything" | "F0e166Dc34D14d6c228ffac576c9a43c"
-    }
-
-    def "empty array has MD5 hash of D41D8CD98F00B204E9800998ECF8427E"() {
-        given:
-            def emptyByteArray = [] as byte[]
-        expect:
-            MD5HexAssertion.md5Hex(emptyByteArray)
-                    .toUpperCase(Locale.ENGLISH) == "D41D8CD98F00B204E9800998ECF8427E"
-    }
-
-    def sampleResult(String data) {
-        SampleResult response = new SampleResult()
-        response.setResponseData(data.getBytes(StandardCharsets.UTF_8))
-        return response
+    @Test
+    fun `empty array has MD5 hash of D41D8CD98F00B204E9800998ECF8427E`() {
+        val emptyByteArray = byteArrayOf()
+        assertEquals(
+            "D41D8CD98F00B204E9800998ECF8427E",
+            MD5HexAssertion.md5Hex(emptyByteArray).uppercase()
+        )
     }
 }
+
+private fun sampleResult(data: String) =
+    SampleResult().apply {
+        responseData = data.toByteArray()
+    }
diff --git a/src/components/src/test/kotlin/org/apache/jmeter/control/RandomOrderControllerTest.kt b/src/components/src/test/kotlin/org/apache/jmeter/control/RandomOrderControllerTest.kt
index a8db72c..c31efcd 100644
--- a/src/components/src/test/kotlin/org/apache/jmeter/control/RandomOrderControllerTest.kt
+++ b/src/components/src/test/kotlin/org/apache/jmeter/control/RandomOrderControllerTest.kt
@@ -17,80 +17,80 @@
 
 package org.apache.jmeter.control
 
+import io.mockk.mockk
 import org.apache.jmeter.junit.stubs.TestSampler
 import org.apache.jmeter.testelement.TestElement
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertNotEquals
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
 
-import spock.lang.Specification
+class RandomOrderControllerTest {
+    val sut = RandomOrderController()
 
-class RandomOrderControllerSpec extends Specification {
-
-    def sut = new RandomOrderController()
-
-    def "next() on an empty controller returns null"() {
-        given:
-            sut.initialize()
-        when:
-            def nextSampler = sut.next()
-        then:
-            nextSampler == null
+    @Test
+    fun `next() on an empty controller returns null`() {
+        sut.initialize()
+        val nextSampler = sut.next()
+        assertNull(nextSampler)
     }
 
-    def "next() returns only provided sampler"() {
-        given:
-            def sampler = new TestSampler("the one and only")
-            sut.addTestElement(sampler)
-            sut.initialize()
-        when:
-            def nextSampler = sut.next()
-            def nextSamplerAfterEnd = sut.next()
-        then:
-            nextSampler == sampler
-            nextSamplerAfterEnd == null
+    @Test
+    fun `next() returns only provided sampler`() {
+        val sampler = TestSampler("the one and only")
+        sut.addTestElement(sampler)
+        sut.initialize()
+
+        val nextSampler = sut.next()
+        val nextSamplerAfterEnd = sut.next()
+
+        assertEquals(sampler, nextSampler, "there's only one sampler, so it should be returned from .next()")
+        assertNull(nextSamplerAfterEnd, "nextSamplerAfterEnd")
     }
 
-    def "next() returns exactly all added elements in random order"() {
-        given:
-            def samplerNames = (1..50).collect { it.toString() }
-            samplerNames.each {
-                sut.addTestElement(new TestSampler(it))
-            }
-            sut.initialize()
-        when:
-            def elements = getAllTestElements(sut)
-        then: "the same elements are returned but in a different order"
-            def elementNames = elements.collect { it.getName() }
-            elementNames.toSet() == samplerNames.toSet() // same elements
-            elementNames != samplerNames                 // not the same order
+    @Test
+    fun `next() returns exactly all added elements in random order`() {
+        val samplerNames = (1..50).map { it.toString() }
+        samplerNames.forEach {
+            sut.addTestElement(TestSampler(it))
+        }
+        sut.initialize()
 
+        val elements = sut.getAllTestElements()
+
+        // then: "the same elements are returned but in a different order"
+        // val
+        val elementNames = elements.map { it.name }
+        assertEquals(samplerNames.toSet(), elementNames.toSet(), "controller should return the same elements")
+        assertNotEquals(samplerNames, elementNames, "The order of elements should be randomized")
     }
 
-    def "next() is null if isDone() is true"() {
-        given:
-            sut.addTestElement(Mock(TestElement))
-            sut.initialize()
-            sut.setDone(true)
-        when:
-            def nextSampler = sut.next()
-        then:
-            sut.isDone()
-            nextSampler == null
+    @Test
+    fun `next() is null if isDone() is true`() {
+        sut.addTestElement(mockk<TestElement>())
+        sut.initialize()
+        sut.isDone = true
+
+        val nextSampler = sut.next()
+        assertTrue(sut.isDone, ".isDone()")
+        assertNull(nextSampler, "nextSampler")
     }
 
     /**
      * Builds and returns a list by 'iterating' through the
-     * {@link GenericController}, using {@link GenericController#next()},
-     * placing each item in a list until <code>null</code> is encountered.
+     * [GenericController], using [GenericController.next()],
+     * placing each item in a list until `null` is encountered.
      *
-     * @param controller the {@link GenericController} to 'iterate' though
+     * @param controller the [GenericController] to 'iterate' though
      * @return a list of all items (in order) returned from next()
      * method, excluding null
      */
-    def getAllTestElements(GenericController controller) {
-        def sample
-        def samplers = []
-        while ((sample = controller.next()) != null) {
-            samplers.add(sample)
+    fun GenericController.getAllTestElements() =
+        buildList {
+            while (true) {
+                val sampler = next() ?: break
+                add(sampler)
+            }
         }
-        return samplers
-    }
 }
diff --git a/src/components/src/test/kotlin/org/apache/jmeter/control/RunTimeTest.kt b/src/components/src/test/kotlin/org/apache/jmeter/control/RunTimeTest.kt
index 52b6ee2..679fa85 100644
--- a/src/components/src/test/kotlin/org/apache/jmeter/control/RunTimeTest.kt
+++ b/src/components/src/test/kotlin/org/apache/jmeter/control/RunTimeTest.kt
@@ -17,104 +17,117 @@
 
 package org.apache.jmeter.control
 
+import io.mockk.every
+import io.mockk.mockk
 import org.apache.jmeter.junit.stubs.TestSampler
 import org.apache.jmeter.samplers.Sampler
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Disabled
+import org.junit.jupiter.api.Test
+import kotlin.math.ceil
+import kotlin.system.measureTimeMillis
 
-import spock.lang.Ignore
-import spock.lang.Specification
+class RunTimeTest {
+    val sut = RunTime()
 
-class RunTimeSpec extends Specification {
+    @Test
+    @Disabled("It fails too often due to timing issues")
+    fun `RunTime stops within a tolerance after specified runtime`() {
+        sut.runtime = 1
+        val runTimeMillis = 1000L
+        val expectedLoops = 5
+        val tolerance = ceil(0.1 * expectedLoops)
+        val samplerWaitTime: Long = runTimeMillis / expectedLoops
+        val samp1 = TestSampler("Sample 1", samplerWaitTime)
+        val samp2 = TestSampler("Sample 2", samplerWaitTime)
 
-    def sut = new RunTime()
+        val sampler1Loops = 2
+        val loop1 = LoopController().apply {
+            loops = sampler1Loops
+            setContinueForever(false)
+            addTestElement(samp1)
+        }
 
-    @Ignore("It fails too often due to timing issues")
-    def "RunTime stops within a tolerance after specified runtime"() {
-        given:
-            sut.setRuntime(1)
+        val loop2 = LoopController().apply {
+            loops = expectedLoops * 2
+            setContinueForever(false)
+            addTestElement(samp2)
+        }
 
-            def runTimeMillis = 1000
-            def expectedLoops = 5
-            def tolerance = Math.ceil(0.1f * expectedLoops)
-            int samplerWaitTime = runTimeMillis / expectedLoops
-            TestSampler samp1 = new TestSampler("Sample 1", samplerWaitTime)
-            TestSampler samp2 = new TestSampler("Sample 2", samplerWaitTime)
+        sut.addTestElement(loop1)
+        sut.addTestElement(loop2)
+        sut.isRunningVersion = true
+        loop1.isRunningVersion = true
+        loop2.isRunningVersion = true
+        sut.initialize()
 
-            def sampler1Loops = 2
-            LoopController loop1 = new LoopController()
-            loop1.setLoops(sampler1Loops)
-            loop1.setContinueForever(false)
-            loop1.addTestElement(samp1)
-
-            LoopController loop2 = new LoopController()
-            loop2.setLoops(expectedLoops * 2)
-            loop2.setContinueForever(false)
-            loop2.addTestElement(samp2)
-
-            sut.addTestElement(loop1)
-            sut.addTestElement(loop2)
-            sut.setRunningVersion(true)
-            loop1.setRunningVersion(true)
-            loop2.setRunningVersion(true)
-            sut.initialize()
-        when:
-            def sampler
-            int loopCount = 0
-            long now = System.currentTimeMillis()
-            while ((sampler = sut.next()) != null) {
+        // when:
+        var loopCount = 0
+        val elapsed = measureTimeMillis {
+            while (true) {
+                val sampler = sut.next() ?: break
                 loopCount++
                 sampler.sample(null)
             }
-            long elapsed = System.currentTimeMillis() - now
-        then:
-            sut.getIterCount() == 1
-            loopCount >= expectedLoops - tolerance
-            loopCount <= expectedLoops + tolerance
-            elapsed >= runTimeMillis - (tolerance * samplerWaitTime)
-            elapsed <= runTimeMillis + (tolerance * samplerWaitTime)
-            samp1.getSamples() == sampler1Loops
-            samp2.getSamples() >= expectedLoops - sampler1Loops - tolerance
-            samp2.getSamples() <= expectedLoops - sampler1Loops + tolerance
+        }
+        // then:
+        assertEquals(1, sut.iterCount, ".iterCount")
+        assertEquals(expectedLoops.toDouble(), loopCount.toDouble(), tolerance, "loopCount")
+        assertEquals(elapsed.toDouble(), runTimeMillis.toDouble(), tolerance * samplerWaitTime, "elapsedMillis")
+        assertEquals(sampler1Loops, samp1.samples, "samp1.samples")
+        assertEquals(
+            (expectedLoops - sampler1Loops).toDouble(),
+            samp2.samples.toDouble(),
+            tolerance,
+            "samp2.samples should be expectedLoops - sampler1Loops"
+        )
     }
 
-    def "Immediately returns null when runtime is set to 0"() {
-        given:
-            sut.setRuntime(0)
-            sut.addTestElement(Mock(Sampler))
-        when:
-            def sampler = sut.next()
-        then:
-            sampler == null
+    @Test
+    fun `Immediately returns null when runtime is set to 0`() {
+        sut.runtime = 0
+        sut.addTestElement(mockk<Sampler>())
+
+        assertNull(sut.next(), ".next()")
     }
 
-    def "Immediately returns null if only Controllers are present"() {
-        given:
-            sut.setRuntime(10)
-            sut.addTestElement(Mock(Controller))
-            sut.addTestElement(Mock(Controller))
-        when:
-            def sampler = sut.next()
-        then:
-            sampler == null
-    }
-    def "within time limit samplers are returned until empty"() {
-        given:
-            def mockSampler = Mock(Sampler)
-            sut.setRuntime(10)
-            sut.addTestElement(mockSampler)
-            sut.addTestElement(mockSampler)
-        when:
-            def samplers = [sut.next(), sut.next(), sut.next()]
-        then:
-            samplers == [mockSampler, mockSampler, null]
+    @Test
+    fun `Immediately returns null if only Controllers are present`() {
+        sut.runtime = 10
+        sut.addTestElement(
+            mockk<Controller> {
+                every { next() } returns null
+                every { isDone } returns true
+            }
+        )
+        sut.addTestElement(
+            mockk<Controller> {
+                every { next() } returns null
+                every { isDone } returns true
+            }
+        )
+
+        assertNull(sut.next(), ".next()")
     }
 
-    def "RunTime immediately returns null when there are no test elements"() {
-        given:
-            sut.setRuntime(10)
-        when:
-            def sampler = sut.next()
-        then:
-            sampler == null
+    @Test
+    fun `within time limit samplers are returned until empty`() {
+        val mockSampler = mockk<Sampler>()
+        sut.runtime = 10
+        sut.addTestElement(mockSampler)
+        sut.addTestElement(mockSampler)
+
+        assertEquals(
+            listOf(mockSampler, mockSampler, null),
+            listOf(sut.next(), sut.next(), sut.next()),
+            "there are two elements, so first two .next() should return them, then null"
+        )
     }
 
+    @Test
+    fun `RunTime immediately returns null when there are no test elements`() {
+        sut.runtime = 10
+        assertNull(sut.next(), ".next()")
+    }
 }
diff --git a/src/components/src/test/kotlin/org/apache/jmeter/control/ThroughputControllerTest.kt b/src/components/src/test/kotlin/org/apache/jmeter/control/ThroughputControllerTest.kt
index 2f9c918..84112a4 100644
--- a/src/components/src/test/kotlin/org/apache/jmeter/control/ThroughputControllerTest.kt
+++ b/src/components/src/test/kotlin/org/apache/jmeter/control/ThroughputControllerTest.kt
@@ -18,66 +18,70 @@
 package org.apache.jmeter.control
 
 import org.apache.jmeter.junit.stubs.TestSampler
+import org.apache.jmeter.threads.TestCompiler
+import org.apache.jmeter.treebuilder.dsl.testTree
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
 
-import spock.lang.Specification
+class ThroughputControllerTest {
+    val sut = ThroughputController()
 
-class ThroughputControllerSpec extends Specification {
-
-    def sut = new ThroughputController()
-
-    def setup() {
-        sut.addTestElement(new TestSampler("one"))
-        sut.addTestElement(new TestSampler("two"))
+    @BeforeEach
+    fun setup() {
+        sut.addTestElement(TestSampler("one"))
+        sut.addTestElement(TestSampler("two"))
     }
 
-    def "new TC isDone is true"() {
-        given:
-            def newTC = new ThroughputController()
-        expect:
-            newTC.isDone()
+    @Test
+    fun `TC isDone is true`() {
+        val newTC = ThroughputController()
+        assertTrue(newTC.isDone, ".isDone")
     }
 
-    def "2 maxThroughput runs samplers inside the TC only twice"() {
-        given:
-            sut.setStyle(ThroughputController.BYNUMBER)
-            sut.setMaxThroughput(2)
+    @Test
+    fun `2 maxThroughput runs samplers inside the TC only twice`() {
+        sut.style = ThroughputController.BYNUMBER
+        sut.setMaxThroughput(2)
 
-            def expectedNames =
-                    ["zero", "one", "two", "three",
-                     "zero", "one", "two", "three",
-                     "zero", "three",
-                     "zero", "three",
-                     "zero", "three",]
+        val expectedNames =
+            listOf(
+                "zero", "one", "two", "three",
+                "zero", "one", "two", "three",
+                "zero", "three",
+                "zero", "three",
+                "zero", "three",
+            )
 
-            def loop = createLoopController(5)
-        when:
-            def actualNames = expectedNames.collect({
-                loop.next().getName()
-            })
-        then:
-            loop.next() == null
-            actualNames == expectedNames
-            sut.isDone()
-            sut.testEnded()
+        val loop = createLoopController(5)
+
+        val actualNames = expectedNames.map {
+            loop.next()?.name
+        }
+
+        assertEquals(expectedNames, actualNames, "actualNames")
+        assertNull(loop.next(), "loop.next()")
+        assertTrue(sut.isDone, ".isDone")
+        sut.testEnded()
     }
 
-    def "0 maxThroughput does not run any samplers inside the TC"() {
-        given:
-            sut.setStyle(ThroughputController.BYNUMBER)
-            sut.setMaxThroughput(0)
+    @Test
+    fun `0 maxThroughput does not run any samplers inside the TC`() {
+        sut.style = ThroughputController.BYNUMBER
+        sut.setMaxThroughput(0)
+        val loops = 3
+        val loop = createLoopController(loops)
+        val expectedNames = (1..loops).flatMap { listOf("zero", "three") }
 
-            def loops = 3
-            def loop = createLoopController(loops)
+        val actualNames = expectedNames.map {
+            loop.next().name
+        }
 
-            def expectedNames = ["zero", "three"] * loops
-        when:
-            def actualNames = expectedNames.collect({
-                loop.next().getName()
-            })
-        then:
-            loop.next() == null
-            actualNames == expectedNames
-            sut.testEnded()
+        assertEquals(expectedNames, actualNames, "actualNames")
+        assertNull(loop.next(), "loop.next()")
+        sut.testEnded()
     }
 
     /**
@@ -88,25 +92,24 @@
      *        - sampler two
      * </pre>
      */
-    def "0 maxThroughput does not run any sampler inside the TC and does not cause StackOverFlowError"() {
-        given:
-            sut.setStyle(ThroughputController.BYNUMBER)
-            sut.setMaxThroughput(0)
+    @Test
+    fun `0 maxThroughput does not run any sampler inside the TC and does not cause StackOverFlowError`() {
+        sut.style = ThroughputController.BYNUMBER
+        sut.setMaxThroughput(0)
 
-            LoopController innerLoop = new LoopController()
-            innerLoop.setLoops(10000)
-            innerLoop.addTestElement(sut)
-            innerLoop.addIterationListener(sut)
-            innerLoop.initialize()
-            innerLoop.setRunningVersion(true)
-            sut.testStarted()
-            sut.setRunningVersion(true)
+        val innerLoop = LoopController().apply {
+            setLoops(10000)
+            addTestElement(sut)
+            addIterationListener(sut)
+            initialize()
+            isRunningVersion = true
+        }
+        sut.testStarted()
+        sut.isRunningVersion = true
 
-        when:
-            innerLoop.next() == null
-            innerLoop.next() == null
-        then:
-            sut.testEnded()
+        assertNull(innerLoop.next(), "innerLoop.next()")
+        assertNull(innerLoop.next(), "innerLoop.next(), second call")
+        sut.testEnded()
     }
 
     /**
@@ -117,98 +120,93 @@
      *        - sampler two
      * </pre>
      */
-    def "0.0 percentThroughput does not run any sampler inside the TC and does not cause StackOverFlowError"() {
-        given:
-            sut.setStyle(ThroughputController.BYPERCENT)
-            sut.setPercentThroughput("0.0")
+    @Test
+    fun `0 percentThroughput does not run any sampler inside the TC and does not cause StackOverFlowError`() {
+        sut.style = ThroughputController.BYPERCENT
+        sut.percentThroughput = "0.0"
 
-            LoopController innerLoop = new LoopController()
-            innerLoop.setLoops(10000)
-            innerLoop.addTestElement(sut)
-            innerLoop.addIterationListener(sut)
-            innerLoop.initialize()
-            innerLoop.setRunningVersion(true)
-            sut.testStarted()
-            sut.setRunningVersion(true)
+        val innerLoop = LoopController().apply {
+            loops = 10000
+            addTestElement(sut)
+            addIterationListener(sut)
+            initialize()
+            isRunningVersion = true
+        }
+        sut.testStarted()
+        sut.isRunningVersion = true
 
-        when:
-            innerLoop.next() == null
-            innerLoop.next() == null
-        then:
-            sut.testEnded()
+        assertNull(innerLoop.next(), "innerLoop.next()")
+        assertNull(innerLoop.next(), "innerLoop.next(), second call")
+
+        sut.testEnded()
     }
 
-    def "33.33% will run all the samplers inside the TC every 3 iterations"() {
-        given:
-            sut.setStyle(ThroughputController.BYPERCENT)
-            sut.setPercentThroughput(33.33f)
+    @Test
+    fun `33 percentThroughput will run all the samplers inside the TC every 3 iterations`() {
+        sut.style = ThroughputController.BYPERCENT
+        sut.setPercentThroughput(33.33f)
 
-            def loop = createLoopController(9)
-            // Expected results established using the DDA algorithm - see:
-            // http://www.siggraph.org/education/materials/HyperGraph/scanline/outprims/drawline.htm
-            def expectedNames =
-                    ["zero", "three", // 0/1 vs. 1/1 -> 0 is closer to 33.33
-                     "zero", "one", "two", "three", // 0/2 vs. 1/2 -> 50.0 is closer to 33.33
-                     "zero", "three", // 1/3 vs. 2/3 -> 33.33 is closer to 33.33
-                     "zero", "three", // 1/4 vs. 2/4 -> 25.0 is closer to 33.33
-                     "zero", "one", "two", "three", // 1/5 vs. 2/5 -> 40.0 is closer to 33.33
-                     "zero", "three", // 2/6 vs. 3/6 -> 33.33 is closer to 33.33
-                     "zero", "three", // 2/7 vs. 3/7 -> 0.2857 is closer to 33.33
-                     "zero", "one", "two", "three", // 2/8 vs. 3/8 -> 0.375 is closer to 33.33
-                     "zero", "three", // ...
-                    ]
-        when:
-            def actualNames = expectedNames.collect({
-                loop.next().getName()
-            })
-        then:
-            loop.next() == null
-            actualNames == expectedNames
-            sut.testEnded()
+        val loop = createLoopController(9)
+        // Expected results established using the DDA algorithm - see:
+        // http://www.siggraph.org/education/materials/HyperGraph/scanline/outprims/drawline.htm
+        val expectedNames = listOf(
+            "zero", "three", // 0/1 vs. 1/1 -> 0 is closer to 33.33
+            "zero", "one", "two", "three", // 0/2 vs. 1/2 -> 50.0 is closer to 33.33
+            "zero", "three", // 1/3 vs. 2/3 -> 33.33 is closer to 33.33
+            "zero", "three", // 1/4 vs. 2/4 -> 25.0 is closer to 33.33
+            "zero", "one", "two", "three", // 1/5 vs. 2/5 -> 40.0 is closer to 33.33
+            "zero", "three", // 2/6 vs. 3/6 -> 33.33 is closer to 33.33
+            "zero", "three", // 2/7 vs. 3/7 -> 0.2857 is closer to 33.33
+            "zero", "one", "two", "three", // 2/8 vs. 3/8 -> 0.375 is closer to 33.33
+            "zero", "three", // ...
+        )
+        val actualNames = expectedNames.map {
+            loop.next()?.name
+        }
+        assertEquals(expectedNames, actualNames, "actualNames")
+        assertNull(loop.next(), "loop.next()")
+        sut.testEnded()
     }
 
-    def "0% does not run any samplers inside the TC"() {
-        given:
-            sut.setStyle(ThroughputController.BYPERCENT)
-            sut.setPercentThroughput(0.0f)
+    @Test
+    fun `0 percentThroughput does not run any samplers inside the TC`() {
+        sut.style = ThroughputController.BYPERCENT
+        sut.setPercentThroughput(0.0f)
 
-            def loops = 3
-            def loop = createLoopController(loops)
+        val loops = 3
+        val loop = createLoopController(loops)
 
-            def expectedNames = ["zero", "three",] * loops
-        when:
-            def actualNames = expectedNames.collect({
-                loop.next().getName()
-            })
-        then:
-            loop.next() == null
-            actualNames == expectedNames
-            sut.testEnded()
+        val expectedNames = (1..loops).flatMap { listOf("zero", "three") }
+        val actualNames = expectedNames.map {
+            loop.next().name
+        }
+
+        assertEquals(expectedNames, actualNames, "actualNames")
+        assertNull(loop.next(), "loop.next()")
+        sut.testEnded()
     }
 
-    def "100% always runs all samplers inside the TC"() {
-        given:
-            sut.setStyle(ThroughputController.BYPERCENT)
-            sut.setPercentThroughput(100.0f)
+    @Test
+    fun `100 percentThroughput always runs all samplers inside the TC`() {
+        sut.style = ThroughputController.BYPERCENT
+        sut.setPercentThroughput(100.0f)
 
-            def loops = 3
-            def loop = createLoopController(loops)
+        val loops = 3
+        val loop = createLoopController(loops)
 
-            def expectedNames = ["zero", "one", "two", "three",] * loops
-        when:
-            def actualNames = expectedNames.collect({
-                loop.next().getName()
-            })
-        then:
-            loop.next() == null
-            actualNames == expectedNames
-            sut.testEnded()
+        val expectedNames = (1..loops).flatMap { listOf("zero", "one", "two", "three") }
+        val actualNames = expectedNames.map {
+            loop.next()?.name
+        }
+        assertEquals(expectedNames, actualNames, "actualNames")
+        assertNull(loop.next(), "loop.next()")
+        sut.testEnded()
     }
 
     /**
      * Create a LoopController, executed once, which contains an inner loop that
-     * runs {@code innerLoops} times, this inner loop contains a TestSampler ("zero")
-     * the {@link ThroughputController} which contains two test samplers
+     * runs [innerLoops] times, this inner loop contains a TestSampler ("zero")
+     * the [ThroughputController] which contains two test samplers
      * ("one" and "two") followed by a final TestSampler ("three"):
      *
      * <pre>
@@ -221,25 +219,31 @@
      *     - sampler three
      * </pre>
      *
-     * @param innerLoops number of times to loop the {@link ThroughputController}
-     * @return the{@link LoopController}
+     * @param innerLoops number of times to loop the [ThroughputController]
+     * @return the [LoopController]
      */
-    def createLoopController(int innerLoops) {
-        LoopController innerLoop = new LoopController()
-        innerLoop.setLoops(innerLoops)
-        innerLoop.addTestElement(new TestSampler("zero"))
-        innerLoop.addTestElement(sut)
-        innerLoop.addIterationListener(sut)
-        innerLoop.addTestElement(new TestSampler("three"))
-
-        def outerLoop = new LoopController()
-        outerLoop.setLoops(1)
-        outerLoop.addTestElement(innerLoop)
+    private fun createLoopController(innerLoops: Int): LoopController {
         sut.testStarted()
-        outerLoop.setRunningVersion(true)
-        sut.setRunningVersion(true)
-        innerLoop.setRunningVersion(true)
+        val tree = testTree {
+            LoopController::class {
+                loops = 1
+                LoopController::class {
+                    loops = innerLoops
+                    +TestSampler("zero")
+                    +sut
+                    +TestSampler("three")
+                }
+            }
+        }
+        val compiler = TestCompiler(tree)
+        tree.traverse(compiler)
+
+        val outerLoop = tree.list().first() as LoopController
         outerLoop.initialize()
-        outerLoop
+
+        sut.isRunningVersion = true
+        sut.testStarted()
+
+        return outerLoop
     }
 }
diff --git a/src/components/src/test/kotlin/org/apache/jmeter/extractor/BoundaryExtractorTest.kt b/src/components/src/test/kotlin/org/apache/jmeter/extractor/BoundaryExtractorTest.kt
index c38944d..4bbe7a8 100644
--- a/src/components/src/test/kotlin/org/apache/jmeter/extractor/BoundaryExtractorTest.kt
+++ b/src/components/src/test/kotlin/org/apache/jmeter/extractor/BoundaryExtractorTest.kt
@@ -17,384 +17,362 @@
 
 package org.apache.jmeter.extractor
 
-import java.util.stream.Stream
-
 import org.apache.jmeter.samplers.SampleResult
 import org.apache.jmeter.threads.JMeterContext
 import org.apache.jmeter.threads.JMeterContextService
 import org.apache.jmeter.threads.JMeterVariables
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertThrows
+import org.junit.jupiter.api.fail
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.MethodSource
+import org.junit.jupiter.params.provider.ValueSource
+import java.util.stream.Stream
 
-import spock.lang.Specification
-import spock.lang.Unroll
+class BoundaryExtractorTest {
+    data class ExtractCase(val occurrences: IntRange, val matchNumber: Int, val expected: List<String>)
+    companion object {
+        const val LEFT = "LB"
+        const val RIGHT = "RB"
+        const val DEFAULT_VAL = "defaultVal"
+        const val VAR_NAME = "varName"
 
-@Unroll
-class BoundaryExtractorSpec extends Specification {
+        /**
+         * Creates a string with a "match" for each number in the list.
+         *
+         * @param occurrences list of numbers to be the "body" of a match
+         * @return a string with a start, end and then a left boundary + number + right boundary
+         * e.g. "... LB1RB LB2RB ..."
+         */
+        fun createInputString(occurrences: IntRange) =
+            occurrences.joinToString(" ", prefix = "start \t\r\n", postfix = "\n\t end") {
+                LEFT + it + RIGHT
+            }
 
-    static LEFT = "LB"
-    static RIGHT = "RB"
-    static DEFAULT_VAL = "defaultVal"
-    static VAR_NAME = "varName"
+        fun createSampleResult(responseData: String) = SampleResult().apply {
+            sampleStart()
+            setResponseData(responseData, "ISO-8859-1")
+            sampleEnd()
+        }
 
-    def sut = new BoundaryExtractor()
+        @JvmStatic
+        fun extractCases() = listOf(
+            ExtractCase(1..1, -1, listOf("1")),
+            ExtractCase(1..1, 0, listOf("1")),
+            ExtractCase(1..1, 1, listOf("1")),
+            ExtractCase(1..1, 2, listOf()),
+            ExtractCase(1..2, -1, listOf("1", "2")),
+            ExtractCase(1..2, 1, listOf("1")),
+            ExtractCase(1..2, 2, listOf("2")),
+            ExtractCase(1..3, 3, listOf("3")),
+        )
 
-    SampleResult prevResult
-    JMeterVariables vars
-    JMeterContext context
+        @JvmStatic
+        fun extractCasesStream() = listOf(
+            ExtractCase(1..1, -1, List(10) { "1" }),
+            ExtractCase(1..1, 0, List(10) { "1" }),
+            ExtractCase(1..1, 1, listOf("1")),
+            ExtractCase(1..1, 10, listOf("1")),
+            ExtractCase(1..1, 11, listOf()),
+            ExtractCase(1..2, -1, (1..10).flatMap { listOf("1", "2") }),
+            ExtractCase(1..2, 1, listOf("1")),
+            ExtractCase(1..2, 2, listOf("2")),
+            ExtractCase(1..3, 3, listOf("3")),
+        )
+    }
 
-    def setup() {
-        vars = new JMeterVariables()
+    val sut = BoundaryExtractor()
+
+    private lateinit var prevResult: SampleResult
+    lateinit var vars: JMeterVariables
+    private lateinit var context: JMeterContext
+
+    @BeforeEach
+    fun setup() {
+        vars = JMeterVariables()
         context = JMeterContextService.getContext()
-        context.setVariables(vars)
+        context.variables = vars
 
-        sut.setThreadContext(context)
-        sut.setRefName(VAR_NAME)
-        sut.setLeftBoundary(LEFT)
-        sut.setRightBoundary(RIGHT)
-        sut.setDefaultValue(DEFAULT_VAL)
-        sut.setMatchNumber(1)
+        sut.threadContext = context
+        sut.refName = VAR_NAME
+        sut.leftBoundary = LEFT
+        sut.rightBoundary = RIGHT
+        sut.defaultValue = DEFAULT_VAL
+        sut.matchNumber = 1
 
-        prevResult = new SampleResult()
-        prevResult.sampleStart()
-        prevResult.setResponseData(createInputString(1..2), null)
-        prevResult.sampleEnd()
-        context.setPreviousResult(prevResult)
+        prevResult = SampleResult().apply {
+            sampleStart()
+            setResponseData(createInputString(1..2), null)
+            sampleEnd()
+        }
+        context.previousResult = prevResult
     }
 
-    def "Extract, where pattern exists, with matchNumber=#matchNumber from #occurrences returns #expected"() {
-        given:
-            def input = createInputString(occurrences)
-        when:
-            def matches = sut.extract(LEFT, RIGHT, matchNumber, input)
-        then:
-            matches == expected
-        where:
-            occurrences | matchNumber || expected
-            1..1        | -1          || ['1']
-            1..1        | 0           || ['1']
-            1..1        | 1           || ['1']
-            1..1        | 2           || []
-            1..2        | -1          || ['1', '2']
-            1..2        | 1           || ['1']
-            1..2        | 2           || ['2']
-            1..3        | 3           || ['3']
+    private fun assertVarValueEquals(expected: String?) {
+        assertEquals(expected, vars.get(VAR_NAME), "vars.get($VAR_NAME)")
+    }
+    private fun assertVarNameMatchNrEquals(expected: String?) {
+        assertEquals(expected, vars.get("${VAR_NAME}_matchNr"), "vars.get(${VAR_NAME}_matchNr)")
     }
 
-    def "Extract, where pattern does not exist, with matchNumber=#matchNumber returns an empty list"() {
-        expect:
-            sut.extract(LEFT, RIGHT, matchNumber, 'start end') == []
-        where:
-            matchNumber << [-1, 0, 1, 2, 100]
+    private fun assertAllVars(expected: List<String>) {
+        assertEquals(expected, getAllVars(), "getAllVars()")
     }
 
-    def "Extract, where pattern exists in the stream, with matchNumber=#matchNumber from #occurrences returns #expected"() {
-        given:
-            def input = createInputString(occurrences)
-            Stream<String> stream = ([input, "", null] * 10).stream()
-        when:
-            def matches = sut.extract(LEFT, RIGHT, matchNumber, stream)
-        then:
-            matches == expected
-        where:
-            occurrences | matchNumber || expected
-            1..1        | -1          || ['1'] * 10
-            1..1        | 0           || ['1'] * 10
-            1..1        | 1           || ['1']
-            1..1        | 10          || ['1']
-            1..1        | 11          || []
-            1..2        | -1          || ['1', '2'] * 10
-            1..2        | 1           || ['1']
-            1..2        | 2           || ['2']
-            1..3        | 3           || ['3']
+    @ParameterizedTest
+    @MethodSource("extractCases")
+    fun `Extract, where pattern exists, with matchNumber=#matchNumber from #occurrences returns #expected`(case: ExtractCase) {
+        val input = createInputString(case.occurrences)
+        val matches = BoundaryExtractor.extract(LEFT, RIGHT, case.matchNumber, input)
+
+        assertEquals(case.expected, matches)
     }
 
-    def "Extract, where pattern does not exist in the stream, with matchNumber=#matchNumber returns an empty list"() {
-        given:
-            Stream<String> stream = (['start end'] * 10).stream()
-        expect:
-            sut.extract(LEFT, RIGHT, matchNumber, stream) == []
-        where:
-            matchNumber << [-1, 0, 1, 2, 100]
+    @ParameterizedTest
+    @ValueSource(ints = [-1, 0, 1, 2, 100])
+    fun `Extract, where pattern does not exist, with matchNumber=#matchNumber returns an empty list`(matchNumber: Int) {
+        assertEquals(listOf<Any>(), BoundaryExtractor.extract(LEFT, RIGHT, matchNumber, "start end"))
     }
 
-    def "IllegalArgumentException when name (#name) is null"() {
-        given:
-            sut.setLeftBoundary(lb)
-            sut.setRightBoundary(rb)
-            sut.setRefName(name)
-        when:
-            sut.process()
-        then:
-            thrown(IllegalArgumentException)
-        where:
-            lb   | rb   | name
-            "l"  | "r"  | null
+    @ParameterizedTest
+    @MethodSource("extractCasesStream")
+    fun `Extract, where pattern exists in the stream, with matchNumber=#matchNumber from #occurrences returns #expected`(case: ExtractCase) {
+        val input = createInputString(case.occurrences)
+        val stream = (1..10).flatMap { listOf(input, "", null) }.stream()
+        val matches = BoundaryExtractor.extract(LEFT, RIGHT, case.matchNumber, stream)
+
+        assertEquals(case.expected, matches)
     }
 
-    def "matching only on left boundary returns default"() {
-        given:
-            sut.setRightBoundary("does-not-exist")
-        when:
-            sut.process()
-        then:
-            vars.get(VAR_NAME) == DEFAULT_VAL
+    @ParameterizedTest
+    @ValueSource(ints = [-1, 0, 1, 2, 100])
+    fun `Extract, where pattern does not exist in the stream, with matchNumber=#matchNumber returns an empty list`(matchNumber: Int) {
+        val stream: Stream<String> = (1..10).flatMap { listOf("start end") }.stream()
+
+        assertEquals(listOf<Any>(), BoundaryExtractor.extract(LEFT, RIGHT, matchNumber, stream))
     }
 
-    def "matching only on right boundary returns default"() {
-        given:
-            sut.setLeftBoundary("does-not-exist")
-        when:
+    @Test
+    fun `IllegalArgumentException when name (#name) is null`() {
+        sut.leftBoundary = "l"
+        sut.rightBoundary = "r"
+        sut.refName = null
+
+        assertThrows<IllegalArgumentException> {
             sut.process()
-        then:
-            vars.get(VAR_NAME) == DEFAULT_VAL
+        }
     }
 
-    def "variables from a previous extraction are removed"() {
-        given:
-            sut.setMatchNumber(-1)
-            sut.process()
-            assert vars.get("${VAR_NAME}_1") == "1"
-            assert vars.get("${VAR_NAME}_matchNr") == "2"
-        when:
-            // Now rerun with match fail
-            sut.setMatchNumber(10)
-            sut.process()
-        then:
-            vars.get(VAR_NAME) == DEFAULT_VAL
-            vars.get("${VAR_NAME}_1") == null
-            vars.get("${VAR_NAME}_matchNr") == null
+    @Test
+    fun `matching only on left boundary returns default`() {
+        sut.rightBoundary = "does-not-exist"
+        sut.process()
+        assertVarValueEquals(DEFAULT_VAL)
     }
 
-    def "with no sub-samples parent and all scope return data but children scope does not"() {
-        given:
-            sut.setScopeParent()
-        when:
-            sut.process()
-        then:
-            vars.get(VAR_NAME) == "1"
-
-        and:
-            sut.setScopeAll()
-        when:
-            sut.process()
-        then:
-            vars.get(VAR_NAME) == "1"
-
-        and:
-            sut.setScopeChildren()
-        when:
-            sut.process()
-        then:
-            vars.get(VAR_NAME) == DEFAULT_VAL
+    @Test
+    fun `matching only on right boundary returns default`() {
+        sut.leftBoundary = "does-not-exist"
+        sut.process()
+        assertVarValueEquals(DEFAULT_VAL)
     }
 
-    def "with sub-samples parent, all and children scope return expected item"() {
-        given:
-            prevResult.addSubResult(createSampleResult("${LEFT}sub1${RIGHT}"))
-            prevResult.addSubResult(createSampleResult("${LEFT}sub2${RIGHT}"))
-            prevResult.addSubResult(createSampleResult("${LEFT}sub3${RIGHT}"))
-            sut.setScopeParent()
-        when:
-            sut.process()
-        then:
-            vars.get(VAR_NAME) == "1"
+    @Test
+    fun `variables from a previous extraction are removed`() {
+        sut.matchNumber = -1
+        sut.process()
+        assertEquals("1", vars.get("${VAR_NAME}_1"))
+        assertEquals("2", vars.get("${VAR_NAME}_matchNr"))
 
-        and:
-            sut.setScopeAll()
-            sut.setMatchNumber(3) // skip 2 in parent sample
-        when:
-            sut.process()
-        then:
-            vars.get(VAR_NAME) == "sub1"
-
-        and:
-            sut.setScopeChildren()
-            sut.setMatchNumber(3)
-        when:
-            sut.process()
-        then:
-            vars.get(VAR_NAME) == "sub3"
+        // Now rerun with match fail
+        sut.matchNumber = 10
+        sut.process()
+        assertVarValueEquals(DEFAULT_VAL)
+        assertNull(vars.get("${VAR_NAME}_1"), "vars.get(${VAR_NAME}_1)")
+        assertVarNameMatchNrEquals(null)
     }
 
-    def "with sub-samples parent, all and children scope return expected data"() {
-        given:
-            prevResult.addSubResult(createSampleResult("${LEFT}sub1${RIGHT}"))
-            prevResult.addSubResult(createSampleResult("${LEFT}sub2${RIGHT}"))
-            prevResult.addSubResult(createSampleResult("${LEFT}sub3${RIGHT}"))
-            sut.setMatchNumber(-1)
+    @Test
+    fun `with no sub-samples parent and all scope return data but children scope does not`() {
+        sut.setScopeParent()
+        sut.process()
+        assertVarValueEquals("1")
 
-            sut.setScopeParent()
-        when:
-            sut.process()
-        then:
-            vars.get("${VAR_NAME}_matchNr") == "2"
-            getAllVars() == ["1", "2"]
+        sut.setScopeAll()
+        sut.process()
+        assertVarValueEquals("1")
 
-        and:
-            sut.setScopeAll()
-        when:
-            sut.process()
-        then:
-            vars.get("${VAR_NAME}_matchNr") == "5"
-            getAllVars() == ["1", "2", "sub1", "sub2", "sub3"]
-
-        and:
-            sut.setScopeChildren()
-        when:
-            sut.process()
-        then:
-            vars.get("${VAR_NAME}_matchNr") == "3"
-            getAllVars() == ["sub1", "sub2", "sub3"]
+        sut.setScopeChildren()
+        sut.process()
+        assertVarValueEquals(DEFAULT_VAL)
     }
 
-    def "when 'default empty value' is true the default value is allowed to be empty"() {
-        given:
-            sut.setMatchNumber(10) // no matches
-            sut.setDefaultValue("")
-            sut.setDefaultEmptyValue(true)
-        when:
-            sut.process()
-        then:
-            vars.get(VAR_NAME) == ""
+    @Test
+    fun `with sub-samples parent, all and children scope return expected item`() {
+        prevResult.addSubResult(createSampleResult("${LEFT}sub1$RIGHT"))
+        prevResult.addSubResult(createSampleResult("${LEFT}sub2$RIGHT"))
+        prevResult.addSubResult(createSampleResult("${LEFT}sub3$RIGHT"))
+        sut.setScopeParent()
+
+        sut.process()
+
+        assertVarValueEquals("1")
+
+        sut.setScopeAll()
+        sut.matchNumber = 3 // skip 2 in parent sample
+        sut.process()
+        assertVarValueEquals("sub1")
+
+        sut.setScopeChildren()
+        sut.matchNumber = 3
+        sut.process()
+        assertVarValueEquals("sub3")
     }
 
-    def "when default value is empty but not allowed null is returned"() {
-        given:
-            sut.setMatchNumber(10) // no matches
-            sut.setDefaultValue("")
-            sut.setDefaultEmptyValue(false)
-        when:
-            sut.process()
-        then:
-            vars.get(VAR_NAME) == null
+    @Test
+    fun `with sub-samples parent, all and children scope return expected data`() {
+        prevResult.addSubResult(createSampleResult("${LEFT}sub1$RIGHT"))
+        prevResult.addSubResult(createSampleResult("${LEFT}sub2$RIGHT"))
+        prevResult.addSubResult(createSampleResult("${LEFT}sub3$RIGHT"))
+        sut.matchNumber = -1
+
+        sut.setScopeParent()
+        sut.process()
+
+        assertVarNameMatchNrEquals("2")
+        assertAllVars(listOf("1", "2"))
+
+        sut.setScopeAll()
+        sut.process()
+        assertVarNameMatchNrEquals("5")
+        assertAllVars(listOf("1", "2", "sub1", "sub2", "sub3"))
+
+        sut.setScopeChildren()
+        sut.process()
+        assertVarNameMatchNrEquals("3")
+        assertAllVars(listOf("sub1", "sub2", "sub3"))
     }
 
-    def "with no previous results result is null"() {
-        given:
-            context.setPreviousResult(null)
-            sut.setDefaultEmptyValue(true)
-        when:
-            sut.process()
-        then:
-            vars.get(VAR_NAME) == null
+    @Test
+    fun `when 'default empty value' is true the default value is allowed to be empty`() {
+        sut.matchNumber = 10 // no matches
+        sut.defaultValue = ""
+        sut.setDefaultEmptyValue(true)
+        sut.process()
+        assertVarValueEquals("")
     }
 
-    def "with non-existent variable result is null"() {
-        given:
-            sut.setDefaultValue(null)
-            sut.setScopeVariable("empty-var-name")
-        when:
-            sut.process()
-        then:
-            vars.get(VAR_NAME) == null
+    @Test
+    fun `when default value is empty but not allowed null is returned`() {
+        sut.matchNumber = 10 // no matches
+        sut.defaultValue = ""
+        sut.setDefaultEmptyValue(false)
+        sut.process()
+        assertVarValueEquals(null)
     }
 
-    def "not allowing blank default value returns null upon no matches"() {
-        given:
-            sut.setMatchNumber(10) // no matches
-            sut.setDefaultValue("")
-            sut.setDefaultEmptyValue(false)
-        when:
-            sut.process()
-        then:
-            vars.get(VAR_NAME) == null
+    @Test
+    fun `with no previous results result is null`() {
+        context.previousResult = null
+        sut.setDefaultEmptyValue(true)
+        sut.process()
+        assertVarValueEquals(null)
     }
 
-    def "extract all matches from variable input"() {
-        given:
-            sut.setMatchNumber(-1)
-            sut.setScopeVariable("contentvar")
-            vars.put("contentvar", createInputString(1..2))
-        when:
-            sut.process()
-        then:
-            getAllVars() == ["1", "2"]
-            vars.get("${VAR_NAME}_matchNr") == "2"
+    @Test
+    fun `with non-existent variable result is null`() {
+        sut.defaultValue = null
+        sut.setScopeVariable("empty-var-name")
+        sut.process()
+        assertVarValueEquals(null)
     }
 
-    def "extract random from variable returns one of the matches"() {
-        given:
-            sut.setMatchNumber(0)
-            sut.setScopeVariable("contentvar")
-            vars.put("contentvar", createInputString(1..42))
-        when:
-            sut.process()
-        then:
-            (1..42).collect({ it.toString() }).contains(vars.get(VAR_NAME))
-            vars.get("${VAR_NAME}_matchNr") == null
+    @Test
+    fun `not allowing blank default value returns null upon no matches`() {
+        sut.matchNumber = 10 // no matches
+        sut.defaultValue = ""
+        sut.setDefaultEmptyValue(false)
+        sut.process()
+        assertVarValueEquals(null)
     }
 
-    def "extract all from an empty variable returns no results"() {
-        given:
-            sut.setMatchNumber(-1)
-            sut.setScopeVariable("contentvar")
-            vars.put("contentvar", "")
-        when:
-            sut.process()
-        then:
-            vars.get("${VAR_NAME}_matchNr") == "0"
-            vars.get("${VAR_NAME}_1") == null
+    @Test
+    fun `extract all matches from variable input`() {
+        sut.matchNumber = -1
+        sut.setScopeVariable("contentvar")
+        vars.put("contentvar", createInputString(1..2))
+        sut.process()
+        assertAllVars(listOf("1", "2"))
+        assertVarNameMatchNrEquals("2")
     }
 
-    def "previous extractions are cleared"() {
-        given:
-            sut.setMatchNumber(-1)
-            sut.setScopeVariable("contentvar")
-            vars.put("contentvar", createInputString(1..10))
-            sut.process()
-            assert getAllVars() == (1..10).collect({ it.toString() })
-            assert vars.get("${VAR_NAME}_matchNr") == "10"
-            vars.put("contentvar", createInputString(11..15))
-            sut.setMatchNumber("-1")
-            def expectedMatches = (11..15).collect({ it.toString() })
-        when:
-            sut.process()
-        then:
-            getAllVars() == expectedMatches
-            vars.get("${VAR_NAME}_matchNr") == "5"
-            (6..10).collect { vars.get("${VAR_NAME}_${it}") } == [null] * 5
+    @Test
+    fun `extract random from variable returns one of the matches`() {
+        sut.matchNumber = 0
+        sut.setScopeVariable("contentvar")
+        vars.put("contentvar", createInputString(1..42))
 
-        and:
-            sut.setMatchNumber(0)
-        when:
-            sut.process()
-        then:
-            expectedMatches.contains(vars.get(VAR_NAME))
+        sut.process()
+
+        (1..42).map { it.toString() }.contains(vars.get(VAR_NAME))
+        assertVarNameMatchNrEquals(null)
     }
 
-    /**
-     * Creates a string with a "match" for each number in the list.
-     *
-     * @param occurrences list of numbers to be the "body" of a match
-     * @return a string with a start, end and then a left boundary + number + right boundary
-     * e.g. "... LB1RB LB2RB ..."
-     */
-    static createInputString(List<Integer> occurrences) {
-        def middle = occurrences.collect({ LEFT + it + RIGHT }).join(" ")
-        return 'start \t\r\n' + middle + '\n\t end'
+    @Test
+    fun `extract all from an empty variable returns no results`() {
+        sut.matchNumber = -1
+        sut.setScopeVariable("contentvar")
+        vars.put("contentvar", "")
+
+        sut.process()
+        assertVarNameMatchNrEquals("0")
+        assertNull(vars.get("${VAR_NAME}_1"), "vars.get(${VAR_NAME}_1)")
     }
 
-    static createSampleResult(String responseData) {
-        SampleResult child = new SampleResult()
-        child.sampleStart()
-        child.setResponseData(responseData, "ISO-8859-1")
-        child.sampleEnd()
-        return child
+    @Test
+    fun `previous extractions are cleared`() {
+        sut.matchNumber = -1
+        sut.setScopeVariable("contentvar")
+        vars.put("contentvar", createInputString(1..10))
+        sut.process()
+        assertAllVars((1..10).map { it.toString() })
+        assertVarNameMatchNrEquals("10")
+        vars.put("contentvar", createInputString(11..15))
+        sut.setMatchNumber("-1")
+        val expectedMatches = (11..15).map { it.toString() }
+
+        sut.process()
+
+        assertAllVars(expectedMatches)
+        assertVarNameMatchNrEquals("5")
+        assertEquals(
+            List(5) { null },
+            (6..10).map { vars.get("${VAR_NAME}_$it") },
+            "vars.get(${VAR_NAME}_6..10)"
+        )
+
+        sut.matchNumber = 0
+        sut.process()
+
+        val varValue = vars.get(VAR_NAME)
+        if (varValue !in expectedMatches) {
+            fail("expectedMatches ($expectedMatches) should contain value of $VAR_NAME variable $varValue")
+        }
     }
 
     /**
      * @return a list of all the variables in the format ${VAR_NAME}_${i}
      * starting at i = 1 until null is returned
      */
-    def getAllVars() {
-        def allVars = []
-        def i = 1
-        def var = vars.get("${VAR_NAME}_${i}")
-        while (var != null) {
-            allVars.add(var)
+    private fun getAllVars(): List<String> = buildList {
+        var i = 1
+        while (true) {
+            val value = vars.get("${VAR_NAME}_$i") ?: break
+            add(value)
             i++
-            var = vars.get("${VAR_NAME}_${i}")
         }
-        return allVars
     }
-
 }
diff --git a/src/components/src/test/kotlin/org/apache/jmeter/extractor/JoddExtractorTest.kt b/src/components/src/test/kotlin/org/apache/jmeter/extractor/JoddExtractorTest.kt
index 52da3dd..fa143b3 100644
--- a/src/components/src/test/kotlin/org/apache/jmeter/extractor/JoddExtractorTest.kt
+++ b/src/components/src/test/kotlin/org/apache/jmeter/extractor/JoddExtractorTest.kt
@@ -17,35 +17,56 @@
 
 package org.apache.jmeter.extractor
 
-import spock.lang.Specification
-import spock.lang.Unroll
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.MethodSource
 
-@Unroll
-class JoddExtractorSpec extends Specification {
+class JoddExtractorTest {
+    data class ExtractCase(
+        val expression: String,
+        val attribute: String,
+        val matchNumber: Int,
+        val expectedList: List<String>,
+        val found: Int,
+        val expected: Int,
+        val cacheKey: String
+    )
 
-    def "extract #expression and #attribute"() {
-        given:
-            def resultList = []
-            def input = """
-<html>
-  <head><title>Test</title></head>
-  <body>
-    <h1 class="title">TestTitle</h1>
-    <p>Some text</p>
-    <h1>AnotherTitle</h1>
-  </body>
-</html>
-"""
-        when:
-            def foundCount = new JoddExtractor().extract(expression, attribute, matchNumber, input, resultList, found, cacheKey)
-        then:
-            foundCount == expected
-            resultList == expectedList
-        where:
-            expression        | attribute | matchNumber | expectedList                  | found | expected | cacheKey
-            "p"               | ""        | 1           | ["Some text"]                 | -1    | 0        | "key"
-            "h1[class=title]" | "class"   | 1           | ["title"]                     | -1    | 0        | "key"
-            "h1"              | ""        | 0           | ["TestTitle", "AnotherTitle"] | -1    | 1        | "key"
-            "notthere"        | ""        | 0           | []                            | -1    | -1       | "key"
+    companion object {
+        @JvmStatic
+        fun extractCases() = listOf(
+            ExtractCase("p", "", 1, listOf("Some text"), -1, 0, "key"),
+            ExtractCase("h1[class=title]", "class", 1, listOf("title"), -1, 0, "key"),
+            ExtractCase("h1", "", 0, listOf("TestTitle", "AnotherTitle"), -1, 1, "key"),
+            ExtractCase("notthere", "", 0, listOf(), -1, -1, "key"),
+        )
+    }
+
+    @ParameterizedTest
+    @MethodSource("extractCases")
+    fun extract(case: ExtractCase) {
+        val resultList = mutableListOf<String>()
+        val input = /* language=xml */
+            """
+            <html>
+              <head><title>Test</title></head>
+              <body>
+                <h1 class="title">TestTitle</h1>
+                <p>Some text</p>
+                <h1>AnotherTitle</h1>
+              </body>
+            </html>
+            """.trimIndent()
+        val foundCount = JoddExtractor().extract(
+            case.expression,
+            case.attribute,
+            case.matchNumber,
+            input,
+            resultList,
+            case.found,
+            case.cacheKey
+        )
+        assertEquals(case.expectedList, resultList, "resultList")
+        assertEquals(case.expected, foundCount, "foundCount")
     }
 }
diff --git a/src/components/src/test/kotlin/org/apache/jmeter/extractor/json/render/JsonRendererExtensions.kt b/src/components/src/test/kotlin/org/apache/jmeter/extractor/json/render/JsonRendererExtensions.kt
new file mode 100644
index 0000000..d989fa9
--- /dev/null
+++ b/src/components/src/test/kotlin/org/apache/jmeter/extractor/json/render/JsonRendererExtensions.kt
@@ -0,0 +1,24 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you 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 org.apache.jmeter.extractor.json.render
+
+import org.junit.jupiter.api.Assertions
+
+internal fun AbstractRenderAsJsonRenderer.assertJsonDataFieldEquals(expected: String?) {
+    Assertions.assertEquals(expected, jsonDataField.getText(), ".jsonDataField.text")
+}
diff --git a/src/components/src/test/kotlin/org/apache/jmeter/extractor/json/render/RenderAsJmesPathRendererTest.kt b/src/components/src/test/kotlin/org/apache/jmeter/extractor/json/render/RenderAsJmesPathRendererTest.kt
index 25eb575..b217758 100644
--- a/src/components/src/test/kotlin/org/apache/jmeter/extractor/json/render/RenderAsJmesPathRendererTest.kt
+++ b/src/components/src/test/kotlin/org/apache/jmeter/extractor/json/render/RenderAsJmesPathRendererTest.kt
@@ -17,119 +17,134 @@
 
 package org.apache.jmeter.extractor.json.render
 
+import org.apache.jmeter.junit.JMeterTestCase
+import org.apache.jmeter.samplers.SampleResult
+import org.apache.jmeter.test.gui.DisabledIfHeadless
+import org.apache.jmeter.util.JMeterUtils
+import org.intellij.lang.annotations.Language
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertNotNull
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.MethodSource
+import java.nio.charset.StandardCharsets
 import javax.swing.JTabbedPane
 
-import org.apache.jmeter.extractor.json.render.RenderAsJmesPathRenderer
-import org.apache.jmeter.junit.categories.NeedGuiTests
-import org.apache.jmeter.junit.spock.JMeterSpec
-import org.apache.jmeter.samplers.SampleResult
-import org.apache.jmeter.util.JMeterUtils
-import org.junit.experimental.categories.Category
+class RenderAsJmesPathRendererTest : JMeterTestCase() {
+    val sut = RenderAsJmesPathRenderer()
 
-import spock.lang.IgnoreIf
+    data class RenderCase(
+        @param:Language("json") val input: String,
+        @param:Language("json") val output: String
+    )
 
-class RenderAsJmesPathRendererSpec extends JMeterSpec {
-    def sut = new RenderAsJmesPathRenderer()
+    data class ExecuteCase(
+        @param:Language("json") val input: String,
+        val expression: String,
+        val output: String
+    )
 
-    def "init of component doesn't fail"() {
-        when:
-            sut.init()
-        then:
-            noExceptionThrown()
-            sut.jsonWithExtractorPanel != null;
+    companion object {
+        @JvmStatic
+        fun renderCases() = listOf(
+            RenderCase("This is not json", "This is not json"),
+            RenderCase(
+                """{name:"Ludwig",age: 23,city: "Bonn"}""",
+                """
+                {
+                    "city": "Bonn",
+                    "name": "Ludwig",
+                    "age": 23
+                }
+                """.trimIndent()
+            ),
+        )
+
+        @JvmStatic
+        fun executeCases() = listOf(
+            ExecuteCase("{\"name\":\"Ludwig\",\"age\": 23,\"city\": \"Bonn\"}", "name", "Result[0]=Ludwig\n"),
+            ExecuteCase("{\"name\":\"Ludwig\",\"age\": 23,\"city\": \"Bonn\"}", "age", "Result[0]=23\n"),
+            ExecuteCase("{\"name\":\"Ludwig\",\"age\": 23,\"city\": \"Bonn\"}", "name1", "NO MATCH"),
+        )
     }
 
-    @IgnoreIf({ JMeterSpec.isHeadless() })
-    def "render image"() {
-        given:
-            sut.init()
-            def sampleResult = new SampleResult();
-        when:
-            sut.renderImage(sampleResult)
-        then:
-            sut.jsonDataField.getText() == JMeterUtils.getResString("render_no_text")
+    @Test
+    fun `init of component doesn't fail`() {
+        sut.init()
+        assertNotNull(sut.jsonWithExtractorPanel, ".jsonWithExtractorPanel")
     }
 
-    def "render null Response"() {
-        given:
-            sut.init()
-            def sampleResult = new SampleResult();
-        when:
-            sut.renderResult(sampleResult)
-        then:
-            sut.jsonDataField.getText() == ""
+    @Test
+    @DisabledIfHeadless
+    fun `render image`() {
+        sut.init()
+        val sampleResult = SampleResult()
+        sut.renderImage(sampleResult)
+        sut.assertJsonDataFieldEquals(JMeterUtils.getResString("render_no_text"))
     }
 
-    @IgnoreIf({ JMeterSpec.isHeadless() })
-    def "render '#input' as JSON Response to '#output'"() {
-        given:
-            sut.init();
-            def sampleResult = new SampleResult();
-        when:
-            sampleResult.setResponseData(input);
-            sut.renderResult(sampleResult)
-        then:
-            output == sut.jsonDataField.getText()
-        where:
-            input               |   output
-            "This is not json"  |   "This is not json"
-            "{name:\"Ludwig\",age: 23,city: \"Bonn\"}" | '''{
-    "city": "Bonn",
-    "name": "Ludwig",
-    "age": 23
-}'''
+    @Test
+    fun `render null Response`() {
+        sut.init()
+        val sampleResult = SampleResult()
+        sut.renderResult(sampleResult)
+        sut.assertJsonDataFieldEquals("")
     }
 
-    def "execute '#expression' on '#input' results into '#output'"() {
-        given:
-            sut.init();
-            sut.expressionField.setText(expression);
-            def sampleResult = new SampleResult();
-        when:
-            sut.executeTester(input);
-        then:
-            output == sut.resultField.getText()
-        where:
-            input               | expression          | output
-            "{\"name\":\"Ludwig\",\"age\": 23,\"city\": \"Bonn\"}"   | "name"           | "Result[0]=Ludwig\n"
-            "{\"name\":\"Ludwig\",\"age\": 23,\"city\": \"Bonn\"}"   | "age"           | "Result[0]=23\n"
-            "{\"name\":\"Ludwig\",\"age\": 23,\"city\": \"Bonn\"}" | "name1" | "NO MATCH"
+    @DisabledIfHeadless
+    @ParameterizedTest
+    @MethodSource("renderCases")
+    fun `render JSON Response`(case: RenderCase) {
+        sut.init()
+        val sampleResult = SampleResult()
+        sampleResult.setResponseData(case.input, StandardCharsets.UTF_8.name())
+        sut.renderResult(sampleResult)
+
+        sut.assertJsonDataFieldEquals(case.output)
     }
 
-    def "clearData clears expected fields"() {
-        given:
-            sut.init()
-            sut.jsonDataField.setText("blabla")
-            sut.resultField.setText("blabla")
-        when:
-            sut.clearData()
-        then:
-            sut.jsonDataField.getText() == ""
-            sut.resultField.getText() == ""
+    @ParameterizedTest
+    @MethodSource("executeCases")
+    fun `execute expression`(case: ExecuteCase) {
+        sut.init()
+        sut.expressionField.text = case.expression
+        sut.executeTester(case.input)
+
+        assertEquals(case.output, sut.resultField.getText(), ".resultField.text")
     }
 
-    def "setupTabPane adds the tab to rightSide"() {
-        given:
-            sut.init()
-            def rightSideTabbedPane = new JTabbedPane();
-            sut.setRightSide(rightSideTabbedPane)
-        when:
-            sut.setupTabPane()
-        then:
-            sut.rightSide.getTabCount() == 1
-            // Investigate why it's failing
-            // sut.rightSide.getTabComponentAt(0) == sut.jsonWithExtractorPanel
+    @Test
+    fun `clearData clears expected fields`() {
+        sut.init()
+        sut.jsonDataField.setText("blabla")
+        sut.resultField.setText("blabla")
+
+        sut.clearData()
+
+        assertEquals("", sut.resultField.getText(), ".resultField.text")
+        sut.assertJsonDataFieldEquals("")
     }
 
-    def "setupTabPane called twice does not add twice the tab"() {
-        given:
-            sut.init()
-            def rightSideTabbedPane = new JTabbedPane();
-            sut.setRightSide(rightSideTabbedPane)
-            sut.setupTabPane()
-        when:
-            sut.setupTabPane()
-        then:
-            sut.rightSide.getTabCount() == 1
+    @Test
+    fun `setupTabPane adds the tab to rightSide`() {
+        sut.init()
+        val rightSideTabbedPane = JTabbedPane()
+        sut.rightSide = rightSideTabbedPane
+
+        sut.setupTabPane()
+        assertEquals(1, sut.rightSide.getTabCount(), ".rightSide.getTabCount()")
+
+        // Investigate why it's failing
+        // sut.rightSide.getTabComponentAt(0) == sut.jsonWithExtractorPanel
+    }
+
+    @Test
+    fun `setupTabPane called twice does not add twice the tab`() {
+        sut.init()
+        val rightSideTabbedPane = JTabbedPane()
+        sut.rightSide = rightSideTabbedPane
+        sut.setupTabPane()
+        sut.setupTabPane()
+        assertEquals(1, sut.rightSide.getTabCount(), ".rightSide.getTabCount()")
     }
 }
diff --git a/src/components/src/test/kotlin/org/apache/jmeter/extractor/json/render/RenderAsJsonRendererTest.kt b/src/components/src/test/kotlin/org/apache/jmeter/extractor/json/render/RenderAsJsonRendererTest.kt
index c6404aa..c046707 100644
--- a/src/components/src/test/kotlin/org/apache/jmeter/extractor/json/render/RenderAsJsonRendererTest.kt
+++ b/src/components/src/test/kotlin/org/apache/jmeter/extractor/json/render/RenderAsJsonRendererTest.kt
@@ -17,115 +17,134 @@
 
 package org.apache.jmeter.extractor.json.render
 
+import org.apache.jmeter.junit.JMeterTestCase
+import org.apache.jmeter.samplers.SampleResult
+import org.apache.jmeter.test.gui.DisabledIfHeadless
+import org.apache.jmeter.util.JMeterUtils
+import org.intellij.lang.annotations.Language
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertNotNull
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.MethodSource
+import java.nio.charset.StandardCharsets
 import javax.swing.JTabbedPane
 
-import org.apache.jmeter.junit.spock.JMeterSpec
-import org.apache.jmeter.samplers.SampleResult
-import org.apache.jmeter.util.JMeterUtils
+class RenderAsJsonRendererTest : JMeterTestCase() {
+    val sut = RenderAsJsonRenderer()
 
-import spock.lang.IgnoreIf
+    data class RenderCase(
+        @param:Language("json") val input: String,
+        @param:Language("json") val output: String
+    )
 
-class RenderAsJsonRendererSpec extends JMeterSpec {
-    def sut = new RenderAsJsonRenderer()
+    data class ExecuteCase(
+        @param:Language("json") val input: String,
+        @param:Language("jsonpath") val expression: String,
+        val output: String
+    )
 
-    def "init of component doesn't fail"() {
-        when:
-            sut.init()
-        then:
-            noExceptionThrown()
-            sut.jsonWithExtractorPanel != null
+    companion object {
+        @JvmStatic
+        fun renderCases() = listOf(
+            RenderCase("This is not json", "This is not json"),
+            RenderCase(
+                """{name:"Ludwig",age: 23,city: "Bonn"}""",
+                """
+                {
+                    "city": "Bonn",
+                    "name": "Ludwig",
+                    "age": 23
+                }
+                """.trimIndent()
+            ),
+        )
+
+        @JvmStatic
+        fun executeCases() = listOf(
+            ExecuteCase("{\"name\":\"Ludwig\",\"age\": 23,\"city\": \"Bonn\"}", "$..name", "Result[0]=Ludwig\n"),
+            ExecuteCase("This is not json", "$..name", "NO MATCH"),
+            ExecuteCase(
+                "{\"name\":\"Ludwig\",\"age\": 23,\"city\": \"Bonn\"}",
+                "$..",
+                "Exception: Path must not end with a '.' or '..'"
+            ),
+        )
     }
 
-    @IgnoreIf({ JMeterSpec.isHeadless() })
-    def "render image"() {
-        given:
-            sut.init()
-            def sampleResult = new SampleResult()
-        when:
-            sut.renderImage(sampleResult)
-        then:
-            sut.jsonDataField.getText() == JMeterUtils.getResString("render_no_text")
+    @Test
+    fun `init of component doesn't fail`() {
+        sut.init()
+        assertNotNull(sut.jsonWithExtractorPanel, "jsonWithExtractorPanel")
     }
 
-    def "render null Response"() {
-        given:
-            sut.init()
-            def sampleResult = new SampleResult()
-        when:
-            sut.renderResult(sampleResult)
-        then:
-            sut.jsonDataField.getText() == ""
+    @Test
+    @DisabledIfHeadless
+    fun `render image`() {
+        sut.init()
+        val sampleResult = SampleResult()
+        sut.renderImage(sampleResult)
+        sut.assertJsonDataFieldEquals(JMeterUtils.getResString("render_no_text"))
     }
 
-    @IgnoreIf({ JMeterSpec.isHeadless() })
-    def "render '#input' as JSON Response to '#output'"() {
-        given:
-            sut.init()
-            def sampleResult = new SampleResult()
-            sampleResult.setResponseData(input)
-        when:
-            sut.renderResult(sampleResult)
-        then:
-            output == sut.jsonDataField.getText()
-        where:
-            input                                      | output
-            "This is not json"                         | "This is not json"
-            "{name:\"Ludwig\",age: 23,city: \"Bonn\"}" | '''{
-    "city": "Bonn",
-    "name": "Ludwig",
-    "age": 23
-}'''
+    @Test
+    fun `render null Response`() {
+        sut.init()
+        val sampleResult = SampleResult()
+        sut.renderResult(sampleResult)
+        sut.assertJsonDataFieldEquals("")
     }
 
-    def "execute '#expression' on '#input' results into '#output'"() {
-        given:
-            sut.init()
-            sut.expressionField.setText(expression)
-        when:
-            sut.executeTester(input)
-        then:
-            output == sut.resultField.getText()
-        where:
-            input                                      | expression | output
-            "{name:\"Ludwig\",age: 23,city: \"Bonn\"}" | "\$..name" | "Result[0]=Ludwig\n"
-            "This is not json"                         | "\$..name" | "NO MATCH"
-            "{name:\"Ludwig\",age: 23,city: \"Bonn\"}" | "\$.."     | "Exception: Path must not end with a '.' or '..'"
+    @DisabledIfHeadless
+    @ParameterizedTest
+    @MethodSource("renderCases")
+    fun `render JSON Response`(case: RenderCase) {
+        sut.init()
+        val sampleResult = SampleResult()
+        sampleResult.setResponseData(case.input, StandardCharsets.UTF_8.name())
+        sut.renderResult(sampleResult)
+
+        sut.assertJsonDataFieldEquals(case.output)
     }
 
-    def "clearData clears expected fields"() {
-        given:
-            sut.init()
-            sut.jsonDataField.setText("blabla")
-            sut.resultField.setText("blabla")
-        when:
-            sut.clearData()
-        then:
-            sut.jsonDataField.getText() == ""
-            sut.resultField.getText() == ""
+    @ParameterizedTest
+    @MethodSource("executeCases")
+    fun `execute expression`(case: ExecuteCase) {
+        sut.init()
+        sut.expressionField.text = case.expression
+        sut.executeTester(case.input)
+
+        assertEquals(case.output, sut.resultField.getText(), ".resultField.text")
     }
 
-    def "setupTabPane adds the tab to rightSide"() {
-        given:
-            sut.init()
-            def rightSideTabbedPane = new JTabbedPane()
-            sut.setRightSide(rightSideTabbedPane)
-        when:
-            sut.setupTabPane()
-        then:
-            sut.rightSide.getTabCount() == 1
-            // Investigate why it's failing
-            // sut.rightSide.getTabComponentAt(0) == sut.jsonWithExtractorPanel
+    @Test
+    fun `clearData clears expected fields`() {
+        sut.init()
+        sut.jsonDataField.text = "blabla"
+        sut.resultField.text = "blabla"
+        sut.clearData()
+        assertEquals("", sut.resultField.getText(), ".resultField.text")
+        sut.assertJsonDataFieldEquals("")
     }
 
-    def "setupTabPane called twice does not add twice the tab"() {
-        given:
-            sut.init()
-            def rightSideTabbedPane = new JTabbedPane()
-            sut.setRightSide(rightSideTabbedPane)
-            sut.setupTabPane()
-        when:
-            sut.setupTabPane()
-        then:
-            sut.rightSide.getTabCount() == 1
+    @Test
+    fun `setupTabPane adds the tab to rightSide`() {
+        sut.init()
+        val rightSideTabbedPane = JTabbedPane()
+        sut.rightSide = rightSideTabbedPane
+        sut.setupTabPane()
+        assertEquals(1, sut.rightSide.tabCount, ".rightSide.getTabCount()")
+        // Investigate why it's failing
+        // sut.rightSide.getTabComponentAt(0) == sut.jsonWithExtractorPanel
+    }
+
+    @Test
+    fun `setupTabPane called twice does not add twice the tab`() {
+        sut.init()
+        val rightSideTabbedPane = JTabbedPane()
+        sut.rightSide = rightSideTabbedPane
+        sut.setupTabPane()
+        sut.setupTabPane()
+        assertEquals(1, sut.rightSide.tabCount, ".rightSide.getTabCount()")
     }
 }
diff --git a/src/components/src/test/kotlin/org/apache/jmeter/timers/UniformRandomTimerTest.kt b/src/components/src/test/kotlin/org/apache/jmeter/timers/UniformRandomTimerTest.kt
index dae6286..f78afd6 100644
--- a/src/components/src/test/kotlin/org/apache/jmeter/timers/UniformRandomTimerTest.kt
+++ b/src/components/src/test/kotlin/org/apache/jmeter/timers/UniformRandomTimerTest.kt
@@ -17,50 +17,52 @@
 
 package org.apache.jmeter.timers
 
-import spock.lang.Specification
-import spock.lang.Unroll
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.fail
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.MethodSource
 
-class UniformRandomTimerSpec extends Specification {
+class UniformRandomTimerTest {
+    data class RangeCase(val delay: String, val range: Double, val min: Long, val max: Long)
 
-    def sut = new UniformRandomTimer()
+    val sut = UniformRandomTimer()
 
-    def "default delay is 0"() {
-        when:
-            def computedDelay = sut.delay()
-        then:
-            computedDelay == 0L
+    companion object {
+        @JvmStatic
+        fun rangeCases() = listOf(
+            RangeCase("1", 10.5, 1, 11),
+            RangeCase("1", 0.1, 1, 1),
+            RangeCase("0", -50.0, 0, 50),
+        )
     }
 
-    def "default range is 0"() {
-        when:
-            def actualRange = sut.range
-        then:
-            actualRange == 0L
+    @Test
+    fun `default delay is 0`() {
+        assertEquals(0L, sut.delay(), ".delay()")
     }
 
-    def "delay can be set via a String"() {
-        given:
-            sut.setDelay("1")
-        when:
-            def computedDelay = sut.delay()
-        then:
-            computedDelay == 1L
+    @Test
+    fun `default range is 0`() {
+        assertEquals(0.0, sut.range, ".range")
     }
 
-    @Unroll
-    def "#delay <= computedDelay <= trunc(#delay + abs(#range))"() {
-        given:
-            sut.setDelay(delay)
-            sut.setRange(range)
-        when:
-            def computedDelay = sut.delay()
-        then:
-            min <= computedDelay
-            computedDelay <= max
-        where:
-            delay | range | min | max
-            "1"   | 10.5  | 1   | 11
-            "1"   | 0.1   | 1   | 1
-            "0"   | -50.0 | 0   | 50
+    @Test
+    fun `delay can be set via a String`() {
+        sut.delay = "1"
+        assertEquals(1L, sut.delay(), ".delay()")
+    }
+
+    @ParameterizedTest
+    @MethodSource("rangeCases")
+    fun `computed delay should be within range`(case: RangeCase) {
+        sut.delay = case.delay
+        sut.range = case.range
+
+        val computedDelay = sut.delay()
+
+        if (computedDelay < case.min || computedDelay > case.max) {
+            fail("computed delay $computedDelay should be within [${case.min}..${case.max}]")
+        }
     }
 }
diff --git a/src/components/src/test/kotlin/org/apache/jmeter/visualizers/backend/graphite/PickleGraphiteMetricsSenderTest.kt b/src/components/src/test/kotlin/org/apache/jmeter/visualizers/backend/graphite/PickleGraphiteMetricsSenderTest.kt
index 1e6a628..af9a46f 100644
--- a/src/components/src/test/kotlin/org/apache/jmeter/visualizers/backend/graphite/PickleGraphiteMetricsSenderTest.kt
+++ b/src/components/src/test/kotlin/org/apache/jmeter/visualizers/backend/graphite/PickleGraphiteMetricsSenderTest.kt
@@ -17,106 +17,109 @@
 
 package org.apache.jmeter.visualizers.backend.graphite
 
+import io.mockk.mockk
+import io.mockk.verify
+import org.apache.commons.pool2.impl.GenericKeyedObjectPool
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
 import java.time.Instant
 
-import org.apache.commons.pool2.impl.GenericKeyedObjectPool
+class PickleGraphiteMetricsSenderTest {
 
-import spock.lang.Specification
+    private val sut = PickleGraphiteMetricsSender()
 
-class PickleGraphiteMetricsSenderSpec extends Specification {
-
-    def sut = new PickleGraphiteMetricsSender()
-
-    def "new sender has no metrics"() {
-        expect:
-            sut.metrics.isEmpty()
+    private fun assertMetricsIsEmpty() {
+        assertTrue(sut.metrics.isEmpty(), ".metrics.isEmpty()")
     }
 
-    def "adding metric to sender creates correct MetricTuple"() {
-        given:
-            def expectedName = "prefix-contextName.metricName"
-            def expectedTS = 1000000
-            def expectedVal = "value"
-
-            sut.setup("host", 1024, "prefix-")
-        when:
-            sut.addMetric(expectedTS, "contextName", "metricName", expectedVal)
-        then:
-            def actualMetrics = sut.metrics
-            actualMetrics.size() == 1
-            def actualMetric = actualMetrics.get(0)
-            actualMetric.name == expectedName
-            actualMetric.timestamp == expectedTS
-            actualMetric.value == expectedVal
+    @Test
+    fun `new sender has no metrics`() {
+        assertMetricsIsEmpty()
     }
 
-    def "writeAndSendMetrics does not attempt connection if there's nothing to send"() {
-        given:
-            sut.setup("non-existant-host", 1024, "prefix-")
-        when:
-            sut.writeAndSendMetrics()
-        then:
-            sut.metrics.isEmpty()
-            noExceptionThrown()
+    @Test
+    fun `adding metric to sender creates correct MetricTuple`() {
+        val expectedTS = 1000000L
+        val expectedVal = "value"
+
+        sut.setup("host", 1024, "prefix-")
+
+        sut.addMetric(expectedTS, "contextName", "metricName", expectedVal)
+
+        assertEquals(
+            "MetricTuple(name=prefix-contextName.metricName, timestamp=1000000, value=value)",
+            sut.metrics.joinToString { "MetricTuple(name=${it.name}, timestamp=${it.timestamp}, value=${it.value})" },
+            ".metrics"
+        )
     }
 
-    def "writeAndSendMetrics connects and sends if there's something to send, dropping metrics on connection failure, without throwing exceptions"() {
-        given:
-            SocketConnectionInfos socketConnInfoMock = Mock()
-            GenericKeyedObjectPool<SocketConnectionInfos, SocketOutputStream> objectPoolStub = Mock()
-            sut.setup(socketConnInfoMock, objectPoolStub, "prefix-")
-            sut.addMetric(1, "contextName", "metricName", "val")
-        when:
-            sut.writeAndSendMetrics()
-        then:
-            1 * objectPoolStub.borrowObject(socketConnInfoMock)
-            sut.metrics.isEmpty()
-            noExceptionThrown()
+    @Test
+    fun `writeAndSendMetrics does not attempt connection if there's nothing to send`() {
+        sut.setup("non-existant-host", 1024, "prefix-")
+        sut.writeAndSendMetrics()
+        assertMetricsIsEmpty()
     }
 
-    def "destroy closes outputStreamPool"() {
-        given:
-            GenericKeyedObjectPool<SocketConnectionInfos, SocketOutputStream> objectPoolStub = Mock()
-            sut.setup(Mock(SocketConnectionInfos), objectPoolStub, "prefix-")
-            sut.addMetric(1, "contextName", "metricName", "val")
-        when:
-            sut.destroy()
-        then:
-            1 * objectPoolStub.close()
-            // TODO: should destroy also set metrics to null or are we relying on the original reference to be removed after destroy is called?
-            sut.metrics.size() == 1
-            noExceptionThrown()
+    @Test
+    fun `writeAndSendMetrics connects and sends if there's something to send, dropping metrics on connection failure, without throwing exceptions`() {
+        val socketConnInfoMock = mockk<SocketConnectionInfos>()
+        val objectPoolStub = mockk<GenericKeyedObjectPool<SocketConnectionInfos, SocketOutputStream>>()
+        sut.setup(socketConnInfoMock, objectPoolStub, "prefix-")
+        sut.addMetric(1, "contextName", "metricName", "val")
+
+        sut.writeAndSendMetrics()
+        verify(exactly = 1) { objectPoolStub.borrowObject(socketConnInfoMock) }
+        assertMetricsIsEmpty()
     }
 
-    def static newMetric(String name, long timestamp, String value) {
-        return new GraphiteMetricsSender.MetricTuple(name, timestamp, value)
+    @Test
+    fun `destroy closes outputStreamPool`() {
+        val objectPoolStub = mockk<GenericKeyedObjectPool<SocketConnectionInfos, SocketOutputStream>>(relaxed = true)
+        sut.setup(mockk<SocketConnectionInfos>(), objectPoolStub, "prefix-")
+        sut.addMetric(1, "contextName", "metricName", "val")
+
+        sut.destroy()
+
+        verify(exactly = 1) { objectPoolStub.close() }
+        // TODO: should destroy also set metrics to null or are we relying on the original reference to be removed after destroy is called?
+        assertEquals(1, sut.metrics.size, ".metrics.size")
     }
 
-    def "convertMetricsToPickleFormat produces expected result for one metric"() {
-        given:
-            def name = "name"
-            def timeStamp = Instant.now().getEpochSecond()
-            def value = "value-1.23"
-            def metric = newMetric(name, timeStamp, value)
-            def metrics = Collections.singletonList(metric)
-        when:
-            def result = sut.convertMetricsToPickleFormat(metrics)
-        then:
-            result == "(l(S'${name}'\n(L${timeStamp}L\nS'${value}'\ntta."
+    private fun newMetric(name: String, timestamp: Long, value: String) =
+        GraphiteMetricsSender.MetricTuple(name, timestamp, value)
+
+    @Test
+    fun `convertMetricsToPickleFormat produces expected result for one metric`() {
+        val name = "name"
+        val timeStamp = Instant.now().getEpochSecond()
+        val value = "value-1.23"
+        val metric = newMetric(name, timeStamp, value)
+        val metrics = listOf(metric)
+
+        val result = PickleGraphiteMetricsSender.convertMetricsToPickleFormat(metrics)
+
+        assertEquals(
+            "(l(S'$name'\n(L${timeStamp}L\nS'$value'\ntta.",
+            result,
+        )
     }
 
-    def "convertMetricsToPickleFormat produces expected result for multiple metrics"() {
-        given:
-            def name = "name"
-            def timeStamp = Instant.now().getEpochSecond()
-            def value = "value-1.23"
-            def metric = newMetric(name, timeStamp, value)
-            def metrics = Arrays.asList(metric, metric)
-        when:
-            def result = sut.convertMetricsToPickleFormat(metrics)
-        then:
-            result == "(l" +
-                    "(S'${name}'\n(L${timeStamp}L\nS'${value}'\ntta" +
-                    "(S'${name}'\n(L${timeStamp}L\nS'${value}'\ntta."
+    @Test
+    fun `convertMetricsToPickleFormat produces expected result for multiple metrics`() {
+        val name = "name"
+        val timeStamp = Instant.now().getEpochSecond()
+        val value = "value-1.23"
+        val metric = newMetric(name, timeStamp, value)
+        val metrics = listOf(metric, metric)
+
+        val result = PickleGraphiteMetricsSender.convertMetricsToPickleFormat(metrics)
+
+        assertEquals(
+            "(l" +
+                "(S'$name'\n(L${timeStamp}L\nS'$value'\ntta" +
+                "(S'$name'\n(L${timeStamp}L\nS'$value'\ntta.",
+            result,
+        )
     }
 }
diff --git a/src/components/src/test/kotlin/org/apache/jmeter/visualizers/backend/graphite/TextGraphiteMetricsSenderTest.kt b/src/components/src/test/kotlin/org/apache/jmeter/visualizers/backend/graphite/TextGraphiteMetricsSenderTest.kt
index 067e3d1..2112f08 100644
--- a/src/components/src/test/kotlin/org/apache/jmeter/visualizers/backend/graphite/TextGraphiteMetricsSenderTest.kt
+++ b/src/components/src/test/kotlin/org/apache/jmeter/visualizers/backend/graphite/TextGraphiteMetricsSenderTest.kt
@@ -17,73 +17,66 @@
 
 package org.apache.jmeter.visualizers.backend.graphite
 
+import io.mockk.mockk
+import io.mockk.verify
 import org.apache.commons.pool2.impl.GenericKeyedObjectPool
+import org.junit.jupiter.api.Assertions
+import org.junit.jupiter.api.Test
 
-import spock.lang.Specification
+class TextGraphiteMetricsSenderTest {
 
-class TextGraphiteMetricsSenderSpec extends Specification {
+    private val sut = TextGraphiteMetricsSender()
 
-    def sut = new TextGraphiteMetricsSender()
-
-    def "new sender has no metrics"() {
-        expect:
-            sut.metrics.isEmpty()
+    private fun assertMetricsIsEmpty() {
+        Assertions.assertTrue(sut.metrics.isEmpty(), ".metrics.isEmpty()")
     }
 
-    def "adding metric to sender creates correct MetricTuple"() {
-        given:
-            def expectedName = "prefix-contextName.metricName"
-            def expectedTS = 1000000
-            def expectedVal = "value"
-
-            sut.setup("host", 1024, "prefix-")
-        when:
-            sut.addMetric(expectedTS, "contextName", "metricName", expectedVal)
-        then:
-            def actualMetrics = sut.metrics
-            actualMetrics.size() == 1
-            def actualMetric = actualMetrics.get(0)
-            actualMetric.name == expectedName
-            actualMetric.timestamp == expectedTS
-            actualMetric.value == expectedVal
+    @Test
+    fun `new sender has no metrics`() {
+        assertMetricsIsEmpty()
     }
 
-    def "writeAndSendMetrics does not attempt connection if there's nothing to send"() {
-        given:
-            sut.setup("non-existant-host", 1024, "prefix-")
-        when:
-            sut.writeAndSendMetrics()
-        then:
-            sut.metrics.isEmpty()
-            noExceptionThrown()
+    @Test
+    fun `adding metric to sender creates correct MetricTuple`() {
+        val expectedTS = 1000000L
+        val expectedVal = "value"
+
+        sut.setup("host", 1024, "prefix-")
+        sut.addMetric(expectedTS, "contextName", "metricName", expectedVal)
+
+        Assertions.assertEquals(
+            "MetricTuple(name=prefix-contextName.metricName, timestamp=1000000, value=value)",
+            sut.metrics.joinToString { "MetricTuple(name=${it.name}, timestamp=${it.timestamp}, value=${it.value})" },
+            ".metrics"
+        )
     }
 
-    def "writeAndSendMetrics connects and sends if there's something to send, dropping metrics on connection failure, without throwing exceptions"() {
-        given:
-            SocketConnectionInfos socketConnInfoMock = Mock()
-            GenericKeyedObjectPool<SocketConnectionInfos, SocketOutputStream> objectPoolStub = Mock()
-            sut.setup(socketConnInfoMock, objectPoolStub, "prefix-")
-            sut.addMetric(1, "contextName", "metricName", "val")
-        when:
-            sut.writeAndSendMetrics()
-        then:
-            1 *  objectPoolStub.borrowObject(socketConnInfoMock)
-            sut.metrics.isEmpty()
-            noExceptionThrown()
+    @Test
+    fun `writeAndSendMetrics does not attempt connection if there's nothing to send`() {
+        sut.setup("non-existant-host", 1024, "prefix-")
+        sut.writeAndSendMetrics()
+        assertMetricsIsEmpty()
     }
 
-    def "destroy closes outputStreamPool"() {
-        given:
-            GenericKeyedObjectPool<SocketConnectionInfos, SocketOutputStream> objectPoolStub = Mock()
-            sut.setup(Mock(SocketConnectionInfos), objectPoolStub, "prefix-")
-            sut.addMetric(1, "contextName", "metricName", "val")
-        when:
-            sut.destroy()
-        then:
-            1 *  objectPoolStub.close()
-            // TODO: should destroy also set metrics to null or are we relying on the original reference to be removed after destroy is called?
-            sut.metrics.size() == 1
-            noExceptionThrown()
+    @Test
+    fun `writeAndSendMetrics connects and sends if there's something to send, dropping metrics on connection failure, without throwing exceptions`() {
+        val socketConnInfoMock = mockk<SocketConnectionInfos>()
+        val objectPoolStub = mockk<GenericKeyedObjectPool<SocketConnectionInfos, SocketOutputStream>>()
+        sut.setup(socketConnInfoMock, objectPoolStub, "prefix-")
+        sut.addMetric(1, "contextName", "metricName", "val")
+        sut.writeAndSendMetrics()
+        verify(exactly = 1) { objectPoolStub.borrowObject(socketConnInfoMock) }
+        assertMetricsIsEmpty()
     }
 
+    @Test
+    fun `destroy closes outputStreamPool`() {
+        val objectPoolStub = mockk<GenericKeyedObjectPool<SocketConnectionInfos, SocketOutputStream>>(relaxed = true)
+        sut.setup(mockk<SocketConnectionInfos>(), objectPoolStub, "prefix-")
+        sut.addMetric(1, "contextName", "metricName", "val")
+
+        sut.destroy()
+
+        verify(exactly = 1) { objectPoolStub.close() }
+    }
 }
diff --git a/src/components/src/test/kotlin/org/apache/jmeter/visualizers/backend/influxdb/InfluxDBRawBackendListenerClientTest.kt b/src/components/src/test/kotlin/org/apache/jmeter/visualizers/backend/influxdb/InfluxDBRawBackendListenerClientTest.kt
index 5390a25..283b21a 100644
--- a/src/components/src/test/kotlin/org/apache/jmeter/visualizers/backend/influxdb/InfluxDBRawBackendListenerClientTest.kt
+++ b/src/components/src/test/kotlin/org/apache/jmeter/visualizers/backend/influxdb/InfluxDBRawBackendListenerClientTest.kt
@@ -17,83 +17,102 @@
 
 package org.apache.jmeter.visualizers.backend.influxdb
 
+import io.mockk.mockk
+import io.mockk.verify
+import io.mockk.verifyOrder
 import org.apache.jmeter.samplers.SampleResult
 import org.apache.jmeter.visualizers.backend.BackendListenerContext
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.fail
 
-import spock.lang.Specification
+class InfluxDBRawBackendListenerClientTest {
 
-class InfluxDBRawBackendListenerClientSpec extends Specification {
+    val sut = InfluxDBRawBackendListenerClient()
+    private val defaultContext = BackendListenerContext(sut.getDefaultParameters())
 
-    def sut = new InfluxDBRawBackendListenerClient()
-    def defaultContext = new BackendListenerContext(sut.getDefaultParameters())
-
-    def createOkSample() {
-        def t = 1600123456789
-        def okSample = SampleResult.createTestSample(t - 100, t)
-        okSample.setLatency(42)
-        okSample.setConnectTime(7)
-        okSample.setSampleLabel("myLabel")
-        okSample.setThreadName("myThread")
-        okSample.setResponseOK()
-        return okSample
+    private fun createOkSample(): SampleResult {
+        val t = 1600123456789
+        return SampleResult.createTestSample(t - 100, t).apply {
+            latency = 42
+            connectTime = 7
+            sampleLabel = "myLabel"
+            threadName = "myThread"
+            setResponseOK()
+        }
     }
 
-    def "Default parameters contain minimum required options"() {
-        expect:
-            sut.getDefaultParameters()
-                    .getArgumentsAsMap()
-                    .keySet()
-                    .containsAll([
-                            "influxdbMetricsSender", "influxdbUrl",
-                            "influxdbToken", "measurement"])
+    @Test
+    fun `Default parameters contain minimum required options`() {
+        val actualKeys = sut.getDefaultParameters()
+            .getArgumentsAsMap()
+            .keys
+        val expectedKeys = setOf(
+            "influxdbMetricsSender", "influxdbUrl",
+            "influxdbToken", "measurement"
+        )
+        if (!actualKeys.containsAll(expectedKeys)) {
+            fail("Default arguments $actualKeys should include all keys of $expectedKeys")
+        }
     }
 
-    def "Provided args are used during setup"() {
-        when:
-            sut.setupTest(defaultContext)
-        then:
-            sut.measurement == sut.DEFAULT_MEASUREMENT
-            sut.influxDBMetricsManager.class.isAssignableFrom(HttpMetricsSender.class)
+    @Test
+    fun `Provided args are used during setup`() {
+        sut.setupTest(defaultContext)
+        assertEquals("jmeter", sut.measurement, ".measurement")
+        assertEquals(
+            HttpMetricsSender::class.java,
+            sut.influxDBMetricsManager::class.java,
+            ".influxDBMetricsManager.class"
+        )
     }
 
-    def "OK sample data is mapped correctly to InfluxDB tags and fields"() {
-        given:
-            def okSample = createOkSample()
-        when:
-            def tags = sut.createTags(okSample)
-            def fields = sut.createFields(okSample)
-        then:
-            tags == "status=ok,transaction=myLabel,threadName=myThread"
-            fields == "duration=100,ttfb=42,connectTime=7"
+    @Test
+    fun `OK sample data is mapped correctly to InfluxDB tags and fields`() {
+        val okSample = createOkSample()
+        assertEquals(
+            "status=ok,transaction=myLabel,threadName=myThread",
+            InfluxDBRawBackendListenerClient.createTags(okSample),
+            "createTags($okSample)"
+        )
+        assertEquals(
+            "duration=100,ttfb=42,connectTime=7",
+            InfluxDBRawBackendListenerClient.createFields(okSample),
+            "createFields($okSample)"
+        )
     }
 
-    def "Failed sample data is mapped correctly to InfluxDB tags and fields"() {
-        given:
-            def koSample = new SampleResult()
-            koSample.setSampleLabel("myLabel")
-            koSample.setThreadName("myThread")
-        expect:
-            sut.createTags(koSample) == "status=ko,transaction=myLabel,threadName=myThread"
+    @Test
+    fun `Failed sample data is mapped correctly to InfluxDB tags and fields`() {
+        val koSample = SampleResult().apply {
+            sampleLabel = "myLabel"
+            threadName = "myThread"
+        }
+        assertEquals(
+            "status=ko,transaction=myLabel,threadName=myThread",
+            InfluxDBRawBackendListenerClient.createTags(koSample),
+            "createTags($koSample)"
+        )
     }
 
-    def "Upon handling sample result data is added to influxDBMetricsManager and written"() {
-        given:
-            def mockSender = Mock(InfluxdbMetricsSender)
-            def sut = new InfluxDBRawBackendListenerClient(mockSender)
-        when:
-            sut.handleSampleResults([createOkSample()], defaultContext)
-        then:
-            1 * mockSender.addMetric(_, _, _, _)
-            1 * mockSender.writeAndSendMetrics()
+    @Test
+    fun `Upon handling sample result data is added to influxDBMetricsManager and written`() {
+        val mockSender = mockk<InfluxdbMetricsSender>(relaxed = true)
+        val sut = InfluxDBRawBackendListenerClient(mockSender)
+        sut.handleSampleResults(listOf(createOkSample()), defaultContext)
+        verifyOrder {
+            mockSender.addMetric(any(), any(), any(), any())
+            mockSender.writeAndSendMetrics()
+        }
     }
 
-    def "teardownTest calls destroy on influxDBMetricsManager"() {
-        given:
-            def mockSender = Mock(InfluxdbMetricsSender)
-            def sut = new InfluxDBRawBackendListenerClient(mockSender)
-        when:
-            sut.teardownTest()
-        then:
-            1 * mockSender.destroy()
+    @Test
+    fun `teardownTest calls destroy on influxDBMetricsManager`() {
+        val mockSender = mockk<InfluxdbMetricsSender>(relaxed = true)
+        val sut = InfluxDBRawBackendListenerClient(mockSender)
+        sut.teardownTest(defaultContext)
+        verify(exactly = 1) {
+            mockSender.destroy()
+        }
     }
 }
diff --git a/src/components/src/test/kotlin/org/apache/jmeter/visualizers/backend/influxdb/InfluxdbBackendListenerClientTest.kt b/src/components/src/test/kotlin/org/apache/jmeter/visualizers/backend/influxdb/InfluxdbBackendListenerClientTest.kt
index bb78784..03b5b53 100644
--- a/src/components/src/test/kotlin/org/apache/jmeter/visualizers/backend/influxdb/InfluxdbBackendListenerClientTest.kt
+++ b/src/components/src/test/kotlin/org/apache/jmeter/visualizers/backend/influxdb/InfluxdbBackendListenerClientTest.kt
@@ -18,32 +18,40 @@
 package org.apache.jmeter.visualizers.backend.influxdb
 
 import org.apache.jmeter.visualizers.backend.BackendListenerContext
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
 
-import spock.lang.Specification
+class InfluxdbBackendListenerClientTest {
 
-class InfluxdbBackendListenerClientSpec extends Specification {
+    val sut = InfluxdbBackendListenerClient()
+    private val defaultContext = BackendListenerContext(sut.getDefaultParameters())
 
-    def sut = new InfluxdbBackendListenerClient()
-    def defaultContext = new BackendListenerContext(sut.getDefaultParameters())
-
-    def "setupTest with default config does not raise an exception"() {
-        when:
-            sut.setupTest(defaultContext)
-        then:
-            noExceptionThrown()
+    @Test
+    fun `setupTest with default config does not raise an exception`() {
+        sut.setupTest(defaultContext)
     }
 
-    def "Sending metrics when empty does not raise an exception"() {
-        given:
-            sut.setupTest(defaultContext)
-        when:
-            sut.run()
-        then:
-            noExceptionThrown()
+    @Test
+    fun `Sending metrics when empty does not raise an exception`() {
+        sut.setupTest(defaultContext)
+        sut.run()
     }
 
-    def "Default parameters are equal to default args"() {
-        expect:
-            sut.getDefaultParameters().getArgumentsAsMap() == InfluxdbBackendListenerClient.DEFAULT_ARGS
+    @Test
+    fun `Default parameters are equal to default args`() {
+        assertEquals(
+            mapOf(
+                "influxdbMetricsSender" to "org.apache.jmeter.visualizers.backend.influxdb.HttpMetricsSender",
+                "influxdbUrl" to "http://host_to_change:8086/write?db=jmeter",
+                "application" to "application name",
+                "measurement" to "jmeter",
+                "summaryOnly" to "false",
+                "samplersRegex" to ".*",
+                "percentiles" to "99;95;90",
+                "testTitle" to "Test name",
+                "eventTags" to ""
+            ),
+            sut.getDefaultParameters().getArgumentsAsMap()
+        )
     }
 }