Cypress is a great testing framework for “anything that runs in a browser” allowing for clean, maintainable end to end tests. However, these tests can difficult and annoying to for developers to run, especially those who aren’t front end specialists. The following covers getting existing Cypress tests integrated and easily running within the Gradle-based build system of a Spring Boot project.
The advantages of this approach include:
./gradlew build
will run these tests – no additional commands are necessary- Developers will run these tests more often due to the ease of running them
- No additional scripting (such as GitLab CI jobs or GitHub actions) is necessary. Since continuous integration already runs the build (including tests), it will naturally also run this new test.
- Because this approach uses Docker orchestrated by testcontainers, developers don’t even need to install Cypress.
CypressTest
as written, assumes:
- The Gradle build has a dependency declared on testcontainers:
dependencies {
implementation(platform("org.testcontainers:testcontainers-bom:1.16.0"))
testImplementation "org.testcontainers:testcontainers"
}
- The frontend application source is located in the
frontend
directory. That directory contains the usual content, includingpackage.json
and thecypress
directory. - JUnit 5 is the test framework
- Gradle is the build system and
gradlew
is available - The application uses Spring Boot 2.x (tested up to 2.5.4)
- The Gradle build has gradle-node-plugin setup
- Gradle has a
yarnCypressVersion
task defined that looks like this:
task yarnCypressVersion(type: YarnTask) {
args = ['cypress', '--version']
}
- In
src/test/resources/application.yml
, aninternal.datasource.url
property is defined. For example, if using a postgres database run by testcontainers:
# ?TC_TMPFS=/var/lib/postgresql/data:rw is a performance optimization to use a tmpfs for postgres data accelerating startup/runtime, see https://www.testcontainers.org/modules/databases/jdbc/
internal.datasource.url: jdbc:tc:postgresql:11:///?TC_TMPFS=/var/lib/postgresql/data:rw
spring.datasource.url: ${internal.datasource.url}
However, these assumptions can be changed with fairly minor adjustments to the test.
CypressTest
ensures that the same version of Cypress is used to run the tests as is defined in the Yarn dependency so CypressTest
is consistent with manual execution of these tests.
import static org.assertj.core.api.Assertions.assertThat;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Duration;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.util.Assert;
import org.springframework.util.StreamUtils;
import org.testcontainers.Testcontainers;
import org.testcontainers.containers.BindMode;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.SelinuxContext;
import org.testcontainers.containers.output.Slf4jLogConsumer;
import org.testcontainers.containers.startupcheck.OneShotStartupCheckStrategy;
import lombok.extern.slf4j.Slf4j;
// if the Cypress tests end up writing to the database, a separate database instance must be used to ensure this test doesn't pollute the expected database state of other tests that run after it. &TC_REUSABLE=true ensures that.
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, properties = {"spring.datasource.url=${internal.datasource.url}&TC_REUSABLE=true"})
@Slf4j
/* default */ class CypressTest {
@LocalServerPort
private int port;
private static String cypressVersion;
@BeforeAll
private static void beforeAll() throws Exception {
cypressVersion = getCypressVersion();
log.info("Tests will run using Cypress version {}", cypressVersion);
}
@Test
/* default */ void testCypress() throws Exception {
Testcontainers.exposeHostPorts(port); // allow the container to access the running web application
try (GenericContainer<?> container = new GenericContainer<>("cypress/included:" + cypressVersion)) {
container.addFileSystemBind(Path.of(".").toAbsolutePath().toString(), "/src", BindMode.READ_WRITE, SelinuxContext.SHARED);
container
.withLogConsumer(new Slf4jLogConsumer(log))
.withEnv("CYPRESS_baseUrl", String.format("https://%s:%d", GenericContainer.INTERNAL_HOST_HOSTNAME, port))
.withWorkingDirectory("/src/frontend")
.withStartupCheckStrategy(
new OneShotStartupCheckStrategy().withTimeout(Duration.ofHours(1))
).start();
assertThat(container.getLogs()).contains("All specs passed!");
}
}
/** Get the installed version of Cypress.
*
* This approach ensures that as the version of Cypress specified in package management changes,
* this tests will always use the same version.
* @return installed version of Cypress.
* @throws Exception if something goes wrong
*/
private static String getCypressVersion() throws Exception {
String cmd = "./gradlew";
if (System.getProperty("os.name").startsWith("Win")) {
cmd = "./gradlew.bat";
}
final Process process = Runtime.getRuntime().exec(new String[]{cmd, "yarnCypressVersion"}, null, new File("."));
Assert.state(process.waitFor() == 0,"Cypress version command did not complete successfully");
final String output = StreamUtils.copyToString(process.getInputStream(), StandardCharsets.UTF_8);
final Matcher matcher = Pattern.compile("Cypress package version: (?<version>\\d++(?:\\.\\d++)++)").matcher(output);
Assert.state(matcher.find(), "Could not determine Cypress version from command output. Output: " + output);
return matcher.group("version");
}
}
Next Steps
The same Docker/testcontainers approach can be used to integrate other tools and tests into the build process. For instance, another great addition is Lighthouse Performance Testing.
Cypress Testing Integrated with Gradle and Spring Boot by Craig Andrews is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License.