Adding Selenium Browser Tests to My Testcontainers Setup
Tue Jul 20 11:20:42 EDT 2021
- Tinkering With Testcontainers for Domino-based Web Apps
- Adding Selenium Browser Tests to My Testcontainers Setup
- Building a Full Domino Image for JUnit Tests
Yesterday, I talked about how I dove into Testcontainers for my app-testing needs. Today, I decided to use this to close another bit of long-open business: automated browser testing. I've been very much a dilettante when it comes to that, but we have a handful of browser-ish tests just to make sure the login page, the main page, and some utility pages load up and include expected content, and those can serve as a foundation for much more.
Background
In general, when you think "automated browser testing", that means Selenium. As a toolkit, Selenium has hooks for the browsers you want and has essentially universal support, working smoothly in Java with JUnit. However, the actual act of loading a real browser is miserable, mostly on account of needing you to install the browser and point to it programmatically, which is doable but is another potential system-specific configuration that I'd much, much rather avoid in my automated builds.
Accordingly, and because my needs have been simple, I've used HtmlUnit, which is a portable Java browser-like library that does the yeoman's work of letting you perform basic Selenium tests without having to configure actual native OS installations. It's neat, imposes basically no strictures on your workflow, and I recommend it for lots of uses. Still, it's not the same as real browsers, and I had to do things like disable JavaScript processing to avoid it tripping up on some funky JS that full-grown browsers can deal with.
Enter Webdriver Containers
So, now that I had Testcontainers configured to run the web app, my eye turned to Webdriver Containers, an ancillary capability of Testcontainers that lets you run these full-fledged browsers via their Docker images, and even has cool abilities like letting you record the screen interactions over VNC. Portability and full production representation? Sign me up.
The initial setup was pretty easy, just adding some dependencies for the Selenium remote driver (replacing my HtmlUnit driver) and the Testcontainers Selenium module:
1 2 3 4 5 6 7 8 9 10 11 12 | <dependency> <groupId>org.seleniumhq.selenium</groupId> <artifactId>selenium-remote-driver</artifactId> <version>3.141.59</version> <scope>test</scope> </dependency> <dependency> <groupId>org.testcontainers</groupId> <artifactId>selenium</artifactId> <version>1.15.3</version> <scope>test</scope> </dependency> |
Programmatic Container Setup
After that, my next task was to configure the containers. I'll skip over some of my troubleshooting and just describe where I ended up. Basically, since both the webapp and browsers are in Docker containers, I had to coordinate how they communicate with each other. There seem to be a few ways to do this, but the route I went was to build a Docker network in my container orchestration class, bind all of the containers to it, and then reference the app via a network alias.
With that addition and some containers for Chrome and Firefox, the class looks more like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | public enum AppTestContainers { instance; public final Network network = Network.builder() .driver("bridge") //$NON-NLS-1$ .build(); public final GenericContainer<?> webapp; public final BrowserWebDriverContainer<?> chrome; public final BrowserWebDriverContainer<?> firefox; @SuppressWarnings("resource") private AppTestContainers() { webapp = new GenericContainer<>(DockerImageName.parse("client-webapp-test:1.0.0-SNAPSHOT")) //$NON-NLS-1$ .withExposedPorts(8080) .withNetwork(network) .withNetworkAliases("client-webapp-test"); //$NON-NLS-1$ chrome = new BrowserWebDriverContainer<>() .withCapabilities(new ChromeOptions()) .withNetwork(network); firefox = new BrowserWebDriverContainer<>() .withCapabilities(new FirefoxOptions()) .withNetwork(network); webapp.start(); chrome.start(); firefox.start(); Runtime.getRuntime().addShutdownHook(new Thread(() -> { webapp.close(); chrome.close(); firefox.close(); network.close(); })); } } |
Now that they're all on the same Docker network, the browser containers are able to refer to the webapp like "http://client-webapp-test:8080".
Adding Parameterized Tests
The handful of UI tests I'd set up previously had lines like WebDriver driver = new HtmlUnitDriver(BrowserVersion.FIREFOX, true)
to create their WebDriver
instance, but now I want to run the tests with both real Firefox and real Chrome. Since I want to test that the app works consistently, I'll want the same tests across browsers - and that's a call for parameterized tests in JUnit.
The way parameterized tests work in JUnit is that you declare a test as being parameterized, and then feed it your parameters via one of a number of mechanisms - "all values of an enum", "this array of strings", and a handful of others. The one to use here is to make a class implementing ArgumentsProvider
and configure that:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | import java.util.stream.Stream; import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.ArgumentsProvider; import org.testcontainers.containers.BrowserWebDriverContainer; public class BrowserArgumentsProvider implements ArgumentsProvider { @Override public Stream<? extends Arguments> provideArguments(ExtensionContext context) throws Exception { return Stream.of( AppTestContainers.instance.chrome, AppTestContainers.instance.firefox ) .map(BrowserWebDriverContainer::getWebDriver) .map(Arguments::of); } } |
This class will take my configured browser containers, get the WebDriver
instance for each, and provide that as parameters to a test method. In turn, the test method looks like this:
1 2 3 4 5 6 7 8 | @ParameterizedTest @ArgumentsSource(BrowserArgumentsProvider.class) public void testDefaultLoginPage(WebDriver driver) { driver.get(getContainerRootUrl()); assertEquals("Expected App Title", driver.getTitle()); // Other tests follow } |
Now, JUnit will run the test twice, once for each browser, and I can add any other configurations I want smoothly.
Minor Gotcha: Container vs. Non-Container URLs
Though some of my tests were using Selenium already, most of them just use the JAX-RS REST client from the testing JVM directly, which is not containerized in this setup. That meant that I had to start worrying about the distinction between the URLs - the containers can't access "localhost:(some random port)", while the JUnit JVM can't access "client-webapp-test:8080".
For the most part, that's not too tough: I added some more utility methods named to suit and changed the UI tests to use those. However, there was one tricky bit: one of the UI tests uses Selenium to fetch the page and process the HTML, but then uses the JAX-RS client to make sure that a bunch of references on the page resolve to non-404 resources properly. Stuff like this:
1 2 3 4 5 | driver.findElements(By.xpath("//link[@rel='stylesheet']")) .stream() .map(link -> link.getAttribute("href")) .map(href -> rootUri.resolve(href)) .forEach(uri -> checkUrlWorks(uri, jaxRsClient)); |
(It's highly likely that there's a better way to do this in Selenium, but hey, it's still a useful example.)
The trouble with the above was that the URLs coming out of Selenium included the full container URL, not the host-accessible one.
Fortunately, that's not too tricky - it's really just string substitution, since the host and container URLs are known at runtime and won't conflict with anything. So I added a "decontainerize" method and run my URLs through it in the stream:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | public URI decontainerize(URI uri) { String url = uri.toString(); if(url.startsWith(getContainerRootUrl())) { return URI.create(getRootUrl() + url.substring(getContainerRootUrl().length())); } else { return uri; } } // later driver.findElements(By.xpath("//link[@rel='stylesheet']")) .stream() .map(link -> link.getAttribute("href")) .map(href -> rootUri.resolve(href)) .map(this::decontainerize) .forEach(uri -> checkUrlWorks(uri, jaxRsClient)); |
With that, all the results came back green again.
Overall, this was a little fiddly, but mostly in a way that helped me learn a little bit more about how this sort of thing works, and now I'm prepped to do real, portable full test suites. Neat!