Running Cypress tests with TestContainers for a Spring Boot with Thymeleaf application

Posted at — Jun 15, 2019
Riekpil logo
Learn how to test real-world applications with the Testing Spring Boot Applications Masterclass. Comprehensive online course with 8 modules and 130+ video lessons to master well-known Java testing libraries: JUnit 5, Mockito, Testcontainers, WireMock, Awaitility, Selenium, LocalStack, Selenide, and Spring's Outstanding Test Support.

UPDATE: The full test class code in this post does not actually fail the test when the Cypress tests fail. See my updated example at ./blog/2019/06/16/ensure-junit-test-fails-when-cypress-tests-fail/[Ensure JUnit test fails when Cypress tests fail].

At the last ng-be conference I saw a demo of Cypress. Cypress allows to do functional testing of your web application, quite similar to Selenium for example, but still quite different.

Ever since that time, I wanted to try it out, but never got around to it until this week. My application is not a Single Page Application, but after reading the post End-to-End Testing Web Apps: The Painless Way I was convinced that it should be doable for my Spring Boot application that uses Thymeleaf for Server Side Rendering of the HTML pages.

Creating a Cypress test

To get started, I downloaded the desktop application using the 'Download now' option at https://www.cypress.io/. After that unzip cypress.zip and drag the Cypress application to your Applications (on macOS).

To create our first test, we need a few files.

  • src/test/e2e/cypress.json: This file contains some general settings for Cypress

  • src/test/e2e/cypress/integration/spec.js: This file contains our tests

  • src/test/e2e/cypress/plugins/index.js: For Cypress plugins, see https://on.cypress.io/plugins-guide for more info

  • src/test/e2e/cypress/support/index.js: Allows to load commands to make your tests easier to read

In cypress.json, we will put the base url of our application. By default, Spring Boot will run on localhost at port 8080, so our configuration should look like this:

{
  "baseUrl": "http://localhost:8080"
}

In the index.js that is in the support directory, we load the commands.js file that should also be in the same directory:

// Import commands.js using ES2015 syntax:
import './commands'

The commands.js file can be empty for now. The index.js in the plugins is also empty for now.

Finally, the spec.js file is the most important one as it contains our actual tests.

For example:

Cypress.on('uncaught:exception', (err, runnable) => {
    // returning false here prevents Cypress from
    // failing the test
    return false

});

context('Website login', () => {

    beforeEach(() => {
        cy.visit('/')
    });

    it('should redirect to login page if not logged on', function () {
        cy.url().should('include', 'login')
    });

    it('allows login with admin/admin credentials', function () {
        cy.get('#username')
            .type('admin');
        cy.get('#password')
            .type('admin');
        cy.get('button[type=submit]').click();

        // Administrators see the users page by default
        cy.url().should('include', 'users');
    });
});

The first part with the uncaught:exception thing is there because otherwise Cypress would already fail at startup, for reasons that are unclear to me. Try if it works for your application without it by all means.

So we have 2 tests:

  • One test asserts that if somebody tries to access the home page, he will be redirected to the login page.

  • The second test logs on using the admin/admin credentials and asserts if the user arrives at the /users url after log on.

Running in the Cypress desktop application

To run these tests in the desktop application of Cypress, you just need to do these simple steps:

  1. Start the Spring Boot application using Maven/Gradle or your favorite IDE

  2. Open Cypress desktop application

  3. Select the src/test/e2e directory in the Cypress application

After that, you will see Cypress running your tests side-by-side with your application:

screenshot 2019 06 15 at 20.14.24

If you now edit your spec.js file, the Cypress application will watch it for changes and run all tests again as soon as you save the file.

It might be annoying if you are working on 1 test that all tests run. You can use it.only() instead of it() to instruct Cypress to only run that one test. E.g.:
it.only('should redirect to login page if not logged on', function () {
    cy.url().should('include', 'login')
});

Running the tests with TestContainers

Cypress recently published Docker images for its tool. We can use those with Testcontainers so that we can start the Cypress test from inside a JUnit test with TestContainers. With that, we have the full application running in a well known state, and it makes it easy to run the Cypress tests as part of the Maven build.

Step 1 - Add Testcontainers to the build

If you are not already using Testcontainers, add the dependency to your pom.xml:

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>testcontainers</artifactId>
    <version>${testcontainers.version}</version>
    <scope>test</scope>
</dependency>

I used version 1.11.3.

Step 2 - Create an integration test

Create a CypressIntegrationTest Java file in src/test/java that uses the @SpringBootTest annotation to startup the full application on a random port:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ActiveProfiles(SpringProfiles.INTEGRATION_TEST)
class CypressIntegrationTest {

}

Step 3 - Create the Cypress docker image with Testcontainers

In our CypressIntegrationTest, we use the GenericContainer class from Testcontainers:

private GenericContainer createCypressContainer() {
    GenericContainer result = new GenericContainer("cypress/included:3.3.1");
    result.withClasspathResourceMapping("e2e", "/e2e", BindMode.READ_WRITE);
    result.setWorkingDirectory("/e2e");
    result.addEnv("CYPRESS_baseUrl", "http://host.testcontainers.internal:" + port);
    return result;
}
  • Use the cypress docker image that has everything included at version 3.3.1.

  • Map what is on the classpath under e2e to a path in the Docker container at /e2e as the Docker container expects to find the tests there.

  • Set the working directory in the container to /e2e

  • Override the baseUrl that is defined in cypress.json via an environment variable

As the @SpringBootTest will run our application at a random port, we need to inject that port into our test:

@LocalServerPort
private int port;

With that port field, we can build up the URL that Cypress should use for testing.

To make it possible for the Cypress docker image started by Testcontainers to communicate with out application started by Spring Boot, we need to add this line at the start of our test:

// Ensures that the container will be able to access the Spring Boot application that
// is started via @SpringBootTest
Testcontainers.exposeHostPorts(port);

Adding this line allows the Docker container to access the host via host.testcontainers.internal.

Step 4 - Put the Cypress tests on the classpath

With Testcontainers, you can put a directory that is on the classpath mounted as a volume in the docker container. Our tests are in src/test/e2e which is not on the classpath by default. We can easily add them on the (test)classpath by adding a ` block to our `pom.xml:

...

src/test/e2e

e2e

...

Step 5 - Wait for the tests to be executed

If we now just start the GenericContainer in our unit test, it will start but immediately stop before any tests are run.

Not sure if it is the best way, but I added a CountDownLatch to wait for Cypress to write Run Finished to the output. After that, I know all tests have been run.

Full code

To recap, this is the full code of my test:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ActiveProfiles(SpringProfiles.INTEGRATION_TEST)
class CypressIntegrationTest {

    private static final Logger LOGGER = LoggerFactory.getLogger(CypressIntegrationTest.class);

    private static final int MAX_TOTAL_TEST_TIME_IN_MINUTES = 5;

    @LocalServerPort
    private int port;

    @Autowired
    private UserService userService;

    @Test
    void runCypressTests() throws InterruptedException {

        // Ensures that the container will be able to access the Spring Boot application that
        // is started via @SpringBootTest
        Testcontainers.exposeHostPorts(port);

        userService.addAdministrator("admin", "Administrator", "admin", Gender.MALE,
                                     LocalDate.of(1978, Month.DECEMBER, 2));

        CountDownLatch countDownLatch = new CountDownLatch(1);

        try (GenericContainer container = createCypressContainer()) {

            container.start();
            container.followOutput(new Consumer() {

                @Override
                public void accept(OutputFrame outputFrame) {

                    LOGGER.debug(outputFrame.getUtf8String());

                    if (outputFrame.getUtf8String().contains("Run Finished")) {
                        countDownLatch.countDown();
                    }
                }
            });

            countDownLatch.await(MAX_TOTAL_TEST_TIME_IN_MINUTES, TimeUnit.MINUTES);

            // Just sleep a bit extra because 'Run Finished' is not the really last line,
            // but very close to the end

            Thread.sleep(2000);
        }
    }

    @NotNull
    private GenericContainer createCypressContainer() {
        GenericContainer result = new GenericContainer("cypress/included:3.3.1");
        result.withClasspathResourceMapping("e2e", "/e2e", BindMode.READ_WRITE);
        result.setWorkingDirectory("/e2e");
        result.addEnv("CYPRESS_baseUrl", "http://host.testcontainers.internal:" + port);
        return result;
    }
}
Since this is a Spring Boot test, I can @Autowire any service I want to do some initial setup. In this example, I create an administrator account to be able to test login.

Run the tests via Maven

Just run mvn test and the CypressIntegrationTest will be done as part of the build. The video that Cypress generates of the test execution can be found at target/test-classes/e2e/cypress/videos.

You probably don’t want to run those tests for every Maven build. Use Maven profiles to only run the integration test when a certain profile is active.

Conclusion

It is perfectly possible to running Cypress tests as part of a Maven build for a Spring Boot application that uses Thymeleaf for server side rendering. Testcontainers make it quite easy and straightforward.

If you want to be notified in the future about new articles, as well as other interesting things I'm working on, join my mailing list!
I send emails quite infrequently, and will never share your email address with anyone else.