From 8f75d23ad6531d26682087893c8ecc2683999002 Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Wed, 8 Jul 2026 00:12:22 -0400 Subject: [PATCH] Add testing support option to introduce latency --- gradle/publish-root-config.gradle | 1 + .../src/en/guide/introduction/whatsNew.adoc | 9 ++ .../en/guide/testing/functionalTesting.adoc | 66 ++++++++++++ grails-test-examples/latency/build.gradle | 48 +++++++++ .../latency/grails-app/conf/application.yml | 47 ++++++++ .../latency/grails-app/conf/logback.xml | 31 ++++++ .../latencyapp/FastController.groovy | 34 ++++++ .../latencyapp/SlowController.groovy | 34 ++++++ .../controllers/latencyapp/UrlMappings.groovy | 30 ++++++ .../init/latencyapp/Application.groovy | 30 ++++++ .../latencyapp/LatencyFunctionalSpec.groovy | 62 +++++++++++ grails-testing-support-latency/build.gradle | 69 ++++++++++++ .../latency/LatencyAutoConfiguration.groovy | 56 ++++++++++ .../testing/latency/LatencyFilter.groovy | 93 ++++++++++++++++ .../testing/latency/LatencyProperties.groovy | 80 ++++++++++++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../LatencyAutoConfigurationSpec.groovy | 63 +++++++++++ .../testing/latency/LatencyFilterSpec.groovy | 102 ++++++++++++++++++ settings.gradle | 3 + 19 files changed, 859 insertions(+) create mode 100644 grails-test-examples/latency/build.gradle create mode 100644 grails-test-examples/latency/grails-app/conf/application.yml create mode 100644 grails-test-examples/latency/grails-app/conf/logback.xml create mode 100644 grails-test-examples/latency/grails-app/controllers/latencyapp/FastController.groovy create mode 100644 grails-test-examples/latency/grails-app/controllers/latencyapp/SlowController.groovy create mode 100644 grails-test-examples/latency/grails-app/controllers/latencyapp/UrlMappings.groovy create mode 100644 grails-test-examples/latency/grails-app/init/latencyapp/Application.groovy create mode 100644 grails-test-examples/latency/src/integration-test/groovy/latencyapp/LatencyFunctionalSpec.groovy create mode 100644 grails-testing-support-latency/build.gradle create mode 100644 grails-testing-support-latency/src/main/groovy/org/apache/grails/testing/latency/LatencyAutoConfiguration.groovy create mode 100644 grails-testing-support-latency/src/main/groovy/org/apache/grails/testing/latency/LatencyFilter.groovy create mode 100644 grails-testing-support-latency/src/main/groovy/org/apache/grails/testing/latency/LatencyProperties.groovy create mode 100644 grails-testing-support-latency/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 grails-testing-support-latency/src/test/groovy/org/apache/grails/testing/latency/LatencyAutoConfigurationSpec.groovy create mode 100644 grails-testing-support-latency/src/test/groovy/org/apache/grails/testing/latency/LatencyFilterSpec.groovy diff --git a/gradle/publish-root-config.gradle b/gradle/publish-root-config.gradle index 30aa6c4aa2d..2d49bca0ff5 100644 --- a/gradle/publish-root-config.gradle +++ b/gradle/publish-root-config.gradle @@ -98,6 +98,7 @@ def publishedProjects = [ 'grails-testing-support-dbcleanup-h2', 'grails-testing-support-dbcleanup-postgresql', 'grails-testing-support-http-client', + 'grails-testing-support-latency', 'grails-testing-support-mongodb', 'grails-testing-support-views-gson', 'grails-testing-support-web', diff --git a/grails-doc/src/en/guide/introduction/whatsNew.adoc b/grails-doc/src/en/guide/introduction/whatsNew.adoc index 482b962915d..d397f64817e 100644 --- a/grails-doc/src/en/guide/introduction/whatsNew.adoc +++ b/grails-doc/src/en/guide/introduction/whatsNew.adoc @@ -105,6 +105,15 @@ Integration tests that implement `HttpClientSupport` can now use `httpPostForm(. request bodies. The helper URL-encodes form fields using UTF-8, and repeated values in a `Collection` or array are encoded as repeated keys in insertion order. +==== Latency Testing Support + +The new `grails-testing-support-latency` module injects artificial latency into the application under test, so that +requests randomly take longer. Slower responses widen the window in which timing bugs occur, turning intermittently +flaky functional tests — assertions that race a navigation, missing waits, ordering assumptions — into deterministic +failures. The module is inert until `grails.testing.latency.enabled` is set to `true`, and the delay range, the +fraction of requests affected, and the url patterns are all configurable. See the +xref:testing/functionalTesting[Functional Testing] section for details. + ==== GORM for MongoDB: TTL, Text, and Reconciled Indexes GORM for MongoDB can now declare a https://www.mongodb.com/docs/manual/core/index-ttl/[TTL index] directly in the mapping DSL. diff --git a/grails-doc/src/en/guide/testing/functionalTesting.adoc b/grails-doc/src/en/guide/testing/functionalTesting.adoc index e4bd122a9e1..e33611915ac 100644 --- a/grails-doc/src/en/guide/testing/functionalTesting.adoc +++ b/grails-doc/src/en/guide/testing/functionalTesting.adoc @@ -88,3 +88,69 @@ class MySpec extends Specification { } ---- + + +==== Simulating Latency + +Functional tests are prone to timing bugs: an assertion that races a page navigation, a missing +wait, or an assumption about response ordering may pass reliably on a fast local machine and fail +intermittently on slower CI infrastructure. The `grails-testing-support-latency` module makes such +bugs reproducible by injecting artificial latency into the application under test, so that requests +randomly take longer. + +Add the dependency to your integration test configuration: + +[source,groovy] +---- +dependencies { + integrationTestRuntimeOnly 'org.apache.grails:grails-testing-support-latency' +} +---- + +The module is inert until enabled. To activate it, set `grails.testing.latency.enabled` to `true`, +for example in `application.yml` under a test environment, or as a system property on the test JVM: + +[source,groovy] +---- +tasks.named('integrationTest', Test) { + systemProperty('grails.testing.latency.enabled', 'true') +} +---- + +When enabled, a servlet filter is registered with the highest precedence that delays each matched +request by a random amount of time before it is processed, mirroring what a slow network or an +overloaded server looks like to the browser or HTTP client driving the test. + +The following configuration properties are supported under the `grails.testing.latency` prefix: + +[format="csv", options="header"] +|=== +Property,Default Value,Description +enabled,`false`,Whether to inject artificial latency +min-delay,`0`,Lower bound (inclusive) of the random delay per request +max-delay,`2s`,Upper bound (inclusive) of the random delay per request +probability,`1.0`,Fraction of requests to delay between `0.0` (none) and `1.0` (all) +url-patterns,`/*`,Servlet url patterns the filter applies to +seed,none,Optional fixed random seed for reproducible delay sequences +|=== + +Durations without a unit suffix are interpreted as milliseconds; suffixes such as `500ms`, `2s` are +also supported. For example, to delay half of all requests by between a quarter and two seconds: + +[source,yaml] +---- +grails: + testing: + latency: + enabled: true + min-delay: 250ms + max-delay: 2s + probability: 0.5 +---- + +A useful pattern is a dedicated CI job (or an occasional local run) that executes the functional +test suite with latency enabled and a generous `max-delay`. Tests that only pass on a fast server — +for example a Geb assertion that reads the page before a click-triggered navigation completes — +will fail deterministically under latency instead of flaking rarely in CI. + +WARNING: This module is a testing aid. Never enable it in production configuration. diff --git a/grails-test-examples/latency/build.gradle b/grails-test-examples/latency/build.gradle new file mode 100644 index 00000000000..f6531a05763 --- /dev/null +++ b/grails-test-examples/latency/build.gradle @@ -0,0 +1,48 @@ +/* + * 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 + * + * https://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. + */ + +plugins { + id 'org.apache.grails.buildsrc.properties' + id 'org.apache.grails.buildsrc.dependency-validator' + id 'org.apache.grails.buildsrc.compile' + id 'org.apache.grails.buildsrc.vulnerability-scan' +} + +version = '0.1' +group = 'latencyapp' + +apply plugin: 'org.apache.grails.gradle.grails-web' + +dependencies { + implementation platform(project(':grails-bom')) + + implementation 'org.apache.grails:grails-dependencies-starter-web' + implementation 'com.h2database:h2' + implementation 'org.apache.grails:grails-data-hibernate5' + runtimeOnly 'com.zaxxer:HikariCP' + + integrationTestImplementation 'org.apache.grails.testing:grails-testing-support-core' + integrationTestImplementation project(':grails-testing-support-http-client') + integrationTestRuntimeOnly project(':grails-testing-support-latency') +} + +apply { + from rootProject.layout.projectDirectory.file('gradle/functional-test-config.gradle') + from rootProject.layout.projectDirectory.file('gradle/grails-extension-gradle-config.gradle') +} diff --git a/grails-test-examples/latency/grails-app/conf/application.yml b/grails-test-examples/latency/grails-app/conf/application.yml new file mode 100644 index 00000000000..30b2dfed4a6 --- /dev/null +++ b/grails-test-examples/latency/grails-app/conf/application.yml @@ -0,0 +1,47 @@ +# 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 +# +# https://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. + +--- +grails: + profile: web + codegen: + defaultPackage: latencyapp + mime: + types: + json: + - application/json + html: + - text/html + xml: + - text/xml + - application/xml + all: '*/*' + +environments: + test: + dataSource: + dbCreate: create-drop + url: jdbc:h2:mem:testDb;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000 + driverClassName: org.h2.Driver + username: sa + password: '' + grails: + testing: + latency: + enabled: true + min-delay: 1500ms + max-delay: 3s + url-patterns: + - '/slow/*' diff --git a/grails-test-examples/latency/grails-app/conf/logback.xml b/grails-test-examples/latency/grails-app/conf/logback.xml new file mode 100644 index 00000000000..d03662bf7de --- /dev/null +++ b/grails-test-examples/latency/grails-app/conf/logback.xml @@ -0,0 +1,31 @@ + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + diff --git a/grails-test-examples/latency/grails-app/controllers/latencyapp/FastController.groovy b/grails-test-examples/latency/grails-app/controllers/latencyapp/FastController.groovy new file mode 100644 index 00000000000..173db5c1dd8 --- /dev/null +++ b/grails-test-examples/latency/grails-app/controllers/latencyapp/FastController.groovy @@ -0,0 +1,34 @@ +/* + * 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 + * + * https://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 latencyapp + +import grails.compiler.GrailsCompileStatic + +/** + * Outside the configured {@code grails.testing.latency.url-patterns}, so responses + * from this controller are served without artificial delay. + */ +@GrailsCompileStatic +class FastController { + + def ping() { + render('pong') + } + +} diff --git a/grails-test-examples/latency/grails-app/controllers/latencyapp/SlowController.groovy b/grails-test-examples/latency/grails-app/controllers/latencyapp/SlowController.groovy new file mode 100644 index 00000000000..5781be26741 --- /dev/null +++ b/grails-test-examples/latency/grails-app/controllers/latencyapp/SlowController.groovy @@ -0,0 +1,34 @@ +/* + * 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 + * + * https://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 latencyapp + +import grails.compiler.GrailsCompileStatic + +/** + * Matched by the configured {@code grails.testing.latency.url-patterns}, so responses + * from this controller are artificially delayed. + */ +@GrailsCompileStatic +class SlowController { + + def ping() { + render('pong') + } + +} diff --git a/grails-test-examples/latency/grails-app/controllers/latencyapp/UrlMappings.groovy b/grails-test-examples/latency/grails-app/controllers/latencyapp/UrlMappings.groovy new file mode 100644 index 00000000000..fe7179e7d77 --- /dev/null +++ b/grails-test-examples/latency/grails-app/controllers/latencyapp/UrlMappings.groovy @@ -0,0 +1,30 @@ +/* + * 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 + * + * https://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 latencyapp + +class UrlMappings { + + static mappings = { + "/$controller/$action?/$id?(.$format)?" { + constraints { + } + } + } + +} diff --git a/grails-test-examples/latency/grails-app/init/latencyapp/Application.groovy b/grails-test-examples/latency/grails-app/init/latencyapp/Application.groovy new file mode 100644 index 00000000000..0b553547a77 --- /dev/null +++ b/grails-test-examples/latency/grails-app/init/latencyapp/Application.groovy @@ -0,0 +1,30 @@ +/* + * 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 + * + * https://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 latencyapp + +import grails.boot.GrailsApp +import grails.boot.config.GrailsAutoConfiguration + +class Application extends GrailsAutoConfiguration { + + static void main(String[] args) { + GrailsApp.run(Application, args) + } + +} diff --git a/grails-test-examples/latency/src/integration-test/groovy/latencyapp/LatencyFunctionalSpec.groovy b/grails-test-examples/latency/src/integration-test/groovy/latencyapp/LatencyFunctionalSpec.groovy new file mode 100644 index 00000000000..57e57aee26f --- /dev/null +++ b/grails-test-examples/latency/src/integration-test/groovy/latencyapp/LatencyFunctionalSpec.groovy @@ -0,0 +1,62 @@ +/* + * 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 + * + * https://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 latencyapp + +import java.time.Duration + +import spock.lang.Specification + +import grails.testing.mixin.integration.Integration +import org.apache.grails.testing.http.client.HttpClientSupport + +/** + * Confirms the behavior of the {@code grails-testing-support-latency} module against a running + * application. The test environment enables latency for {@code /slow/*} with a minimum delay of + * 1500ms (see {@code application.yml}), so matched requests must take at least that long while + * unmatched requests stay well under it. + */ +@Integration +class LatencyFunctionalSpec extends Specification implements HttpClientSupport { + + private static final long MIN_DELAY_MILLIS = 1500 + + void 'requests matching the latency url patterns are delayed by at least the configured minimum'() { + when: + long start = System.nanoTime() + def response = http('/slow/ping') + long elapsedMillis = Duration.ofNanos(System.nanoTime() - start).toMillis() + + then: + response.assertStatus(200) + response.assertContains('pong') + elapsedMillis >= MIN_DELAY_MILLIS + } + + void 'requests outside the latency url patterns are not delayed'() { + when: + long start = System.nanoTime() + def response = http('/fast/ping') + long elapsedMillis = Duration.ofNanos(System.nanoTime() - start).toMillis() + + then: + response.assertStatus(200) + response.assertContains('pong') + elapsedMillis < MIN_DELAY_MILLIS + } +} diff --git a/grails-testing-support-latency/build.gradle b/grails-testing-support-latency/build.gradle new file mode 100644 index 00000000000..b76c4feaed0 --- /dev/null +++ b/grails-testing-support-latency/build.gradle @@ -0,0 +1,69 @@ +/* + * 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 + * + * https://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. + */ + +plugins { + id 'groovy' + id 'java-library' + id 'org.apache.grails.buildsrc.properties' + id 'org.apache.grails.buildsrc.dependency-validator' + id 'org.apache.grails.buildsrc.compile' + id 'org.apache.grails.buildsrc.publish' + id 'org.apache.grails.buildsrc.sbom' + id 'org.apache.grails.buildsrc.vulnerability-scan' + id 'org.apache.grails.gradle.grails-code-style' + id 'org.apache.grails.gradle.grails-jacoco' +} + +version = projectVersion +group = 'org.apache.grails' + +ext { + pomTitle = 'Grails Latency Testing Support' + pomDescription = + 'Testing support that injects artificial latency into a running Grails application, ' + + 'so functional tests can be exercised against randomly slowed responses to surface timing bugs' +} + +dependencies { + + implementation platform(project(':grails-bom')) + + api 'org.springframework.boot:spring-boot-autoconfigure' + api 'org.springframework.boot:spring-boot-servlet' // FilterRegistrationBean + + implementation 'org.apache.groovy:groovy' + implementation 'org.springframework:spring-core' // Ordered + implementation 'org.springframework:spring-web' // OncePerRequestFilter + implementation 'org.slf4j:slf4j-api' + + compileOnly 'jakarta.servlet:jakarta.servlet-api' + + testImplementation 'jakarta.servlet:jakarta.servlet-api' + testImplementation 'org.assertj:assertj-core' // required by the spring-boot-test context runners + testImplementation 'org.spockframework:spock-core' + testImplementation 'org.springframework:spring-test' + testImplementation 'org.springframework.boot:spring-boot-test' + + testRuntimeOnly 'org.slf4j:slf4j-nop' // Remove annoying warning +} + +apply { + from rootProject.layout.projectDirectory.file('gradle/test-config.gradle') + from rootProject.layout.projectDirectory.file('gradle/docs-config.gradle') +} diff --git a/grails-testing-support-latency/src/main/groovy/org/apache/grails/testing/latency/LatencyAutoConfiguration.groovy b/grails-testing-support-latency/src/main/groovy/org/apache/grails/testing/latency/LatencyAutoConfiguration.groovy new file mode 100644 index 00000000000..3ea739200d1 --- /dev/null +++ b/grails-testing-support-latency/src/main/groovy/org/apache/grails/testing/latency/LatencyAutoConfiguration.groovy @@ -0,0 +1,56 @@ +/* + * 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 + * + * https://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.grails.testing.latency + +import groovy.transform.CompileStatic + +import org.springframework.boot.autoconfigure.AutoConfiguration +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.boot.web.servlet.FilterRegistrationBean +import org.springframework.context.annotation.Bean +import org.springframework.core.Ordered + +/** + * Auto-configuration that registers the {@link LatencyFilter} when + * {@code grails.testing.latency.enabled} is {@code true}. + *

+ * The filter is registered with the highest precedence so that the configured delay applies + * to the entire request processing pipeline, mirroring what a slow network or server would + * look like to an HTTP client or browser driving the application under test. + * + * @see LatencyProperties + */ +@CompileStatic +@AutoConfiguration +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +@ConditionalOnProperty(prefix = 'grails.testing.latency', name = 'enabled', havingValue = 'true') +@EnableConfigurationProperties(LatencyProperties) +class LatencyAutoConfiguration { + + @Bean + FilterRegistrationBean latencyFilter(LatencyProperties latencyProperties) { + FilterRegistrationBean registration = new FilterRegistrationBean<>() + registration.filter = new LatencyFilter(latencyProperties) + registration.urlPatterns = latencyProperties.urlPatterns + registration.order = Ordered.HIGHEST_PRECEDENCE + registration + } +} diff --git a/grails-testing-support-latency/src/main/groovy/org/apache/grails/testing/latency/LatencyFilter.groovy b/grails-testing-support-latency/src/main/groovy/org/apache/grails/testing/latency/LatencyFilter.groovy new file mode 100644 index 00000000000..25a062cd6e3 --- /dev/null +++ b/grails-testing-support-latency/src/main/groovy/org/apache/grails/testing/latency/LatencyFilter.groovy @@ -0,0 +1,93 @@ +/* + * 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 + * + * https://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.grails.testing.latency + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j +import jakarta.servlet.FilterChain +import jakarta.servlet.ServletException +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse + +import org.springframework.web.filter.OncePerRequestFilter + +/** + * Servlet filter that delays each matched request by a random amount of time before + * passing it down the filter chain. + *

+ * Slowing responses down widens the window in which timing bugs occur, turning + * intermittent functional test flakiness (assertions that race a navigation, missing + * waits, ordering assumptions) into deterministic failures. It is intended purely as a + * testing aid and should never be enabled in production. + * + * @see LatencyAutoConfiguration + * @see LatencyProperties + */ +@Slf4j +@CompileStatic +class LatencyFilter extends OncePerRequestFilter { + + private final long minDelayMillis + private final long maxDelayMillis + private final double probability + private final Random random + + LatencyFilter(LatencyProperties properties) { + minDelayMillis = properties.minDelay.toMillis() + maxDelayMillis = properties.maxDelay.toMillis() + probability = properties.probability + if (minDelayMillis < 0) { + throw new IllegalArgumentException("grails.testing.latency.min-delay must not be negative, was ${properties.minDelay}") + } + if (maxDelayMillis < minDelayMillis) { + throw new IllegalArgumentException("grails.testing.latency.max-delay (${properties.maxDelay}) " + + "must not be less than min-delay (${properties.minDelay})") + } + if (probability < 0.0d || probability > 1.0d) { + throw new IllegalArgumentException("grails.testing.latency.probability must be between 0.0 and 1.0, was $probability") + } + random = properties.seed == null ? new Random() : new Random(properties.seed) + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + long delayMillis = nextDelayMillis() + if (delayMillis > 0) { + log.debug('Delaying {} {} by {}ms', request.method, request.requestURI, delayMillis) + try { + Thread.sleep(delayMillis) + } + catch (InterruptedException ignored) { + Thread.currentThread().interrupt() + } + } + filterChain.doFilter(request, response) + } + + private long nextDelayMillis() { + if (probability < 1.0d && random.nextDouble() >= probability) { + return 0 + } + if (minDelayMillis == maxDelayMillis) { + return minDelayMillis + } + minDelayMillis + (long) (random.nextDouble() * (maxDelayMillis - minDelayMillis + 1)) + } +} diff --git a/grails-testing-support-latency/src/main/groovy/org/apache/grails/testing/latency/LatencyProperties.groovy b/grails-testing-support-latency/src/main/groovy/org/apache/grails/testing/latency/LatencyProperties.groovy new file mode 100644 index 00000000000..89fea292fd4 --- /dev/null +++ b/grails-testing-support-latency/src/main/groovy/org/apache/grails/testing/latency/LatencyProperties.groovy @@ -0,0 +1,80 @@ +/* + * 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 + * + * https://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.grails.testing.latency + +import java.time.Duration + +import groovy.transform.CompileStatic + +import org.springframework.boot.context.properties.ConfigurationProperties + +/** + * Configuration for artificial request latency, bound from the {@code grails.testing.latency} prefix. + *

+ * Example configuration ({@code application.yml} of the application under test, or system properties + * passed to the test JVM): + *

+ * grails:
+ *     testing:
+ *         latency:
+ *             enabled: true
+ *             min-delay: 250ms
+ *             max-delay: 2s
+ *             probability: 0.5
+ * 
+ * Durations without a unit suffix are interpreted as milliseconds. + * + * @see LatencyAutoConfiguration + */ +@CompileStatic +@ConfigurationProperties(prefix = 'grails.testing.latency') +class LatencyProperties { + + /** + * Whether to inject artificial latency. Disabled by default so the library is inert + * unless explicitly switched on for a test run. + */ + boolean enabled = false + + /** + * Lower bound (inclusive) of the random delay applied to a request. + */ + Duration minDelay = Duration.ZERO + + /** + * Upper bound (inclusive) of the random delay applied to a request. + */ + Duration maxDelay = Duration.ofSeconds(2) + + /** + * Fraction of requests to delay, between {@code 0.0} (none) and {@code 1.0} (all, the default). + */ + double probability = 1.0d + + /** + * Servlet url patterns the latency filter is mapped to. Defaults to every request. + */ + List urlPatterns = ['/*'] + + /** + * Optional fixed seed for the random number generator, for reproducible delay sequences. + * When unset, delays vary from run to run. + */ + Long seed +} diff --git a/grails-testing-support-latency/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/grails-testing-support-latency/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..a8ebd5a9b9b --- /dev/null +++ b/grails-testing-support-latency/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.apache.grails.testing.latency.LatencyAutoConfiguration diff --git a/grails-testing-support-latency/src/test/groovy/org/apache/grails/testing/latency/LatencyAutoConfigurationSpec.groovy b/grails-testing-support-latency/src/test/groovy/org/apache/grails/testing/latency/LatencyAutoConfigurationSpec.groovy new file mode 100644 index 00000000000..8b0efa942f8 --- /dev/null +++ b/grails-testing-support-latency/src/test/groovy/org/apache/grails/testing/latency/LatencyAutoConfigurationSpec.groovy @@ -0,0 +1,63 @@ +/* + * 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 + * + * https://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.grails.testing.latency + +import spock.lang.Specification + +import org.springframework.boot.autoconfigure.AutoConfigurations +import org.springframework.boot.test.context.runner.WebApplicationContextRunner +import org.springframework.boot.web.servlet.FilterRegistrationBean + +class LatencyAutoConfigurationSpec extends Specification { + + private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(LatencyAutoConfiguration)) + + void 'registers the latency filter when enabled'() { + expect: + contextRunner + .withPropertyValues( + 'grails.testing.latency.enabled=true', + 'grails.testing.latency.min-delay=50', + 'grails.testing.latency.max-delay=250', + 'grails.testing.latency.probability=0.75', + 'grails.testing.latency.url-patterns=/report/*' + ) + .run { context -> + assert context.getBean('latencyFilter', FilterRegistrationBean).filter instanceof LatencyFilter + def properties = context.getBean(LatencyProperties) + assert properties.minDelay.toMillis() == 50 + assert properties.maxDelay.toMillis() == 250 + assert properties.probability == 0.75d + assert properties.urlPatterns == ['/report/*'] + } + } + + void 'does nothing when disabled or not configured'() { + expect: + contextRunner + .withPropertyValues(properties as String[]) + .run { context -> + assert !context.containsBean('latencyFilter') + } + + where: + properties << [[], ['grails.testing.latency.enabled=false']] + } +} diff --git a/grails-testing-support-latency/src/test/groovy/org/apache/grails/testing/latency/LatencyFilterSpec.groovy b/grails-testing-support-latency/src/test/groovy/org/apache/grails/testing/latency/LatencyFilterSpec.groovy new file mode 100644 index 00000000000..a6660780f23 --- /dev/null +++ b/grails-testing-support-latency/src/test/groovy/org/apache/grails/testing/latency/LatencyFilterSpec.groovy @@ -0,0 +1,102 @@ +/* + * 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 + * + * https://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.grails.testing.latency + +import java.time.Duration + +import spock.lang.Specification + +import org.springframework.mock.web.MockFilterChain +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse + +class LatencyFilterSpec extends Specification { + + void 'delays the request by at least the configured minimum before continuing the chain'() { + given: + def filter = new LatencyFilter(new LatencyProperties( + minDelay: Duration.ofMillis(100), + maxDelay: Duration.ofMillis(100) + )) + def chain = new MockFilterChain() + + when: + long start = System.nanoTime() + filter.doFilter(new MockHttpServletRequest('GET', '/report/show'), new MockHttpServletResponse(), chain) + long elapsedMillis = Duration.ofNanos(System.nanoTime() - start).toMillis() + + then: + elapsedMillis >= 100 + chain.request != null + } + + void 'does not delay when the probability is zero'() { + given: + def filter = new LatencyFilter(new LatencyProperties( + minDelay: Duration.ofSeconds(30), + maxDelay: Duration.ofSeconds(30), + probability: 0.0d + )) + def chain = new MockFilterChain() + + when: + long start = System.nanoTime() + filter.doFilter(new MockHttpServletRequest('GET', '/'), new MockHttpServletResponse(), chain) + long elapsedMillis = Duration.ofNanos(System.nanoTime() - start).toMillis() + + then: 'the request completes long before the configured delay' + elapsedMillis < 30_000 + chain.request != null + } + + void 'random delays never undercut the configured minimum'() { + given: + def filter = new LatencyFilter(new LatencyProperties( + minDelay: Duration.ofMillis(20), + maxDelay: Duration.ofMillis(60), + seed: 42L + )) + + expect: + timings(filter, 10).every { it >= Duration.ofMillis(20).toNanos() } + } + + void 'rejects invalid configuration: #description'() { + when: + new LatencyFilter(properties) + + then: + thrown(IllegalArgumentException) + + where: + description | properties + 'max-delay below min-delay' | new LatencyProperties(minDelay: Duration.ofSeconds(2), maxDelay: Duration.ofSeconds(1)) + 'negative min-delay' | new LatencyProperties(minDelay: Duration.ofMillis(-1)) + 'probability above 1.0' | new LatencyProperties(probability: 1.5d) + 'negative probability' | new LatencyProperties(probability: -0.5d) + } + + private static List timings(LatencyFilter filter, int requests) { + (1..requests).collect { + long start = System.nanoTime() + filter.doFilter(new MockHttpServletRequest('GET', '/'), new MockHttpServletResponse(), new MockFilterChain()) + System.nanoTime() - start + } + } +} diff --git a/settings.gradle b/settings.gradle index da33232c4d7..36495d66153 100644 --- a/settings.gradle +++ b/settings.gradle @@ -237,6 +237,7 @@ include( 'grails-testing-support-dbcleanup-h2', 'grails-testing-support-dbcleanup-postgresql', 'grails-testing-support-http-client', + 'grails-testing-support-latency', 'grails-testing-support-mongodb', 'grails-testing-support-redis', 'grails-testing-support-views-gson', @@ -541,6 +542,7 @@ include( 'grails-test-examples-issue-698-domain-save-npe', 'grails-test-examples-issue-views-182', 'grails-test-examples-jetty', + 'grails-test-examples-latency', 'grails-test-examples-namespaces', 'grails-test-examples-plugins-exploded', 'grails-test-examples-plugins-issue11005', @@ -594,6 +596,7 @@ project(':grails-test-examples-views-functional-tests-plugin').projectDir = file project(':grails-test-examples-test-phases').projectDir = file('grails-test-examples/test-phases') project(':grails-test-examples-spring-dependency-management').projectDir = file('grails-test-examples/spring-dependency-management') project(':grails-test-examples-jetty').projectDir = file('grails-test-examples/jetty') +project(':grails-test-examples-latency').projectDir = file('grails-test-examples/latency') includeBuild('./grails-gradle') { name = 'grails-gradle'