Configuring Our End-to-End Tests - Spring Framework

After we have finished this lesson, we

  • Know why we shouldn’t use a setup method when we configure Selenium WebDriver before our test methods are run.
  • Understand why we shouldn’t use a teardown method when we clean up the reserved resources after our test methods have been run.
  • Can create a custom test runner that configures Selenium WebDriver, injects it into our test class, and cleans up the reserved resources.
  • Understand how we can configure our end-to-end tests by using our custom test runner.

Creating and Configuring a WebDriver Object

Before we can write end-to-end tests with Selenium, we have to figure out a way to:

  • Create and configure a new WebDriver object before our end-to-end tests are run.
  • Quit the WebDriver instance and close all browser windows after our end-to-end tests have been run.

We could add this logic to the methods annotated with the @BeforeClass and @AfterClass annotations, but I think that we shouldn’t do it because of these two reasons:

  • Creating new WebDriver objects and quitting the created WebDriver instances in our test classes adds duplicate code to our test suite. This makes our tests hard to write and maintain.
  • Typically end-to-end tests require more setup code than unit or integration tests. If we add technical setup code to our test classes, our test code becomes hard to read because technical implementation details can hide the essence of our test cases.

Let’s find out how we can create a custom test runner that creates a new WebDriver object, injects the created object into our test class, and cleans up the reserved resources.

Creating the Required Setup Annotations

The first thing that we have to do is to create two custom annotations that we use when we configure our end-to-end tests:

First, the @SeleniumTest annotation identifies end-to-end tests that use Selenium and contains the configuration of the created WebDriver object. This annotation can be added to a test class, and it has one attribute called driver that configures the type of the created WebDriver object. The default value of the driver attribute is ChromeDriver.class.

After we have created the @SeleniumTest annotation, its source code looks as follows:

 1import org.openqa.selenium.WebDriver;
 2import org.openqa.selenium.chrome.ChromeDriver;
 3
 4import java.lang.annotation.*;
 5
 6@Documented
 7@Inherited
 8@Retention(RetentionPolicy.RUNTIME)
 9@Target(ElementType.TYPE)
10public @interface SeleniumTest {
11
12    Class<? extends WebDriver> driver() default ChromeDriver.class;
13}

I admit that this annotation is not nearly as useful as it could be. I decided to keep it as simple as possible because I have no idea what kind of configuration options you want to use. That being said, you can add the missing configuration options to this annotation and configure the created WebDriver object by following the instructions given in this lesson.

Credits:

I got this idea from a blog post titled: Spring Boot Integration Testing with Selenium by RafaƂ Borowiec.

Second, the @SeleniumWebDriver annotation identifies the field in which the created WebDriver object should be injected. As you probably figured out, this annotation can be added to a field of a test class.

The source code of the @SeleniumWebDriver annotation looks as follows:

1import java.lang.annotation.*;
2
3@Retention(RetentionPolicy.RUNTIME)
4@Target(ElementType.FIELD)
5public @interface SeleniumWebDriver {
6
7}

Next, we will create a custom test runner that creates a new WebDriver object, injects the created object into our test class, and quits the created WebDriver instance after our test methods have been run.

Creating Our Custom Test Runner

We have to create a custom test runner that fulfills these requirements:

  • It creates a new WebDriver object before the @Before method is run.
  • It injects the created WebDriver object into our test class before the @Before method is run.
  • It quits the created WebDriver instance after the @After method has been run.

In other words, we want to create a test runner that allows us to use the created WebDriver object in our setup, test, and teardown methods. We can create this test runner by following these steps:

First, we have to create a public class called SeleniumTestRunner that extends the BlockJUnit4ClassRunner class.

The source code of the SeleniumTestRunner class looks as follows:

1import org.junit.runners.BlockJUnit4ClassRunner;
2
3public class SeleniumTestRunner extends BlockJUnit4ClassRunner {
4
5}

Second, we have to add a public constructor to the SeleniumTestRunner class. This constructor takes a Class<?> object (the invoked test class) as constructor argument and can throw an InitializationError. The implementation of this constructor simply invokes the constructor of the super class and passes the Class<?> object as a constructor argument.

After we have created this constructor, the source code of the SeleniumTestRunner class looks as follows:

1import org.junit.runners.BlockJUnit4ClassRunner;
2import org.junit.runners.model.InitializationError;;
3
4public class SeleniumTestRunner extends BlockJUnit4ClassRunner {
5
6    public SeleniumTestRunner(Class<?> klass) throws InitializationError {
7        super(klass);
8    }
9}

Third, we have to add a WebDriver field to the SeleniumTestRunner class. This field contains the WebDriver object that is used by our end-to-end tests.

After we have added this field to the SeleniumTestRunner class, its source code looks as follows:

 1import org.junit.runners.BlockJUnit4ClassRunner;
 2import org.junit.runners.model.InitializationError;;
 3import org.openqa.selenium.WebDriver;
 4
 5public class SeleniumTestRunner extends BlockJUnit4ClassRunner {
 6
 7    private WebDriver webDriver;
 8
 9    public SeleniumTestRunner(Class<?> klass) throws InitializationError {
10        super(klass);
11    }
12}

Fourth, we have to to override the collectInitializationErrors() method of the ParentRunner class and implement it by invoking the collectInitializationErrors() method of the BlockJUnit4ClassRunner class. This method validates that the test class is a valid JUnit 4 test class, and we will use this method for ensuring that our setup annotations are used correctly.

After we have implemented this method, the source code of our custom test runner class looks as follows:

 1import org.junit.runners.BlockJUnit4ClassRunner;
 2import org.junit.runners.model.FrameworkField;
 3import org.junit.runners.model.InitializationError;
 4import org.openqa.selenium.WebDriver;
 5import java.util.List;
 6
 7public class SeleniumTestRunner extends BlockJUnit4ClassRunner {
 8
 9	private WebDriver webDriver;
10
11    public SeleniumTestRunner(Class<?> klass) throws InitializationError {
12        super(klass);
13    }
14
15    @Override
16    protected void collectInitializationErrors(List<Throwable> errors) {
17        super.collectInitializationErrors(errors);
18    }
19}

Fifth, we have to verify that our test class is annotated with the @SeleniumTest annotation. After we have written the required code, the source code of the SeleniumTestRunner class looks as follows:

 1import org.junit.runners.BlockJUnit4ClassRunner;
 2import org.junit.runners.model.InitializationError;
 3import org.openqa.selenium.WebDriver;
 4import java.util.List;
 5
 6public class SeleniumTestRunner extends BlockJUnit4ClassRunner {
 7
 8    private WebDriver webDriver;
 9
10    public SeleniumTestRunner(Class<?> klass) throws InitializationError {
11        super(klass);
12    }
13
14    @Override
15    protected void collectInitializationErrors(List<Throwable> errors) {
16        super.collectInitializationErrors(errors);
17        validateSeleniumTestAnnotation(errors);
18    }
19
20    private void validateSeleniumTestAnnotation(List<Throwable> errors) {
21        if (!this.getTestClass().getJavaClass().isAnnotationPresent(SeleniumTest.class)) {
22            String errorMessage = "Cannot instantiate WebDriver because the test class " +
23                    "is not annotated with the @SeleniumTest annotation";
24
25            errors.add(new RuntimeException(errorMessage));
26        }
27    }
28}

Sixth, we have ensure that our test class has exactly one field that is annotated with the @SeleniumWebDriver annotation and verify that the type of this field is WebDriver. After we have written the required code, the source code of the SeleniumTestRunner class look as follows:

 1import org.junit.runners.BlockJUnit4ClassRunner;
 2import org.junit.runners.model.FrameworkField;
 3import org.junit.runners.model.InitializationError;
 4import org.openqa.selenium.WebDriver;
 5import java.util.List;
 6
 7public class SeleniumTestRunner extends BlockJUnit4ClassRunner {
 8
 9	private WebDriver webDriver;
10
11    public SeleniumTestRunner(Class<?> klass) throws InitializationError {
12        super(klass);
13    }
14
15    @Override
16    protected void collectInitializationErrors(List<Throwable> errors) {
17        super.collectInitializationErrors(errors);
18        validateSeleniumTestAnnotation(errors);
19        validateWebDriverField(errors);
20    }
21
22    private void validateSeleniumTestAnnotation(List<Throwable> errors) {
23        if (!this.getTestClass().getJavaClass().isAnnotationPresent(SeleniumTest.class)) {
24            String errorMessage = "Cannot instantiate WebDriver because the test class " +
25                    "is not annotated with the @SeleniumTest annotation";
26
27            errors.add(new RuntimeException(errorMessage));
28        }
29    }
30
31    private void validateWebDriverField(List<Throwable> errors) {
32        List<FrameworkField> fields = this.getTestClass().getAnnotatedFields(SeleniumWebDriver.class);
33
34        if (fields.isEmpty()) {
35            String errorMessage = "Cannot initialize test class because it has no field " +
36                    "that is annotated with the @SeleniumWebDriver annotation";
37            errors.add(new RuntimeException(errorMessage));
38        }
39
40        if (fields.size() > 1) {
41            String errorMessage = "Cannot initialize test class because it has multiple fields " +
42                    "that are annotated with the @SeleniumWebDriver annotation";
43            errors.add(new RuntimeException(errorMessage));
44        }
45
46        if (fields.size() == 1) {
47            FrameworkField field = fields.get(0);
48            if (!field.getType().equals(WebDriver.class)) {
49                String errorMessage = "Cannot initialize test class because the type of the field " +
50                        "that is annotated with the @SeleniumWebDriver annotation is not WebDriver";
51                errors.add(new RuntimeException(errorMessage));
52            }
53        }
54    }
55}

Seventh, we have to override the run() method of the ParentRunner class and implement this method by invoking the run() method of the BlockJUnit4ClassRunner class. We will use this method for creating a new WebDriver object before the @Before method is run and quitting the created WebDriver object after the @After method has been run.

After we have added this method to our custom test runner class, the relevant part of our custom test runner class looks as follows:

 1import org.junit.runner.notification.RunNotifier;
 2import org.junit.runners.BlockJUnit4ClassRunner;
 3import org.junit.runners.model.InitializationError;;
 4import org.openqa.selenium.WebDriver;
 5
 6public class SeleniumTestRunner extends BlockJUnit4ClassRunner {
 7
 8    private WebDriver webDriver;
 9
10    public SeleniumTestRunner(Class<?> klass) throws InitializationError {
11        super(klass);
12    }
13
14    //The other methods are omitted for the sake of clarity
15
16    @Override
17    public void run(RunNotifier notifier) {
18        super.run(notifier);
19    }
20}

Eighth, we have to create a new WebDriver object by using the configuration that is provided by the @SeleniumTest annotation and set a reference to the created object to the WebDriver field.

Because we want to create this object before our @Before method is run, we have to add the required code before the code that invokes the run() method of the BlockJUnit4ClassRunner class.

After we have created a new WebDriver object, the relevant part of the SeleniumTestRunner class looks as follows:

 1import org.junit.runner.notification.RunNotifier;
 2import org.junit.runners.BlockJUnit4ClassRunner;
 3import org.junit.runners.model.InitializationError;;
 4import org.openqa.selenium.WebDriver;
 5
 6public class SeleniumTestRunner extends BlockJUnit4ClassRunner {
 7
 8    private WebDriver webDriver;
 9
10    public SeleniumTestRunner(Class<?> klass) throws InitializationError {
11        super(klass);
12    }
13
14    //The other methods are omitted for the sake of clarity
15
16    @Override
17    public void run(RunNotifier notifier) {
18        this.webDriver = createBrowserBeforeTests();
19
20        super.run(notifier);
21    }
22
23    private WebDriver createBrowserBeforeTests() {
24        SeleniumTest seleniumConfig = this.getTestClass().getAnnotation(SeleniumTest.class);
25        return createWebDriver(seleniumConfig.driver());
26    }
27
28    private WebDriver createWebDriver(Class<? extends WebDriver> webDriverClass) {
29        try {
30            return webDriverClass.newInstance();
31        } catch (InstantiationException e) {
32            throw new RuntimeException(String.format(
33                    "Cannot instantiate WebDriver. Is %s a non abstract class that has no argument constructor?",
34                    webDriverClass.getCanonicalName()
35            ));
36        } catch (IllegalAccessException e) {
37            throw new RuntimeException(String.format(
38                    "Cannot instantiate WebDriver. Does %s have a public constructor?",
39                    webDriverClass.getCanonicalName()
40            ));
41        }
42    }
43}

Ninth, we have to quit to created WebDriver instance after our @After method has been run. This means that we have to add the required code after the code that invokes the run() method of the BlockJUnit4ClassRunner class.

After we have written the code that quits the created WebDriver instance, the relevant part of our test class looks as follows:

 1import org.junit.runner.notification.RunNotifier;
 2import org.junit.runners.BlockJUnit4ClassRunner;
 3import org.junit.runners.model.InitializationError;;
 4import org.openqa.selenium.WebDriver;
 5
 6public class SeleniumTestRunner extends BlockJUnit4ClassRunner {
 7
 8    private WebDriver webDriver;
 9
10    public SeleniumTestRunner(Class<?> klass) throws InitializationError {
11        super(klass);
12    }
13
14    //The other methods are omitted for the sake of clarity
15
16    @Override
17    public void run(RunNotifier notifier) {
18        this.webDriver = createBrowserBeforeTests();
19
20        super.run(notifier);
21
22        closeBrowserAfterTests();
23    }
24
25    private WebDriver createBrowserBeforeTests() {
26        SeleniumTest seleniumConfig = this.getTestClass().getAnnotation(SeleniumTest.class);
27        return createWebDriver(seleniumConfig.driver());
28    }
29
30    private WebDriver createWebDriver(Class<? extends WebDriver> webDriverClass) {
31        try {
32            return webDriverClass.newInstance();
33        } catch (InstantiationException e) {
34            throw new RuntimeException(String.format(
35                    "Cannot instantiate WebDriver. Is %s a non abstract class that has no argument constructor?",
36                    webDriverClass.getCanonicalName()
37            ));
38        } catch (IllegalAccessException e) {
39            throw new RuntimeException(String.format(
40                    "Cannot instantiate WebDriver. Does %s have a public constructor?",
41                    webDriverClass.getCanonicalName()
42            ));
43        }
44    }
45
46    private void closeBrowserAfterTests() {
47        if (this.webDriver != null) {
48            this.webDriver.quit();
49        }
50    }
51}

Tenth, we have to override the withBefores() method of the BlockJUnit4ClassRunner class and ensure that our implementation invokes the method of the super class. The original withBefores() method runs all @Before methods of the test class and its super classes, and we have to override it because we want to inject the created WebDriver object into our test class before these methods are run.

After we have overridden this method, the relevant part of the SeleniumTestRunner class looks as follows:

 1import org.junit.runners.BlockJUnit4ClassRunner;
 2import org.junit.runners.model.FrameworkMethod;
 3import org.junit.runners.model.InitializationError;;
 4import org.junit.runners.model.Statement;
 5import org.openqa.selenium.WebDriver;
 6
 7public class SeleniumTestRunner extends BlockJUnit4ClassRunner {
 8
 9    private WebDriver webDriver;
10
11    public SeleniumTestRunner(Class<?> klass) throws InitializationError {
12        super(klass);
13    }
14
15    //The other methods are omitted for the sake of clarity
16
17    @Override
18    protected Statement withBefores(FrameworkMethod method,
19                                    Object target,
20                                    Statement statement) {
21        return super.withBefores(method, target, statement);
22    }
23}

Eleventh, we have to inject the created WebDriver object into the WebDriver field of our test class that is annotated with the @SeleniumWebDriver annotation. Also, because we want to do this before the @Before method is run, we have to add the required code before the code that invokes the withBefores() method of the BlockJUnit4ClassRunner class.

After we have written the required code, the relevant part of the SeleniumTestRunner class looks as follows:

 1import org.junit.runners.BlockJUnit4ClassRunner;
 2import org.junit.runners.model.FrameworkMethod;
 3import org.junit.runners.model.InitializationError;;
 4import org.junit.runners.model.Statement;
 5import org.openqa.selenium.WebDriver;
 6import java.lang.reflect.Field;
 7
 8public class SeleniumTestRunner extends BlockJUnit4ClassRunner {
 9
10    private WebDriver webDriver;
11
12    public SeleniumTestRunner(Class<?> klass) throws InitializationError {
13        super(klass);
14    }
15
16    //The other methods are omitted for the sake of clarity
17
18    @Override
19    protected Statement withBefores(FrameworkMethod method,
20                                    Object target,
21                                    Statement statement) {
22        injectWebDriverIntoTestObject(target);
23        return super.withBefores(method, target, statement);
24    }
25
26    private void injectWebDriverIntoTestObject(Object testObject) {
27        Field webDriverField = getWebDriverField(testObject);
28        webDriverField.setAccessible(true);
29        try {
30            webDriverField.set(testObject, this.webDriver);
31        } catch (IllegalAccessException e) {
32            throw new RuntimeException("Could not inject WebDriver object because an error occurred", e);
33        }
34    }
35
36    private Field getWebDriverField(Object testObject) {
37        Field[] fields = testObject.getClass().getDeclaredFields();
38
39        for (int index = 0; index < fields.length; index++) {
40            Field field = fields[index];
41            if (field.isAnnotationPresent(SeleniumWebDriver.class)
42                    && field.getType().equals(WebDriver.class)) {
43                return field;
44            }
45        }
46
47        throw new RuntimeException("No WebDriver field was found.");
48    }
49}

We have now created a custom test runner that creates the required WebDriver object, injects it into our test class, and quits the created WebDriver instance.

Let’s find out how we can configure our end-to-end tests by using our custom annotations and the SeleniumTestRunner class.

Configuring Our End-to-End Tests

Let’s create a simple test class that contains one end-to-end test. We can write this test class by following these steps:

  1. Create a new test class and ensure that all test methods of this test class belong to the EndToEndTest category.
  2. Add a WebDriver field to the test class and annotate the field with the @SeleniumWebDriver annotation.
  3. Annotate the test class with the @SeleniumTest annotation and ensure that the type of the WebDriver object is ChromeDriver.
  4. Ensure that our end-to-end tests are run by our custom test runner.
  5. Write a simple test method that opens the front page of our web application and ensures that it has the correct title.

After we have written our test class, its source code looks as follows:

 1import org.junit.Test;
 2import org.junit.experimental.categories.Category;
 3import org.junit.runner.RunWith;
 4import org.openqa.selenium.WebDriver;
 5import org.openqa.selenium.chrome.ChromeDriver;
 6
 7import static org.assertj.core.api.Assertions.assertThat;
 8
 9@RunWith(SeleniumTestRunner.class)
10@SeleniumTest(driver = ChromeDriver.class)
11@Category(EndToEndTest.class)
12public class ShowMessagePageTest {
13
14    @SeleniumWebDriver
15    private WebDriver browser;
16
17    @Test
18    public void shouldOpenFrontPage() {
19        browser.get("http://localhost:8080");
20        assertThat(browser.getTitle()).isEqualTo("Hello World!");
21    }
22}

When we run our end-to-end test, we notice that it passes:

1$ mvn clean verify -P end-to-end-test
2
3Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.885 sec -
4in com.testwithspring.intermediate.message.ShowMessagePageTest
5
6Results :
7
8Tests run: 1, Failures: 0, Errors: 0, Skipped: 0

We can now configure our end-to-end tests without writing any duplicate code. Let’s summarize what we learned from this lesson.

Summary

This lesson has taught us four things:

  • We shouldn’t use setup and teardown methods because they add duplicate code to our test suite and technical implementation details can hide the essence of our test cases.
  • We can create a custom test runner by extending the BlockJUnit4ClassRunner class.
  • We can eliminate duplicate setup code by reading the Selenium WebDriver configuration from the attribute values of the @SeleniumTest annotation.
  • We can eliminate duplicate teardown code by ensuring that our custom test runner quits the created WebDriver instance after the @After method has been run.

Get the source code from Github