{
"baseUrl": "http://localhost:8080"
}
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.
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.
To run these tests in the desktop application of Cypress, you just need to do these simple steps:
Start the Spring Boot application using Maven/Gradle or your favorite IDE
Open Cypress desktop application
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:
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')
});
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.
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.
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 {
}
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
.
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
...
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.
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.
|
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. |
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.