Automated Headless UI and Integration Testing with CasperJS, Maven and Spring Boot

Automated Headless UI testing for a Java application is often painful to setup and perform correctly. It needs to be integrated in the build cycle, there may be multiple runtime dependencies and platform specific configuration, etc. In this post, I explain how we implemented platform-independent automated UI testing with CasperJS, Maven and Spring Boot.

CasperJS is a library for test automation that works with PhantomJS, which is a headless WebKit browser. They are pretty popular in JS world for automated testing and we aim to use them in our build. CasperJS is an alternative to Selenium, which is a Java based UI testing framework.

Maven is a widely used build tool mainly for Java. Our method will integrate with specific phases to execute automatic tests.

Spring Boot is a prominent framework for developing web applications and it includes its own embedded application server so you don’t have to deal with them.

Maven discussion

Despite seeming easy, doing full UI testing in test phase is not a good practice as your app probably needs some db connectivity and some other specific methods to run properly. Which means your unit tests will fail before you even package your app. This practice often leads to skipping tests in every Maven build so you’re left with effectively useless test code base.

Maven has a verify goal designed to perform integration testing. It also has pre-integration-test and post-integration-test phases to do some useful stuff (such as starting and stopping your server).

We aimed UI testing must be configured in platform-independent way so that one must install absolutely nothing on a Linux, Windows or OS X machine other than Maven to run full UI integration testing just with mvn verify.

Unit Testing vs Integration Testing

In the table below, I have summarized the properties of and differences between Unit Testing and Integration Testing with regards to Maven inferred during my expertises.

  Unit Testing Integration Testing
Tests easily reproducible headless cases: utility functions, partially independent business logic, isolated functionalities with mocking reproducible realistic cases (including -but not limited to- UI) often depending on other systems with little to no mocking but still isolated from production instances
Applicable Project Types any project, particularly important for library or utility projects shippable projects, often the last module(s) in multi-module project chains
Coverage Goals increase as much as possible complete it
Maintained by developer team test team
Runtime Duration instant to short medium to long
Typical Run Interval often: after every push to main branch (must be automated with CI) or fixed intervals like 15mins to 1hr (depending on the project) not-so-often: before releases/after sprints or fixed intervals (like once a day or a week), automated or manual runs
Runtime Dependencies none, must be self-sufficient database, other services in the network (though it is best if those get automatically configured at runtime)
Maven Goal test verify
Maven Plugin maven-surefire-plugin maven-failsafe-plugin
Java Class Naming Convention Ending with Test Ending with IT

There are many articles to read on testing but they are often very long and hard to track. I hope this information is useful to some people. Please comment below if you think this can be improved!

Maven Configuration

Both PhantomJS and CasperJS are native executables so they come in different versions for each platform. Following maven plugins download the platform specific executables automatically. If you have a multi-module maven project, put these plugins in the project that you want to run automated tests on (the one with the main application context - @SpringBootApplication).

To run the CasperJS from Java we will need casperjs-junit library which is basically a bridge between JUnit and CasperJS. However, it assumes CasperJS is already installed on the machine, so it is not completely useful for automating platform-agnostic tests.

<dependency>
  <groupId>org.webjars.bower</groupId>
  <artifactId>casperjs</artifactId>
  <version>1.1.1</version>
  <scope>test</scope>
</dependency>

<dependency>
  <groupId>com.github.raonifn</groupId>
  <artifactId>casperjs-junit</artifactId>
  <version>0.4.1.1-SNAPSHOT</version>
  <scope>test</scope>
</dependency>

Now we will configure the main plugins to download and install PhantomJS, download and unpack CasperJS, set necessary flags and pass the executable path to JUnit side.

<build>
  <plugins>

    <!-- Downloads PhantomJS to appropriate location on this computer -->
    <plugin>
      <groupId>com.github.klieber</groupId>
      <artifactId>phantomjs-maven-plugin</artifactId>
      <version>0.7</version>
      <executions>
        <execution>
          <goals>
            <goal>install</goal>
          </goals>
        </execution>
      </executions>
      <configuration>
        <version>1.9.7</version>
      </configuration>
    </plugin>

    <!-- Unpacks CasperJS under build directory -->
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-dependency-plugin</artifactId>
      <version>2.10</version>
      <executions>
        <execution>
          <id>unpack-casperjs</id>
          <phase>pre-integration-test</phase>
          <goals>
            <goal>unpack</goal>
          </goals>
          <configuration>
            <artifactItems>
              <artifactItem>
                <groupId>org.webjars.bower</groupId>
                <artifactId>casperjs</artifactId>
                <version>1.1.1</version>
                <type>jar</type>
                <overWrite>true</overWrite>
                <outputDirectory>${project.build.directory}/casperjs</outputDirectory>
              </artifactItem>
            </artifactItems>
            <outputDirectory>${project.build.directory}/wars</outputDirectory>
            <overWriteReleases>false</overWriteReleases>
            <overWriteSnapshots>true</overWriteSnapshots>
          </configuration>
        </execution>
      </executions>
    </plugin>

    <!-- Passes executable paths to JUnit (CasperRunner) -->
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-failsafe-plugin</artifactId>
      <version>2.15</version>
      <configuration>
        <!-- Sets the VM argument line used when integration tests are run. -->
        <argLine>${failsafeArgLine}</argLine>
        <systemPropertyVariables>
          <phantomjs.binary>${phantomjs.binary}</phantomjs.binary>
          <phantomjs.executable>${phantomjs.binary}</phantomjs.executable>
          <casperjs.executable>${project.build.directory}/casperjs/META-INF/resources/webjars/casperjs/1.1.1/bin/casperjs${casperjs.extension}</casperjs.executable>
        </systemPropertyVariables>
      </configuration>
    </plugin>

    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-surefire-plugin</artifactId>
      <version>2.19</version>
      <configuration>
        <argLine>${surefireArgLine}</argLine>
      </configuration>
    </plugin>

    <!-- ... -->
  </plugins>
</build>

For each platform, CasperJS runnable comes with different extensions. Therefore we need one more step to correctly resolve the casperjs.executable with casperjs.extension property, which is platform-dependent. To fix this issue we add a new profile to our pom.xml.

<!-- checks OS and sets casperJS executable extension -->
<profiles>
  <profile>
    <id>Windows</id>
    <activation>
      <os>
        <family>Windows</family>
      </os>
    </activation>
    <properties>
      <casperjs.extension>.exe</casperjs.extension>
    </properties>
  </profile>
  <profile>
    <id>unix</id>
    <activation>
      <os>
        <family>unix</family>
      </os>
    </activation>
    <properties>
      <casperjs.extension></casperjs.extension>
    </properties>
    <build>
      <plugins>
        <plugin>
          <groupId>org.codehaus.mojo</groupId>
          <artifactId>exec-maven-plugin</artifactId>
          <version>1.2</version>
          <executions>
            <execution>
              <phase>pre-integration-test</phase>
              <goals>
                <goal>exec</goal>
              </goals>
            </execution>
          </executions>
          <configuration>
            <executable>chmod</executable>
            <arguments>
              <argument>+x</argument>
              <argument>${project.build.directory}/casperjs/META-INF/resources/webjars/casperjs/1.1.1/bin/casperjs</argument>
            </arguments>
          </configuration>
        </plugin>
      </plugins>
    </build>
  </profile>
</profiles>

Note that for a Linux system, we also need to mark the runnable as executable with chmod +x command.

JaCoCo plugin for coverage

After your tests are run, you will probably want to measure test coverage including coverage from the CasperJS tests and report it to a SonarQube server or something similar. For that we need to configure the jacoco-maven-plugin with proper configuration. Note that there will be two report files: jacoco-ut.exec for unit tests and jacoco-it.exec for integration tests. SonarQube can understand those reports as seperate and show Unit Coverage, Integration Coverage and Combined Coverage metrics, which is cool.

<plugin>
  <groupId>org.jacoco</groupId>
  <artifactId>jacoco-maven-plugin</artifactId>
  <version>0.7.7.201606060606</version>
  <executions>
    <execution>
      <id>pre-unit-test</id>
      <goals>
        <goal>prepare-agent</goal>
      </goals>
      <configuration>
        <destFile>${project.build.directory}/coverage-reports/jacoco-ut.exec</destFile>
        <propertyName>surefireArgLine</propertyName>
      </configuration>
    </execution>
    <execution>
      <id>post-unit-test</id>
      <phase>test</phase>
      <goals>
        <goal>report</goal>
      </goals>
      <configuration>
        <dataFile>${project.build.directory}/coverage-reports/jacoco-ut.exec</dataFile>
        <outputDirectory>${project.reporting.outputDirectory}/jacoco-ut</outputDirectory>
      </configuration>
    </execution>
    <execution>
      <id>pre-integration-test</id>
      <phase>pre-integration-test</phase>
      <goals>
        <goal>prepare-agent</goal>
      </goals>
      <configuration>
        <destFile>${project.build.directory}/coverage-reports/jacoco-it.exec</destFile>
        <propertyName>failsafeArgLine</propertyName>
      </configuration>
    </execution>
    <execution>
      <id>post-integration-test</id>
      <phase>post-integration-test</phase>
      <goals>
        <goal>report</goal>
      </goals>
      <configuration>
        <dataFile>${project.build.directory}/coverage-reports/jacoco-it.exec</dataFile>
        <outputDirectory>${project.reporting.outputDirectory}/jacoco-it</outputDirectory>
      </configuration>
    </execution>
  </executions>
</plugin>

CasperJS script

Put this simple script in src/test/resources/casperjs/demo.casper.js file. It automatically runs all classpath files matching /casperjs/*.casper.js. You can learn more about how to write scripts here.

var sys = require('system');
var url = sys.env.START_URL;
var siteName = 'Demo';
var failures = [];

if (!url) {
  this.casper.die('START_URL not found');
}

casper.test.begin('Demo CasperIT test', function suite(test) {
  casper.start();

  casper.test.on("fail", function(failure) {
    failures.push(failure);
  });

  //basic tests to see if we can reach the site etc.
  casper.thenOpen(url + "/", function(response) {
    console.log("Trying to open url: " + url);
  }).then( function() {
    test.assertHttpStatus(200, siteName + ' is up');
    test.assertTitle('Automated CasperJS Demo', siteName + ' has the correct title');
  });

  casper.run(function() {
    this.exit(failures.length);
  });

});

Java Classes

For integration testing, we need classes named as *IT. In DemoIT class below, we’re instructing Spring to run on a random port via @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) annotation. After initialization is complete, @LocalServerPort retrieves the random port number so we pass it down as an argument to the CasperJS script (demo.casper.js). CasperIT class waits the application context to start via the CountDownLatch. When the initialization is complete, @Test method releases the latch and instructs JUnit to run CasperIT class.

@RunWith(SpringRunner.class)
@ActiveProfiles("test")
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
public class DemoIT {

  @LocalServerPort
  private int serverPort;

  @Test
  public void casperJS() throws Exception {
    CasperIT.serverPort = this.serverPort;
    CasperIT.countDownLatch();
    JUnitCore.runClasses(CasperIT.class);
  }

  @RunWith(CasperRunner.class)
  public static class CasperIT {

    private static final CountDownLatch latch = new CountDownLatch(1);
    private static int serverPort;

    /**
    * The latch must be counted down after server init.
    */
    public static void countDownLatch() {
      latch.countDown();
    }

    /**
    * Ensures spring context is initialized before this class.
    */
    @BeforeClass
    public static void beforeClass() throws Exception {
      latch.await(5, TimeUnit.MINUTES);
    }

    @CasperEnvironment
    public Map<String, String> env() {
      Map<String, String> map = new HashMap<>();
      map.put("START_URL", "http://localhost:"+serverPort);
      return map;
    }
  }
}

Using a seperate test database

We should not do integration testing with production database. In this example I use spring.data.mongodb.uri=mongodb://localhost/demo-test property to target a different database.

For testing, we will possibly need some demo data, we can easily insert some data during testing, using a class like below. Note that we put this class in test classpath (src/test/java) so that it will not be available in regular application runs.

@Component
public class TestLifecycleBean {

  private static final Logger logger = LoggerFactory.getLogger(TestLifecycleBean.class);

  @Autowired
  private MongoTemplate mongoTemplate;
  @Autowired
  private UserRepository userRepository;

  @EventListener
  public void handleContextRefresh(ContextRefreshedEvent event) {
    logger.info("Importing test data...");
    userRepository.save(new User("John", "Doe", "+901112223344"));
    userRepository.save(new User("Jane", "Doe", "+909116667788"));
  }

  @EventListener
  public void handleContextClose(ContextClosedEvent event) {
    logger.info("Dropping test database...");
    if ("demo-test".equals(mongoTemplate.getDb().getName())) {
      mongoTemplate.getDb().dropDatabase();
      logger.info("Dropped database: demo-test");
    }
  }
}

Running

It should be all set! Just run mvn verify!

Note: verify stage is typically used to run integration tests. It also runs before install, so you can also use that command.

Complete example

I’ve also prepared a complete ready-to-run example project on Github. Please give it a star if you like it!

Good luck!