Java-Cucumber tests using spring dependency injection throws NullPointerException when Feature spans Step classes

Viewed 1393

I am using a fairly typical Maven architecture, Java-Cucumber, Selenium, with Spring Dependency Injection test system set up to test a dynamic Angular front end website. (Versions in the pom.xml)ArchitectureWSpringDI It works really well, I can run hundreds of tests easily, but I can't "DRY" out the test steps the way I could with Ruby Watir. One article states that Ruby has a "world" object that Java is lacking, but the Spring used for Dependency Injection is supposed to solve that

I've read a lot of "retaining state" posts, but nothing seems to apply to how this works, and a lot are several versions behind in Cucumber and Spring, though I am still using Java 8. Most of posts for retaining state seem to be between steps in a single file, in a single test.

The main example, which is one of many, is I want to be able to have a steps file with my @Given I login step, and not have to put that step in a hundred other step files.

If I have a feature file like this:

Feature: As an account holder I examine account details

  Scenario: View personal summary widget info on details page
    Given Log into "web" on "dev" as "username" with "password" using "chrome"
    When I tap the first account section
    Then I see a list of transactions

and match it with a steps file that has all the steps in it like this

@SpringBootTest
public class AccountsSteps {

    private final MyAccountsPage page;
    @Autowired
    public AccountsSteps(MyAccountsPage page){
        this.page = page;
    }

    @Given("Log into {string} on {string} as {string} with {string} using {string}")
    public void logIntoOnAsWithUsing(String app, String env, String user, String pass, String browser) {
        page.loadAny(env, app, browser);
        page.sendUsername(user);
        page.sendPassword(pass);
        page.loginButtonClick();
    }

    @When("I tap the first account section")
    public void iTapTheFirstAccountSection() {
        page.waitForListOfElementType(WebElement);
        page.clickFirstAccountLink();
    }

    @Then("I see a list of transactions")
    public void iSeeAListOfTransactions() {
        By selector = By.cssSelector("div.container");
        page.waitForLocateBySelector(selector);
        Assert.assertTrue(page.hasTextOnPage("Account details"));
    }
}

Everything works great, but if I have another Feature that uses the same @Given, so the one above and below are exact, so it doesn't create a new step in the new Steps file.

Feature: As an account owner I wish to edit my details

  Scenario: My profile loads and verifies the correct member's name
    Given Log into "web" on "dev" as "username" with "password" using "chrome"
    When I use the link in the Self service drop down for My profile
    Then the Contact Details tab loads the proper customer name "Firstname Lastname"

Matched to this Step file, notice the lack of a Given step since it's using the one from the other file.

@SpringBootTest
public class MyProfileSteps {

    private final MyProfilePage page;
    @Autowired
    public MyProfileSteps(MyProfilePage page){
        this.page = page;
    }

    @When("I use the link in the Self service drop down for My profile")
    public void iUseTheLinkInTheSelfServiceDropDownForMyProfile() {
        page.clickSelfServiceLink();
        page.clickMyProfileLink();
    }

    @Then("the Contact Details tab loads the proper customer name {string}")
    public void theContactDetailsTabLoadsTheCustomerName(String fullName) {
        System.out.println(page.getCustomerNameFromProfile().getText());
        Assert.assertTrue(page.getCustomerNameFromProfile().getText().contains(fullName));
        page.teardown();
    }
}

I finally get to the crux of my problem. Upon switching to a step in a different step file, it throws the exception.

When I use the link in the Self service drop down for My profile
      java.lang.NullPointerException
    at java.util.Objects.requireNonNull(Objects.java:203)
    at org.openqa.selenium.support.ui.FluentWait.<init>(FluentWait.java:106)
    at org.openqa.selenium.support.ui.FluentWait.<init>(FluentWait.java:97)
    at projectname.pages.BasePage.waitForClickableThenClickByLocator(BasePage.java:417)
    at projectname.pages.BasePageWeb.clickSelfServiceLink(BasePageWeb.java:858)
    at projectname.steps.MyProfileSteps.iUseTheLinkInTheSelfServiceDropDownForMyProfile(MyProfileSteps.java:39)
    at ✽.I use the link in the drop down for My profile(file:///Users/name/git/project/tests/projectname/src/test/resources/projectname/features/autocomplete/my_profile.feature:10)

I've specifically tied them all together so a test only calls one new Selenium Instance per test, and it is definitely not opening a new browser window, it just crashes and closes.

public interface WebDriverInterface {

    WebDriver getDriver();
    WebDriver getDriverFire();
    void shutdownDriver();
    WebDriver stopOrphanSession();
}

And there are several profiles that will run different configurations, but my main local testing WebDriverInterface looks like this.

@Profile("local")
@Primary
@Component
public class DesktopLocalBrowsers implements WebDriverInterface {

    @Value("${browser.desktop.width}")
    private int desktopWidth;

    @Value("${browser.desktop.height}")
    private int desktopHeight;

    @Value("${webdriver.chrome.mac.driver}")
    private String chromedriverLocation;

    @Value("${webdriver.gecko.mac.driver}")
    private String firefoxdriverLocation;

    public WebDriver local;
    public WebDriver local2;

    public DesktopLocalBrowsers() {
    }

    @Override
    public WebDriver getDriver() {
        System.setProperty("webdriver.chrome.driver", chromedriverLocation);
        System.setProperty("webdriver.chrome.silentOutput", "true");
        ChromeOptions chromeOptions = new ChromeOptions();
        chromeOptions.addArguments("--disable-extensions");
        chromeOptions.addArguments("window-size=" + desktopWidth + "," + desktopHeight);
        local = new ChromeDriver(chromeOptions);
        return local;
    }

    @Override
    public WebDriver getDriverFire() {
        System.setProperty("webdriver.gecko.driver", firefoxdriverLocation);
        FirefoxBinary firefoxBinary = new FirefoxBinary();
        FirefoxOptions firefoxOptions = new FirefoxOptions();
        firefoxOptions.setLogLevel(FirefoxDriverLogLevel.FATAL);
        firefoxOptions.setBinary(firefoxBinary);
        local2 = new FirefoxDriver(firefoxOptions);
        return local2;
    }

    @Override
    public void shutdownDriver() {
        try{
            local.quit();
        }catch (NullPointerException npe){
            local2.quit();
        }
    }


    public WebDriver stopOrphanSession(){
        try{
            if(local != null){
                return local;
            }
        }catch (NullPointerException npe){
            System.out.println("All Drivers Closed");
        }
        return local2;
    }
}

I have fairly standard Runners. I've tried several variations of the Cucumber Runner, moved around into different directories, using glue and extraglue configurations, but either nothing changes, or I break it completely. Here is the one that is working now.

@RunWith(Cucumber.class)
@CucumberOptions(features = "src/test/resources/projectname/features/",
        glue = "backbase",
//        extraGlue = "common",   // glue and extraGlue cannot be used together
        plugin = {
                "pretty",
                "summary",
                "de.monochromata.cucumber.report.PrettyReports:target/cucumber",
        })
public class RunCucumberTest {

}

and my Spring Runner

@RunWith(SpringRunner.class)
@CucumberContextConfiguration
@SpringBootTest(classes = Application.class, webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
public class SpringContextRunner {
}

And the out of the box Application page for reference.

@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

And just in case someone finds it useful to brainstorming or diagnosing, my page objects start with the BasePage, which has become far too enormous since it contains all the common methods, but looks something like this.

public abstract class BasePageWeb {
    
    @Value("${projectname.retail.dev}")
    private String devUrl;
    @Value("${projectname.retail.sit}")
    private String sitUrl;
    @Value("${projectname.retail.uat}")
    private String uatUrl;

    protected WebDriver driver;
    public WebDriverWait wait;

    protected final WebDriverInterface webDriverInterface;

    public BasePageWeb(WebDriverInterface webDriverInterface) {
        this.webDriverInterface = webDriverInterface;
    }

    // env choices: lcl, dev, sit, uat -> app choices: web, id, emp, cxm -> browser choices: chrome, fire
    public void loadAny(String env, String app, String browser) {

        if (browser.equals("chrome")) {
            driver = this.webDriverInterface.getDriver();
        } else if (browser.equals("fire")) {
            driver = this.webDriverInterface.getDriverFire();
        }

        wait = new WebDriverWait(driver, 30);

        String url = "";
        String title = "";
        switch (app) {
            case "web":
                switch (env) {
                    case "dev":
                        url = devUrl;
                        title = "Log in to Project Name";
                        break;
                    case "sit":
                        url = sitUrl;
                        title = "Log in to Project Name";
                        break;
                    case "uat":
                        url = uatUrl;
                        title = "Log in to Project Name";
                        break;
                }
                break;
            default:
                System.out.println("There were no matches to your login choices.");
        }
        driver.get(url);
        wait.until(ExpectedConditions.titleContains(title));
    }
}

And then when I have specific topics where I can create methods that will apply only to that sub area, I extend out the base page, and I inject the sub page into the Steps pages.

@Component
public class MyAccountsPage extends BasePageWeb {


    public MyAccountsPage(WebDriverInterface webDriverInterface) {
        super(webDriverInterface);
    }

    // Find the Product Title Elements, Convert to Strings, and put them all in a simple List.

    public List<String> getAccountInfoTitles(){
        List<WebElement> accountInfoTitlesElements =
                driver.findElements(By.cssSelector("div > .bb-account-info__title"));
        return accountInfoTitlesElements.stream()
                                        .map(WebElement::getText)
                                        .collect(Collectors.toList());
    }
}

If anyone can see what I'm doing wrong, or make suggestions for investigation, I would appreciate it. I know there have been some major cucumber changes after 6.6.0 for how the framework scans for annotations and such, but I haven't been able to determine if that is related or not.

For reference. The pom.xml with all the versions and included dependencies.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xmlns="http://maven.apache.org/POM/4.0.0"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.3.3.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

    <groupId>java-cucumber-generic</groupId>
    <artifactId>java-cucumber-generic-web</artifactId>
    <version>1.0.1-SNAPSHOT</version>
    <packaging>jar</packaging>

    <properties>
        <java.version>1.8</java.version>
        <cucumber.version>6.6.0</cucumber.version>
        <junit.version>4.13</junit.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <port>8358</port>
        <cucumber.reporting.version>5.3.1</cucumber.reporting.version>
        <cucumber.reporting.config.file>automation-web/src/test/resources/projectname/cucumber-reporting.properties</cucumber.reporting.config.file>
        <org.mapstruct.version>1.3.1.Final</org.mapstruct.version>
        <h2database.version>1.4.200</h2database.version>
        <appium.java.client.version>7.3.0</appium.java.client.version>
        <guava.version>29.0-jre</guava.version>
        <reporting-plugin.version>4.0.83</reporting-plugin.version>
        <commons-text.version>1.9</commons-text.version>
        <commons-io.version>2.8.0</commons-io.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>io.cucumber</groupId>
            <artifactId>cucumber-java</artifactId>
            <version>${cucumber.version}</version>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>io.cucumber</groupId>
            <artifactId>cucumber-junit</artifactId>
            <version>${cucumber.version}</version>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>io.cucumber</groupId>
            <artifactId>cucumber-java8</artifactId>
            <version>${cucumber.version}</version>
            <scope>test</scope>
        </dependency>

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

        <!-- Added beyond original archetype -->
        <!-- https://mvnrepository.com/artifact/org.apache.maven.plugins/maven-surefire-plugin -->
        <dependency>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <version>2.12.4</version>
            <scope>test</scope>
        </dependency>

        <!-- To make Wait Until work -->
        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>${guava.version}</version>
        </dependency>

        <!-- Cucumber Reporting -->
        <dependency>
            <groupId>net.masterthought</groupId>
            <artifactId>cucumber-reporting</artifactId>
            <version>${cucumber.reporting.version}</version>
        </dependency>
        <dependency>
            <groupId>de.monochromata.cucumber</groupId>
            <artifactId>reporting-plugin</artifactId>
            <version>${reporting-plugin.version}</version>
        </dependency>


        <!-- For dependency injection https://cucumber.io/docs/cucumber/state/#dependency-injection -->
        <dependency>
            <groupId>io.cucumber</groupId>
            <artifactId>cucumber-spring</artifactId>
            <version>${cucumber.version}</version>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-rest</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.vintage</groupId>
                    <artifactId>junit-vintage-engine</artifactId>
                </exclusion>
            </exclusions>
        </dependency>

        <!-- https://mvnrepository.com/artifact/com.h2database/h2 -->
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <version>${h2database.version}</version>
            <scope>test</scope>
        </dependency>


        <!-- To generate getters, setters, equals, hascode, toString methods -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <!-- Java client, wrapped by Appium -->
        <dependency>
            <groupId>io.appium</groupId>
            <artifactId>java-client</artifactId>
            <version>${appium.java.client.version}</version>
            <scope>test</scope>
        </dependency>

        <!-- https://mvnrepository.com/artifact/org.apache.commons/commons-text -->
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-text</artifactId>
            <version>${commons-text.version}</version>
        </dependency>
        <!-- https://mvnrepository.com/artifact/commons-io/commons-io -->
        <dependency>
            <groupId>commons-io</groupId>
            <artifactId>commons-io</artifactId>
            <version>${commons-io.version}</version>
        </dependency>


    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
                <configuration>
                    <encoding>UTF-8</encoding>
                    <source>${java.version}</source>
                    <target>${java.version}</target>
                </configuration>
            </plugin>

            <!-- Added beyond original archetype -->

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>2.12.4</version>
                <configuration>
                    <testFailureIgnore>true</testFailureIgnore>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

1 Answers

You have two page classes MyAccountsPage and MyProfilePage. While both extend BasePageWeb and thus any instances of the MyAccountsPage and MyProfilePage are are also instances BasePageWeb they are not the same instance!

This can be quite confusing initially because usually there is only a single instance of each class and we treat the instance and the class as if they were the same thing. Rather think of the class as a template from which many instances can be made.

Now if you attach the debugger and inspect the pages before they are used you should see something like this:

MyAccountsPage@1001
 - WebDriver driver = null  <--- field inherited from BasePageWeb
 - other fields

MyProfilePage@1002 <--- different memory address, so different instance!
 - WebDriver driver = null   <--- field inherited from BasePageWeb 
 - other fields

So when you setup the WebDriver using the steps in AccountsSteps, the WebDriver is setup in MyProfilePagebut notMyProfilePage`.

MyAccountsPage@1001
 - WebDriver driver = Webdriver@1003  <-- This one was set.
 - other fields

MyProfilePage@1002
 - WebDriver driver = null   <--- This one is still null.
 - other fields

So when you try use the ProfileSteps that try to use MyProfilePage you end up with an null pointer exception because the instance of the WebDriver in MyProfilePage was never setup.

There are a few solutions here, but they all come down to keeping the webdriver in a single instance by making BasePageWeb a component and using composition instead of inheritance.

@Component
@ScenarioScope
public class BasePageWeb {
 ...
}
public class AccountsSteps {

    private final BasePageWeb basePageWeb;
    private final MyAccountsPage page;

    @Autowired
    public AccountsSteps(BasePageWeb basePageWeb, MyAccountsPage page){
        this.basePageWeb = basePageWeb;
        this.page = page;
    }

    @Given("Log into {string} on {string} as {string} with {string} using {string}")
    public void logIntoOnAsWithUsing(String app, String env, String user, String pass, String browser) {
        basePageWeb.loadAny(env, app, browser);
        page.sendUsername(user);
        page.sendPassword(pass);
        page.loginButtonClick();
    }
    ....

@Component
@ScenarioScope
public class MyAccountsPage {
    private final BasePageWeb basePageWeb;

    public MyAccountsPage(BasePageWeb basePageWeb) {
        this.basePageWeb = basePageWeb;
    }
    ...
}
@Component
@ScenarioScope
public class MyProfilePage {
    private final BasePageWeb basePageWeb;

    public MyProfilePage(BasePageWeb basePageWeb) {
        this.basePageWeb = basePageWeb;
    }
    ...
}
Related