Kontakt

E2E Test Setup with Spring Boot, Playwright, Cucumber & Kotlin

by Katharina on 28.03.2022

The whole example project can be found on our Github.

Spring Boot Project Setup

We set up our Spring Boot Project with the Spring Initialzr and used Spring Web for this example application.

The generated project already comes with a Spring Boot Test which we can use for reference:

  1. package de.sandstorm.e2etestexample
  2.  
  3. import org.junit.jupiter.api.Test
  4. import org.springframework.boot.test.context.SpringBootTest
  5.  
  6. @SpringBootTest
  7. class E2eTestExampleApplicationTests {
  8.  
  9. @Test
  10. fun contextLoads() {
  11. }
  12.  
  13. }

Cucumber Integration

Since we want to write our behavioural tests in a nice and human-readable way with gherkin syntax, we add Cucumber to our project.

Add the following dependencies to your build.gradle.kt (consider specifying the latest versions):

// https://mvnrepository.com/artifact/io.cucumber/cucumber-java8
testImplementation("io.cucumber:cucumber-java8:7.2.3")
// https://mvnrepository.com/artifact/io.cucumber/cucumber-junit
testImplementation("io.cucumber:cucumber-junit:7.2.3")
// https://mvnrepository.com/artifact/io.cucumber/cucumber-spring
testImplementation("io.cucumber:cucumber-spring:7.2.3")
// so that cucumber works with junit5, see https://www.baeldung.com/java-cucumber-gradle#running-using-junit
testImplementation("org.junit.vintage:junit-vintage-engine:5.7.2")

The package 'org.junit.vintage:junit-vinage-engine' is needed for running the Cucumber Test Runner with JUnit5. I encountered Spring Projects where it wasn't necessary to include this dependency explicitly, so you might want to try it without first.

Next, we need to create our Test Runner for the Cucumber Tests:

package e2e
 
import io.cucumber.junit.Cucumber
import io.cucumber.junit.CucumberOptions
import org.junit.runner.RunWith
 
@RunWith(value = Cucumber::class)
@CucumberOptions(features = ["src/test/resources/e2e"], glue = ["e2e"])
class CucumberTestRunner

Regarding the CucumberOptions Annotation: Set 'features' to the path where your Feature Files will live (starting from Project Root). 'glue' contains the package name where Code like Step Definitions can be found.

Additionally, we need to integrate with Spring by creating the following class.

package e2e
 
import de.sandstorm.e2etestexample.E2eTestExampleApplication
import io.cucumber.spring.CucumberContextConfiguration
import org.springframework.boot.test.context.SpringBootTest
 
@CucumberContextConfiguration
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = [E2eTestExampleApplication::class])
class SpringIntegration

Note: We use a random port and inject it later via @LocalServerPort-Annotation in our Step Definitions.

This class will start our Application - just like the generated example test 'E2eTestExampleApplicationTests' - for our Cucumber Tests. The Application is started once for all Cucumber Tests. Keep in mind, that you most likely want to reset your Application State (if you have any) before every scenario. You can do this with the Cucumber 'Before'-Hook like this: 

package e2e
 
import io.cucumber.java8.En
 
class ApplicationSetupSteps: En {
 
    init {
        // reset application state
        Before { ->
            // e.g. truncate the database
            // e.g. remove file repositories
        }
    }
}

The interface 'En' provides the usual Cucumber annotations like '@When', '@Before' and others as Kotlin DSL like Syntax in your 'init'-Block.

Adding Playwright

There are several tools for end-to-end testing your web application. We've grown very fond of Microsoft's Playwright lately, so we want to use it in our project. Luckily for us it already comes with a Java API. The documentation is really helpful and well-written, so you might want to take a look at it too.

Add the following dependency to your gradle build file (consider specifying the latest version):

// https://mvnrepository.com/artifact/com.microsoft.playwright/playwright
testImplementation("com.microsoft.playwright:playwright:1.19.0")

What kind of playwright functionality you'll need is up to your tests, so the steps will look different for each project. But some examples might be:

  1. package e2e
  2.  
  3. import io.cucumber.java8.En
  4. import io.cucumber.java8.Scenario
  5. import org.junit.jupiter.api.Assertions.assertEquals
  6. import org.junit.jupiter.api.Assertions.assertTrue
  7. import org.springframework.boot.web.server.LocalServerPort
  8. [...]
  9.  
  10. class PlaywrightSteps : En {
  11.  
  12. @LocalServerPort
  13. private val port = 0
  14.  
  15. private var playwright: Playwright? = null
  16. private var browser: Browser? = null
  17. private var context: BrowserContext? = null
  18.  
  19. private var tab: Page? = null
  20.  
  21. init {
  22. /**
  23.   * Launch the browser before the first scenario. Since this takes some time we don't want to do it before each
  24.   * scenario.
  25.   * Since there is no AfterAll, we add a shutdown hook to close the browser again.
  26.   */
  27. Before { ->
  28. if (playwright == null) {
  29. println("Initializing playwright browser...")
  30. playwright = Playwright.create()
  31. val browserType = playwright!!.chromium()
  32. // put comment in for debugging with non-headless chromium
  33. browser = browserType.launch(/*BrowserType.LaunchOptions().setHeadless(false)*/)
  34.  
  35. // Hack for "AfterAll" see https://metamorphant.de/blog/posts/2020-03-10-beforeall-afterall-cucumber-jvm-junit/
  36. Runtime.getRuntime()
  37. .addShutdownHook(Thread { playwright!!.close() })
  38. }
  39. }
  40.  
  41. /**
  42.   * Create new Context and Tab for every scenario. Start tracing for debugging purposes.
  43.   */
  44. Before { ->
  45. println("Creating new playwright context and tabs...")
  46. context = browser!!.newContext()
  47. context!!.tracing().start(
  48. StartOptions()
  49. .setScreenshots(true)
  50. .setSnapshots(true)
  51. )
  52. tab = context!!.newPage()
  53. }
  54.  
  55. After { ->
  56. tab?.close()
  57. context?.close()
  58. }
  59.  
  60. /**
  61.   * Stop the tracing and save it into a zip file. If the scenario failed, we keep it and else delete it to not
  62.   * consume too much space.
  63.   */
  64. After { scenario: Scenario ->
  65. val path = Paths.get("e2e/tracing/${scenario.name}-trace.zip")
  66. context!!.tracing().stop(
  67. StopOptions()
  68. .setPath(path)
  69. )
  70. if (!scenario.isFailed) {
  71. Files.deleteIfExists(path)
  72. }
  73. }
  74.  
  75. When("I load {string}") { url: String ->
  76. tab!!.navigate("http://localhost:$port$url")
  77. }
  78.  
  79. When("I wait for the application to load") {
  80. // playwright as auto-waiting, but sometimes especially for dynamic content and ajax stuff, waiting is needed
  81. tab!!.waitForSelector(".Application-Header")
  82. }
  83.  
  84. When("I click on the element with inner text {string}") { text: String ->
  85. tab!!.click("text=$text")
  86. }
  87.  
  88. When("I enter {string} into the input field with label {string}") { input: String, label: String ->
  89. tab!!.fill("label:has-text(\"$label\") + input", input)
  90. }
  91.  
  92. When("I enter {string} into the text area with label {string}") { input: String, label: String ->
  93. tab!!.fill("label:has-text(\"$label\") + textarea", input)
  94. }
  95.  
  96. When("I make a screenshot with name {string}") { name: String ->
  97. tab!!.screenshot(Page.ScreenshotOptions().setPath(Paths.get("e2e/$name.png")))
  98. }
  99.  
  100. When("I wait for the element with text {string} to appear") { text: String ->
  101. // we assume that our React Application is loaded if the Application Header is present
  102. tab!!.waitForSelector("text=$text")
  103. }
  104.  
  105. Then("the page title should be {string}") { title: String ->
  106. assertEquals(title, tab!!.title())
  107. }
  108.  
  109. Then("the page must contain the text {string}") { text: String ->
  110. assertTrue(tab!!.isVisible("text=$text"))
  111. }
  112. }
  113. }

These steps already include tracing and screenshot functionality. 

Note, that we don't want to start a new browser for every scenario since it takes too much time. Therefore, we have to create it before all scenarios and close it when we're finished with everything. Since there is no such thing as 'BeforeAll' and 'AfterAll' hooks in this Cucumber API, we had to fake this behaviour using the 'Before' hook.

/**
* Launch the browser before the first scenario. Since this takes some time we don't want to do it before each
* scenario.
* Since there is no AfterAll, we add a shutdown hook to close the browser again.
*/
Before { ->
  if (playwright == null) {
	println("Initializing playwright browser...")
	playwright = Playwright.create()
	val browserType = playwright!!.chromium()
	// put comment in for debugging with non-headless chromium
	browser = browserType.launch(/*BrowserType.LaunchOptions().setHeadless(false)*/)
 
	// Hack for "AfterAll" see https://metamorphant.de/blog/posts/2020-03-10-beforeall-afterall-cucumber-jvm-junit/
	Runtime.getRuntime()
		.addShutdownHook(Thread { playwright!!.close() })
  }
}

A resulting Feature file using the above steps might look something like this:

  1. Feature: General Application Tests
  2.  
  3. Scenario: The application loads without crashing
  4. When I load "/"
  5. And I wait for the application to load
  6. Then the page title should be "Sandstorm E2E Test Example"
  7. And the page must contain the text "End to End Testing is fun!"

Thanks for reading! As usually questions and feedback are very welcome.