Injecting the WebDriver not the manager.
Sorry! I forgot to mention last time that I was having a week off to spend some quality time with my long suffering wife and kids. I have added a fair deal of code, and I don’t plan on explaining all of it here, just the most relevant.
Last time, I managed to get dependency injection working, but not in a way that I was at all happy with. My decision to create WebDriverManager classes led me to create a WebDriverManager factory for injection. The problem with this is that in reality I don’t ever want a WebDriverManager to control within my tests. Clearly my WebDriverManager class itself should be the provider. The whole point of the manager class is that I should require only one, but it can create multiple instances of the require WebDriver type if required.
That means that I want to be able to inject an instance of my required WebDriverManager, and to use this instance as the WebDriver provider. So a quick look at the API and I realise that of course they provide the ability to bind an instance as a provider, not just a provider type. Embarrassingly easy to fix!
The changes to injection:
Change WebDriverManager to implement Provider<WebDriver>
and rename DriverManager.getDriver()
to DriverManager.get()
package tech.alexontest.poftutor.infrastructure.driver; import com.google.inject.Provider; import org.openqa.selenium.WebDriver; public interface WebDriverManager extends Provider<WebDriver> { String createDriver(); void startService(); void stopService(); void quitDriver(); WebDriver get(); }
and
/** * Return a running WebDriver instance controlling and instance of the requested browser. * @return the WebDriver to control the browser. */ @Override public WebDriver get() { if (null == driver) { startService(); createDriver(); } return driver; }
Then I set up my module as
package tech.alexontest.poftutor.infrastructure.driver; import com.google.inject.AbstractModule; import org.openqa.selenium.WebDriver; import tech.alexontest.poftutor.infrastructure.configuration.TestConfiguration; import tech.alexontest.poftutor.infrastructure.configuration.TestConfigurationFactory; public class DriverFactoryModule extends AbstractModule { private final TestConfiguration testConfiguration; private final DriverManagerFactory driverManagerFactory; public DriverFactoryModule() { this.testConfiguration = TestConfigurationFactory.getTestConfiguration(); this.driverManagerFactory = new DriverManagerFactory(this.testConfiguration); } @Override protected void configure() { final WebDriverManager webDriverManager = driverManagerFactory.get(); bind(TestConfiguration.class).toInstance(testConfiguration); bind(WebDriverManager.class).toInstance(webDriverManager); bind(WebDriver.class).toProvider(webDriverManager); } }
Note that I now I am generating a WebDriverManager
instance here and using it directly both for injecting itself, but also using this instance as my WebDriver
provider.
Now I can go back and directly inject a WebDriver
as required: e.g.
package tech.alexontest.poftutor.pages; import com.google.inject.Inject; import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebElement; import org.openqa.selenium.support.FindBy; import org.openqa.selenium.support.PageFactory; import java.util.List; import java.util.stream.Collectors; public final class HomePageDesktop extends AbstractPage implements HomePage { @FindBy(css = ".site-title") private WebElement title; @FindBy(css = ".widget") private List<WebElement> widgets; @FindBy(css = ".post-content") private List<WebElement> articles; @FindBy(css = ".site-info") private WebElement footerText; @FindBy(css = ".site-info a") private List<WebElement> footerLinks; @Inject public HomePageDesktop(final WebDriver webDriver) { super(webDriver); PageFactory.initElements(webDriver, this); } @Override public String getBrowserTabPageTitle() { return "Alexander on Testing – Adventures in Software Testing"; } @Override public String getURL() { return "https://alexanderontesting.com/"; } @Override public String getTitle() { return title.getText(); } @Override public List<WebElement> getWidgets() { return widgets; } @Override public List<WebElement> getArticles() { return articles; } @Override public String getFooterText() { return footerText.getText(); } @Override public List<String> getFooterLinks() { return footerLinks.stream() .map(we -> we.getAttribute("href")) .collect(Collectors.toList()); } }
You can see a couple of new methods in this class; more on those later: In my first commit I got this working for my standard case of a reused browser,
package tech.alexontest.poftutor.infrastructure; import com.google.inject.Guice; import com.google.inject.Inject; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.TestInstance; import tech.alexontest.poftutor.infrastructure.driver.DriverFactoryModule; import tech.alexontest.poftutor.infrastructure.driver.WebDriverManager; /** * Abstract Test class that reuses the same configured WebDriver for all tests. */ @TestInstance(TestInstance.Lifecycle.PER_CLASS) public abstract class AbstractSingleWebDriverTest { @Inject private WebDriverManager driverManager; @BeforeAll void prepare() { final DriverFactoryModule driverFactoryModule = new DriverFactoryModule(); Guice.createInjector(driverFactoryModule) .injectMembers(this); } @AfterAll void finalise() { System.out.println("Quitting Driver"); driverManager.quitDriver(); driverManager.stopService(); } }
Just generate the module and inject the members once in a method annotated with @BeforeAll
.
But what of the more typical case where each test method should create a new browser? Going back to having separate AbstractTest classes:
package tech.alexontest.poftutor.infrastructure; import com.google.inject.Guice; import com.google.inject.Inject; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.TestInstance; import tech.alexontest.poftutor.infrastructure.driver.DriverFactoryModule; import tech.alexontest.poftutor.infrastructure.driver.WebDriverManager; /** * Abstract Test class that creates a new configured WebDriver for each test. */ @TestInstance(TestInstance.Lifecycle.PER_CLASS) public abstract class AbstractMultipleWebDriverTest { // As I want to create multiple injector instances of the configured type I can reuse the module private DriverFactoryModule driverFactoryModule; @Inject private WebDriverManager driverManager; @BeforeAll void prepare() { driverFactoryModule = new DriverFactoryModule(); } @BeforeEach void setup() { Guice.createInjector(driverFactoryModule) .injectMembers(this); } @AfterEach void teardown() { System.out.println("Quitting Driver"); driverManager.quitDriver(); } @AfterAll void shutdown() { driverManager.stopService(); } }
We set up the module once only, but then create the injector and perform the injection for each test in a method annotated with @BeforeEach
: To show this in action a new test class:
package tech.alexontest.poftutor; import com.google.inject.Inject; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import tech.alexontest.poftutor.infrastructure.AbstractMultipleWebDriverTest; import tech.alexontest.poftutor.steps.HomePageSteps; @Tag("Content") class FooterTests extends AbstractMultipleWebDriverTest { @Inject private HomePageSteps homePageSteps; @BeforeEach void setupHomepage() { homePageSteps.loadHomePage(); } @Test @DisplayName("Footer Text is correct.") void footerTextIsCorrect() { homePageSteps.assertThatFooterTextIsCorrect(); } @Test @DisplayName("Footer Links are valid.") void footerLinksAreValid() { homePageSteps.assertThatFooterLinksAreNotBroken(); } }
Note that I have two new public steps methods that interact with the new methods in my page class earlier.
package tech.alexontest.poftutor.steps; import com.google.inject.Inject; import org.openqa.selenium.WebDriver; import tech.alexontest.poftutor.infrastructure.configuration.TestConfiguration; import tech.alexontest.poftutor.pages.HomePage; import java.io.IOException; import java.net.HttpURLConnection; import java.net.URL; import static org.assertj.core.api.Assertions.assertThat; import static tech.alexontest.poftutor.Constants.MAX_POSTS_PER_LISTING_PAGE; import static tech.alexontest.poftutor.Constants.WIDGETS_PER_PAGE; public class HomePageSteps { private final HomePage homePage; private final TestConfiguration testConfiguration; private final WebDriver webDriver; @Inject public HomePageSteps(final HomePage homePage, final TestConfiguration testConfiguration, final WebDriver webDriver) { this.homePage = homePage; this.testConfiguration = testConfiguration; this.webDriver = webDriver; } public void loadHomePage() { webDriver.navigate().to(testConfiguration.getHomePageUrl()); } public HomePageSteps attemptToLoadHomePageFromUrl(final String url) { webDriver.navigate().to(url); return this; } public void assertThatReportedUrlIsCorrect() { assertThat(webDriver.getCurrentUrl()) .as("Reported URL is not as expected") .isEqualTo(homePage.getURL()); } public void assertThatBrowserTabPageTitleIsCorrect() { assertThat(webDriver.getTitle()) .as("Reported browser tab page title is not as expected") .isEqualTo(homePage.getBrowserTabPageTitle()); } public void assertThatTitleIsCorrect() { assertThat(homePage.getTitle()) .isEqualToIgnoringCase("Alexander on Testing"); } public void assertThatPageContainsUpToFiveArticles() { assertThat(homePage.getArticles()) .size() .isLessThanOrEqualTo(MAX_POSTS_PER_LISTING_PAGE); } public void assertThatPageContainsFiveWidgets() { assertThat(homePage.getWidgets()) .size() .isEqualTo(WIDGETS_PER_PAGE); } public void assertThatFooterTextIsCorrect() { assertThat(homePage.getFooterText()) .as("FooterText is not as expected.") .isEqualTo("© 2018 | Proudly Powered by WordPress | Theme: Nisarg"); } public void assertThatFooterLinksAreNotBroken() { homePage.getFooterLinks() .forEach(this::checkUrlExists); } /** * Crude check that the url leads to a valid page. * @param url the url to check */ private void checkUrlExists(final String url) { try { HttpURLConnection.setFollowRedirects(false); final HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); conn.setRequestMethod("HEAD"); assertThat(conn.getResponseCode()) .isEqualTo(HttpURLConnection.HTTP_OK); } catch (final IOException e) { throw new AssertionError(String.format("Link '%s' does not return a valid response", url)); } } }
Note that constructors can easily become quite long, and I find them easiest to read by formatting each parameter on its own line (24-26) but you do have to keep an eye on this. This is particularly true for steps classes. As we will soon see once we start to navigate away, I will want instances of the follow on steps classes to pass on as return types. This allows my test classes to be mostly programmed against a fluent interface at the cost of more complex steps classes.
I decided to have a look at my DriverFactoryTests as a result of these changes and realised that there were improvements I could make, both to the tests and the DriverManager classes.
All the effort about setting up a serviceManager was to save a little time when running a test class with many tests. Unfortunately as could easily be seen in my original ChromeDriverManager class, this was only used in firing up a local WebDriver. It is actually irrelevant to calling to an external grid. Although when pottering around trying stuff out, this is great. In reality you are unlikely to run your production tests without using some kind of grid, whether local as with mine, or through a provider such as BrowserStack or SauceLabs. So whilst all this work has been good for learning, I am pretty sure that I wouldn’t bother again if creating a production test framework. As I have done it already though; I might as well implement local options for all available browsers, and add a little tweak to only fire up the service when running tests locally.
e.g.
package tech.alexontest.poftutor.infrastructure.driver; import org.openqa.selenium.firefox.FirefoxDriver; import org.openqa.selenium.firefox.FirefoxDriverLogLevel; import org.openqa.selenium.firefox.FirefoxOptions; import org.openqa.selenium.firefox.GeckoDriverService; import org.openqa.selenium.remote.RemoteWebDriver; import java.io.File; import java.io.IOException; public final class FirefoxDriverManager extends AbstractDriverManager implements WebDriverManager { private GeckoDriverService geckoDriverService; private final File geckoDriverExe; private final boolean isLocal; private final boolean isHeadless; FirefoxDriverManager(final boolean isLocal, final boolean isHeadless) { final String path = getClass().getClassLoader().getResource("geckodriver.exe").getPath(); geckoDriverExe = new File(path); System.setProperty("webdriver.gecko.driver", path); this.isLocal = isLocal; this.isHeadless = isHeadless; } @Override public void startService() { if (!isLocal) { return; } if (null == geckoDriverService) { try { geckoDriverService = new GeckoDriverService.Builder() .usingDriverExecutable(geckoDriverExe) .usingAnyFreePort() .build(); geckoDriverService.start(); } catch (final IOException e) { e.printStackTrace(); } System.out.println("GeckoDriverService Started"); } } @Override public void stopService() { if (null != geckoDriverService && geckoDriverService.isRunning()) { geckoDriverService.stop(); System.out.println("GeckoDriverService Stopped"); } } @Override public String createDriver() { final FirefoxOptions options = new FirefoxOptions() .setLogLevel(FirefoxDriverLogLevel.ERROR); //to stop the debug spam // add additional options here as required if (!isLocal) { setDriver(new RemoteWebDriver(getGridUrl(), options)); } else { if (isHeadless) { options.addArguments("--headless"); } setDriver(new FirefoxDriver(geckoDriverService, options)); } System.out.println("FirefoxDriver Started"); return options.getBrowserName(); } }
Note that for Firefox I have also added the headless option as I did for Chrome, so I can run tests silently even if my grid is down. I don’t believe any others currently offer this option.
My first use of dynamic test parameterisation:
I previously used DriverFactoryTests.java
for my exploration of different parameter providers for parameterised tests. By now it was getting a mess however, and the use of an external csv in particular was a total pain. Ideally I don’t want to have to write test cases, it would be nice if every valid DriverType is automatically tested. Well, as DriverType is in fact an Enum
, we can use the @EnumSource
annotation to do just that.
package tech.alexontest.poftutor; import org.junit.jupiter.api.Tag; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; import org.openqa.selenium.JavascriptExecutor; import org.openqa.selenium.WebDriver; import tech.alexontest.poftutor.infrastructure.AbstractCrossBrowserTest; import tech.alexontest.poftutor.infrastructure.driver.DriverManagerFactory; import tech.alexontest.poftutor.infrastructure.driver.DriverType; import static org.assertj.core.api.Assertions.assertThat; @Tag("Framework") class DriverFactoryTests extends AbstractCrossBrowserTest { @ParameterizedTest(name = "{0} successfully launches.") //Run all DriverTypes @EnumSource(DriverType.class) //Examples of how to filter which tests to run // Debugging one that is failing. //@EnumSource(value = DriverType.class, names = "FIREFOX_LOCAL_HEADLESS") // Exclude some values. //@EnumSource(value = DriverType.class, mode = EXCLUDE, names = {"OPERA_LOCAL", "FIREFOX_LOCAL"}) // Match a set of values e.g. run all local browsers only. //@EnumSource(value = DriverType.class, mode = MATCH_ALL, names = "^.*LOCAL.*$") void driverFactoryWorks(final DriverType driverType) { final String browserName = driverType.getWebdriverName(); setDriverManager(DriverManagerFactory.getManager(driverType)); getDriverManager().startService(); assertThat(getDriverManager().createDriver()) .as(String.format("Checking That browser is of type %s", browserName)) .isEqualToIgnoringCase(browserName); final String homePageURL = "https://www.example.com/"; final WebDriver driver = getDriver(homePageURL); final String agentString = (String) ((JavascriptExecutor) driver).executeScript("return navigator.userAgent;"); assertThat(agentString) .as("AgentString does not match pattern for %s", driverType.name()) .containsPattern(driverType.getRegex()); assertThat(driver.getCurrentUrl()) .as(String.format( "Check that '%1$s' successfully loads the homepage '%2$s'", driverType.name(), homePageURL )) .isEqualToIgnoringCase(homePageURL); } }
The write ups that I read about it made a big fuss about how only @EnumSource
only supports a single parameter, but of course each member of an Enum can contain multiple fields, so I merely need to add them to the enum, in this case the reported WebDriverName.
I should comment how much I hate commented out code, but in this case it is for the purpose of illustrating how to alter the code to run only a subset of the tests. With 11 browser options now running I don’t want to have to run them all again if I am trying to debug just one: This is the real downside of parameterised testing, especially when you have test data spread through many different csv files. In the case of @EnumSource the junit 5 team conventiently gave us some filtering options to try. I include a simple example of each here for you. I might :
- use the (default) include mode where I just want to debug a single browser.
- use the exclude mode where I have a temporary problem with a supported browser.
- ,use the regex filtering to limit it to local only testing if for example, my grid hub was out of action.
package tech.alexontest.poftutor.infrastructure.driver; import java.util.regex.Pattern; @SuppressWarnings("squid:S1192") public enum DriverType { CHROME("^(?!.*?\\bOPR/\\b)^(?!.*?\\bEdge/\\b).*?\\bChrome/\\b.*?\\bSafari/\\b.*$", "chrome"), CHROME_LOCAL("^(?!.*?\\bOPR/\\b)^(?!.*?\\bEdge/\\b).*?\\bChrome/\\b.*?\\bSafari/\\b.*$", "chrome"), CHROME_LOCAL_HEADLESS("HeadlessChrome", "chrome"), EDGE("Edge", "MicrosoftEdge"), EDGE_LOCAL("Edge", "MicrosoftEdge"), FIREFOX("Firefox", "firefox"), FIREFOX_LOCAL("Firefox", "firefox"), FIREFOX_LOCAL_HEADLESS("Firefox", "firefox"), IE("rv:11.0", "internet explorer"), IE_LOCAL("rv:11.0", "internet explorer"), OPERA_LOCAL("OPR/", "operablink"),; private final String webdriverName; private final Pattern regex; DriverType(final String regexString, final String webdriverName) { this.regex = Pattern.compile(regexString); this.webdriverName = webdriverName; } public Pattern getRegex() { return regex; } public String getWebdriverName() { return webdriverName; } }
Note here my first (I think) need to suppress a warning (line 5) for the SonarLint static checker that I have running. In this case it is for repeated use of a String literal. I would usually use a constant within the class for such details, but this cannot be done in an Enum. I could have added them into my general constants file, but as far as possible I like to keep data like this exactly where I use it.
Progress made:
- Dependency Injection now injects the WebDriver directly.
- AbstractTests configured for single use and reusable WebDriver instances.
- All browsers now support local testing.
- The DriverService is only launched for local browser testing.
- The DriverFactoryTest class now uses a single parameterisation strategy that by default tests all DriverTypes.
Lessons learnt:
- The DriverManager business was only really worthwhile as a learning tool.
- @EnumSource is a pretty cool way to parameterise tests.
- I think I can now say I feel pretty confident to set up automated dependency injection on a new project.
- Getting the balance between making improvements when the need / opportunity becomes clear and staying focused on the main purpose can be a challenge. In a professional team environment I would want a product owner to be around at times to help decide whether to refactor now or add it to the backlog.
- Two weeks of occasional coding can be a lot of progress.
Next time I’ll be starting to look at how to implement PageBlocks to operate the PageFactory on only a class representing only a section of a webpage.