Behavior, Content, Money – 3 Things you should never give away for free!!!

BCmoney MobileTV

SeleniumIDE “.side” JSON parser for “Durable Selectors”, more maintainable Test Automation

Posted by bcmoney on April 27, 2019 in Automated Testing, DevOps with No Comments


No Gravatar

Selenium is the leading open source Test Automation framework. Its WebDriver specification is in the process of being adopted by all the major browser vendors, with most having fairly complete support at this point. WebDriver is the spec document, language-specific SDK bindings, and associated browser-specific driver implementations that enable you to remotely control a browser in an automated manner. This uses a “selector” in the form of:

  • id – attribute “id” of the element
  • name – attribute “name” of the element
  • css selector – full CSS syntax querySelector like “div#search.find”
  • css path – dotted class names like “.my > .thing > .stuff”
  • xpath – xml path based retrieval like “//html/body/div”
  • linktext – “a” anchor tag text values

Choosing a selector for each “Test Step” in a given “Test Case” enables you to fire “commands” at your browser. Using this recipe we can create and run (record & playback) Selenium tests which automate the repetitive mind-numbing testing steps that a Tester would traditionally carry out manually, such as clicking around within a web application, mobile application or website; entering some data, submitting forms, clicking through E-Commerce flows to make purchases, interacting with specific on-screen elements, etc… all to simulate user behavior with your web/mobile application or website in the “real-world”. This frees up QA Testers for more intellectual work such as Performance Testing to help provide suggestions to speed up the app and support more users, Exploratory Testing “fuzz/negative tests” to try to break the system and find its boundaries by using intentionally wrong inputs, Security/Penetration Testing to identify vulnerabilities, Accessibility Tests to improve support for those with disabilities and regulatory compliance, Usability Tests to improve the UI to help end-users, A/B Tests with Marketing team to improve conversions, etc..

There’s just one catch, creating Selenium tests (and test automation in general) can be seen as a significant investment for most companies, one that certainly not all businesses will agree is worth the upfront cost (or that they’ll even fully understand the value of for that matter). This is a problem in today’s fast-moving marketplaces where if you don’t innovate and move with the speed of the market, you stand a real chance at becoming irrelevant and passed up for more “customer-centered” up and coming competitors. Even large enterprises who once enjoyed long lifetimes as monopolies, oligopolies, or simply “difficult to challenge incumbents” are increasingly seeing competition from all corners. Development teams in all companies are being asked to do more with less, and to keep pace; but how can we possibly move quickly and maintain quality, when all of our testing is manual and acts as a gatekeeper or bottleneck to releases getting out the door? This is what the DevOps series on this blog is meant to address.

What if we had a tool for recording and “playing back” a set of manual steps we’ve carried our one time? Could it be possible to “seed” our test automation this way? In fact, yes this is one possible way, and when it comes to Selenium, then SeleniumIDE for the longest time was the answer. Selenium IDE was historically considered as somewhat of the “black sheep” of the Test Automation industry. You wouldn’t kick it out of your Automated Testing flock (toolkit) completely, it still has useful wool to provide, but you might not be able to sell it as easily to a Dev, Ops or QA team as the “best” long-term solution for their test automation efforts. This became especially true recently as Mozilla’s FireFox team, announced they were deprecating their legacy NSAPI/XUL plugin frameworks in favor of the more standardized and modern W3C’s WebExtensions specification. FireFox being originally the only browser that SeleniumIDE 1.x-2.x was built to work on, this effectively set a ticking clock on the lifetime of SeleniumIDE’s usefulness, and as promised, immediately as FireFox 55 was released, QA engineers relying on SeleniumIDE recorded tests confirmed the death of the legacy test automation tool.

All seemed lost for “record & playback” test automation in Selenium, until Visual Test Automation company Applitools got behind an open source project aimed at a revival of SeleniumIDE. There’s pretty clear benefits to them in having an easy-to-use browser-based automation recording too:

  • it lowers the barrier to Test Automation for most Dev, Ops & QA teams out there (not all of which or all members of which will have coding skills to write a bunch of Selenium scripts by hand)
  • it helps companies seed/bootstrap a suite of Automated Tests where they may be in the unfortunate situation of having absolutely none to start out with
  • it puts Applitools on the map in Developer & QA engineer mindshare by contributing a worthwhile project to the open source community
  • folks who previously didn’t know anything about their company now are more likely to hear about them and at least give them a try
  • it enables companies to quickly get up and running with some recorded Selenium tests, then integrate those tests to Applitools’ visual testing (although of course there’s no requirement to do so, you can just go ahead with basic Test Automation and skip the visual stuff or even roll your own basic visual DIFF tool, a possible topic for a future article)

Whatever their main motivations, their efforts are certainly applauded by myself and many others in the open source & Test Automation communities. Documentation for SeleniumIDE in terms of the commands available can be found here: https://www.seleniumhq.org/selenium-ide/docs/en/introduction/getting-started/

You can download the following flavours of SeleniumIDE:

Installation is easy, but in case you need a walkthrough with screenshots this will take you through the main steps:
https://www.guru99.com/install-selenuim-ide.html

So that’s pretty much all the context you’ll need for why “Record & Playback” has value but if you’re still not convinced, listen to the following podcast and webinars:

SeleniumIDE “.side” recorded JSON test script file

Now on to the meat of this article, first we’ll take an example SIDE JSON File called “GoogleSearch.side” which is a recording that tests Google’s search engine’s question & answer capabilities by grabbing the search results for “who won ncaa basketball national championship 2019” and then compares the search results to the expected (known) March Madness tourney bracket winner:

{
  "id": "f57abcbd-20e2-4ba6-89be-89cdcffbea22",
  "version": "2.0",
  "name": "GoogleSearch",
  "url": "https://www.google.com",
  "tests": [{
    "id": "b49b4db8-3229-40aa-84a1-bc44a2b9c074",
    "name": "NCAA Basketball 2019 champs",
    "commands": [{
      "id": "3e09fee3-31e4-4f81-8d19-1ca20055787a",
      "comment": "",
      "command": "open",
      "target": "/",
      "targets": [],
      "value": ""
    }, {
      "id": "c99800fe-4eee-4c56-9936-ebf084989929",
      "comment": "",
      "command": "setWindowSize",
      "target": "2576x1416",
      "targets": [],
      "value": ""
    }, {
      "id": "2f2e1218-79f9-4e35-aa6c-95cdca77b11d",
      "comment": "searchTerm",
      "command": "executeScript",
      "target": "return \"who won ncaa basketball national championship 2019\";",
      "targets": [],
      "value": "KEYWORDS"
    }, {
      "id": "50612844-5e06-44ff-92d3-f7f1e4dfe462",
      "comment": "searchField",
      "command": "type",
      "target": "name=q",
      "targets": [
        ["name=q", "name"],
        ["css=.gLFyf", "css:finder"],
        ["xpath=//input[@name='q']", "xpath:attributes"],
        ["xpath=//form[@id='tsf']/div[2]/div/div/div/div/input", "xpath:idRelative"],
        ["xpath=//div/div/input", "xpath:position"]
      ],
      "value": "${KEYWORDS}"
    }, {
      "id": "ac079def-a9f5-42d8-ac83-ec032c6986ea",
      "comment": "This is a shortcut to simply send the ENTER key instead of finding the searchButton (in case it changes or fails)",
      "command": "//sendKeys",
      "target": "name=q",
      "targets": [],
      "value": "${KEY_ENTER}"
    }, {
      "id": "f5dc9d06-7b9f-4351-b408-b65ca3d6e10b",
      "comment": "searchButton",
      "command": "click",
      "target": "xpath=(//input[@type='submit'])",
      "targets": [
        ["css=center:nth-child(1) > input:nth-child(1)", "css:finder"],
        ["xpath=(//input[@name='btnK'])[2]", "xpath:attributes"],
        ["xpath=//form[@id='tsf']/div[2]/div/div[3]/center/input", "xpath:idRelative"],
        ["xpath=//div[3]/center/input", "xpath:position"]
      ],
      "value": ""
    }, {
      "id": "733dd64b-4636-484c-aea1-6e279bca8fc7",
      "comment": "",
      "command": "storeText",
      "target": "css=tr:nth-child(3) > .liveresults-sports-immersive__match-tile:nth-child(2) .imspo_mt__tr:nth-child(5) span:nth-child(2)",
      "targets": [
        ["css=tr:nth-child(3) > .liveresults-sports-immersive__match-tile:nth-child(2) .imspo_mt__tr:nth-child(5) span:nth-child(2)", "css:finder"],
        ["xpath=//div[@id='sports-app']/div/div[3]/div/table/tbody/tr[3]/td[2]/div/div/div/table/tbody/tr[5]/td[2]/div[2]/span[2]", "xpath:idRelative"],
        ["xpath=//tr[3]/td[2]/div/div/div/table/tbody/tr[5]/td[2]/div[2]/span[2]", "xpath:position"]
      ],
      "value": "TEAM1_NAME"
    }, {
      "id": "72943b4c-9577-450e-ab70-d19ba5b06dd1",
      "comment": "",
      "command": "storeText",
      "target": "css=tr:nth-child(3) > .liveresults-sports-immersive__match-tile:nth-child(2) .imspo_mt__tr:nth-child(5) .imspo_mt__tt-w:nth-child(1)",
      "targets": [
        ["css=tr:nth-child(3) > .liveresults-sports-immersive__match-tile:nth-child(2) .imspo_mt__tr:nth-child(5) .imspo_mt__tt-w:nth-child(1)", "css:finder"],
        ["xpath=//div[@id='sports-app']/div/div[3]/div/table/tbody/tr[3]/td[2]/div/div/div/table/tbody/tr[5]/td[2]/div/div", "xpath:idRelative"],
        ["xpath=//tr[3]/td[2]/div/div/div/table/tbody/tr[5]/td[2]/div/div", "xpath:position"]
      ],
      "value": "TEAM1_SCORE"
    }, {
      "id": "d5d36be3-9b2d-40ba-9b18-f0c2da08bb59",
      "comment": "searchResult",
      "command": "storeText",
      "target": "css=tr:nth-child(3) > .liveresults-sports-immersive__match-tile:nth-child(2) .imspo_mt__tr:nth-child(6) span:nth-child(2)",
      "targets": [
        ["css=tr:nth-child(3) > .liveresults-sports-immersive__match-tile:nth-child(2) .imspo_mt__tr:nth-child(6) span:nth-child(2)", "css:finder"],
        ["xpath=//div[@id='sports-app']/div/div[3]/div/table/tbody/tr[3]/td[2]/div/div/div/table/tbody/tr[6]/td[2]/div[2]/span[2]", "xpath:idRelative"],
        ["xpath=//tr[3]/td[2]/div/div/div/table/tbody/tr[6]/td[2]/div[2]/span[2]", "xpath:position"]
      ],
      "value": "TEAM2_NAME"
    }, {
      "id": "d9c03e9d-b3a7-418f-a374-3d8ed367e55a",
      "comment": "",
      "command": "storeText",
      "target": "css=tr:nth-child(3) > .liveresults-sports-immersive__match-tile:nth-child(2) .imspo_mt__tr:nth-child(6) .imspo_mt__tt-w:nth-child(1)",
      "targets": [
        ["css=tr:nth-child(3) > .liveresults-sports-immersive__match-tile:nth-child(2) .imspo_mt__tr:nth-child(6) .imspo_mt__tt-w:nth-child(1)", "css:finder"],
        ["xpath=//div[@id='sports-app']/div/div[3]/div/table/tbody/tr[3]/td[2]/div/div/div/table/tbody/tr[6]/td[2]/div/div", "xpath:idRelative"],
        ["xpath=//tr[3]/td[2]/div/div/div/table/tbody/tr[6]/td[2]/div/div", "xpath:position"]
      ],
      "value": "TEAM2_SCORE"
    }, {
      "id": "a230169a-79e8-4ce1-a49f-32d0800b0a65",
      "comment": "",
      "command": "echo",
      "target": "\"Team 1: '${TEAM1_NAME}' scored '${TEAM1_SCORE}'\"",
      "targets": [],
      "value": ""
    }, {
      "id": "d61d0ed3-5b1f-4f6a-a361-e851b9d15be5",
      "comment": "",
      "command": "echo",
      "target": "\"Team 2: '${TEAM2_NAME}' scored '${TEAM2_SCORE}'\"",
      "targets": [],
      "value": ""
    }, {
      "id": "f475ab05-e6dc-4fa4-83cc-5a969704e4f5",
      "comment": "",
      "command": "if",
      "target": "${TEAM1_SCORE} > ${TEAM2_SCORE}",
      "targets": [],
      "value": ""
    }, {
      "id": "25fd7ae9-4cb7-43cc-b511-49740109d5d7",
      "comment": "",
      "command": "echo \"${TEAM1_NAME} won the championship\"",
      "target": "",
      "targets": [],
      "value": ""
    }, {
      "id": "815610f8-89c0-4923-8942-9a49247fe2f7",
      "comment": "",
      "command": "executeScript",
      "target": "return ${TEAM1_NAME}",
      "targets": [],
      "value": "NCAA_MEN_BBALL_CHAMPS"
    }, {
      "id": "07f4fad0-ce47-48a9-9c50-ee326086d565",
      "comment": "",
      "command": "else",
      "target": "",
      "targets": [],
      "value": ""
    }, {
      "id": "aea133b3-6ca1-4df9-bc2d-1ebd04fcd4de",
      "comment": "",
      "command": "echo",
      "target": "\"${TEAM2_NAME} won the championship\"",
      "targets": [],
      "value": ""
    }, {
      "id": "176b60c9-8e1b-41b8-bc4a-257de3c872b4",
      "comment": "",
      "command": "executeScript",
      "target": "return ${TEAM2_NAME}",
      "targets": [],
      "value": "NCAA_MEN_BBALL_CHAMPS"
    }, {
      "id": "466504e4-a1d4-4955-aedd-94ccdaf2eeca",
      "comment": "",
      "command": "end",
      "target": "",
      "targets": [],
      "value": ""
    }, {
      "id": "b8b569ed-c935-405a-b357-e0c4704964bf",
      "comment": "",
      "command": "assertTitle",
      "target": "who won ncaa basketball national championship 2019 - Google Search",
      "targets": [],
      "value": ""
    }, {
      "id": "2c260494-76cc-486c-ba3a-2dce3f1fd07b",
      "comment": "ncaaMenBballChamps",
      "command": "verify",
      "target": "NCAA_MEN_BBALL_CHAMPS",
      "targets": [],
      "value": "Virginia"
    }, {
      "id": "27d0624c-62af-4516-b4ba-479c4fd2184f",
      "comment": "",
      "command": "echo",
      "target": "\"Confirmed '${NCAA_MEN_BBALL_CHAMPS}' won the 2019 NCAA Basketball nationals\"",
      "targets": [],
      "value": ""
    }]
  }],
  "suites": [{
    "id": "9461a691-e5f1-4795-9592-0b180f77eaae",
    "name": "Default Suite",
    "persistSession": false,
    "parallel": false,
    "timeout": 300,
    "tests": ["b49b4db8-3229-40aa-84a1-bc44a2b9c074"]
  }],
  "urls": ["https://www.google.com/"],
  "plugins": []
}

You should be able to try this example out by copy and pasting into a file called “GoogleSearch.side” and then opening in your own Selenium IDE.

What makes these selectors “durable” is that we define common short variable names for them in the “Description” field, which is used by the SideParser to lookup the associated Selector. That selector can easily be re-recorded, but as long as we keep the exact same “durable” variable name we will not need to update our original Java-based Test Case. Durable Selectors combined with a Page Object Model test suite and test case structure makes a fairly compelling Test Automation framework, that can easily be triggered on just about any Device/OS/Browser combo that is support by the Java Selenium SDK.

Depending on if Google changes their search results markup or format, of course, it could break (like all recorded tests, they are inherently brittle); but now that we have the approach of “Durable Selectors”, our Java Page Object Model tests no longer also have to be brittle. You could simply re-record the “.side” (even less-technical Business Analysts or QA team members be brought up to speed and can handle this), then use the parser provided below will kick in and help you dynamically pull out values you’re interested in from the recorded test script. This means for the first time, there really are minimal changes required in our Java-based WebDriver test scripts.

Java “.side” parser

Here’s the example “.side” JSON format parser in Java:

import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.File;
import java.io.FileNotFoundException;
import java.lang.reflect.Field;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.List;
import org.openqa.selenium.By;
import org.openqa.selenium.support.CacheLookup;
import org.openqa.selenium.support.pagefactory.AbstractAnnotations;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
 
/**
 * SideParser
 *  Parses recorded Selenium IDE 4.x Test Suites / Test Cases to grab relevant
 *  selectors numerically for each Test Step. These recordings are in SIDE JSON
 *  format.
 * 
 * @author bcmoney
 * @since 2019-04-27
 * @version 1.0.0
 */
public class SideParser extends AbstractAnnotations {

    //logging mechanism
    private static final Logger logger = LoggerFactory.getLogger(SideParser.class);
    
    private final Field field;

    // default constructor
    public SideParser(Field field) {
        this.field = field;
    }

    @Override
    public boolean isLookupCached() {
        return (field.getAnnotation(CacheLookup.class) != null);
    }

    private boolean isNullOrEmpty(String arg) {
        return ((null  == arg) || (arg.trim().isEmpty()));
    }
    
    /**
     * getLocatorFromTarget
     *  Gets the Locator from a "target" located within a given "Test" specified
     *  by the Selector lookup being applied.
     * 
     * @param target String  specific WebDriver Element locator 
     * @return By  WebDriver 3.x+ selector type-agnostic locator
     */
    public By getLocatorFromTarget(String target) {
        //match in order of precedence (desirability/durability) of target
        By findBy = null;
        if (target.startsWith("id=")) {
            findBy = new By.ById(target); //ID attribute
        } else if (target.startsWith("name=")) {
            findBy = new By.ByName(target); //NAME attribute
        } else if (target.startsWith("link=")) {
            findBy = new By.ByLinkText(target); //LINK text (exact match)
        } else if (target.startsWith("css=")) {
            findBy = new By.ByCssSelector(target); //CSS target
        } else if (target.startsWith("xpath=")) {
            findBy = new By.ByXPath(target); //xPath
        } else {
            findBy = new By.ByClassName(target); //class name
        }
        return findBy;
    }
    
    
    /**
     * buildBy
     *  Builds a generically usable locator from a specific Test Recording/Step.
     * 
     * @return By  WebDriver 3.x+ selector type-agnostic locator
     */
    @Override
    public By buildBy() {
        Selector selector = field.getAnnotation(Selector.class);
        if (selector == null) {
            logger.error("Failed to locate the annotation @Selector");
        } else {
            logger.info("Located @Selector annotation definition");
        }

        String page = selector.inPage();
        String testRecording = selector.testRecording();
        String testStep = selector.testStep();

        //validate PAGE
        if (isNullOrEmpty(page)) {
            logger.error("Page name is missing.");
        } else {
            logger.info("Page name being used: " + page);
        }

        //validate TEST RECORDING path
        if (isNullOrEmpty(testRecording)) {
            logger.error("Locators File name not provided");
        } else {
            logger.info("Test Recording being used: " + testRecording);
        }

        //validate TEST STEP path
        if (isNullOrEmpty(testStep)) {
            logger.error("Element name was not found.");
        } else {
            logger.info("Element name (from 'comment') being used: " + testStep);
        }

        //prefix desired test recording with appropriate full path required to load file
        testRecording = AutomatedTestingUtils.RECORDED_TESTS_PATH + testRecording;
        
        //validate TEST RECORDING file
        File file = new File(testRecording);
        if (null == file || !file.exists()) {
            logger.error("Unable to locate: " + testRecording);
        } else {
            logger.info("Loading SideX (JSON) test recording: " + testRecording);
        }
        
        By locatorFromSelector = null;
        try {
            //load raw Data
            String fileData = new String(Files.readAllBytes(Paths.get(testRecording)));
            // Get Jackson object
            ObjectMapper mapper = new ObjectMapper();
            TestRecording tr = mapper.readValue(fileData, TestRecording.class);
            logger.debug(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(tr));
           
            //validate 
            if (null == tr || tr.getTests().isEmpty()) {
                logger.error("No Test entry found for the page [" + page + "] in the locators file [" + testRecording + "]");
            } else {
                logger.info("Using test: " + tr);
            }

            logger.info("~Found the following Side JSON: " + tr);
            logger.info("~~Test Name: " + tr.getName());

            //get the actual Locator from the Selector used to popuate the Locator
            List tests = tr.getTests();
            for (Test test : tests) {
                logger.debug("~~~~Test: " + test);
                if (test.getName().equalsIgnoreCase(page)) {
                    List commands = test.getCommands();
                    for (Command command : commands) {
                        logger.debug("~~~~~~~~Command: " + command.getCommand());
                        if(command.getComment().equalsIgnoreCase(testStep)) {
                            String target = command.getTarget();
                            logger.debug("~~~~~~~~~~~~~~~~Target: " + target);
                            locatorFromSelector = getLocatorFromTarget(target);
                            logger.debug("~~~~~~~~~~~~~~~~Locator: " + locatorFromSelector);
                        }
                    }
                }                
            }
        } catch (FileNotFoundException e) {
            throw new RuntimeException(e);
        } catch (IOException ioEx) {
            throw new RuntimeException(ioEx);
        }

        return locatorFromSelector;
    }

}

You then need the following 4 POJO models (beans) to assist in the marshalling of data via the JSON parser:

  • TestRecording.java
  • Suite.java
  • Test.java
  • Command.java

First you need “TestRecording.java” to encapsulate the list of Test Suites and Tests covered by a given recording:

import java.util.List;

/**
 * TestRecording 
 *  Format-agnostic model of a given recorded test automation.
 * 
 * @author bcmoney
 * @since 2019-04-27
 * @version 1.0.0
 */
public class TestRecording {
    
    private String id;
    private float version;
    private String name;
    private String url;
    private List tests;
    private List suites;
    private List urls;
    private List plugins;

    
    // Getters & Setters
    public String getId() {
        return id;
    }
    public void setId(String id) {
        this.id = id;
    }
    
    public float getVersion() {
        return version;
    }
    public void setVersion(float version) {
        this.version = version;
    }
    
    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }
        
    public String getUrl() {
        return url;
    }
    public void setUrl(String url) {
        this.url = url;
    }
    
    public List getTests() {
        return tests;
    }
    public void setTests(List tests) {
        this.tests = tests;
    }
    
    public List getSuites() {
        return suites;
    }
    public void setSuites(List suites) {
        this.suites = suites;
    }
    
    public List getUrls() {
        return urls;
    }
    public void setUrls(List urls) {
        this.urls = urls;
    }
    
    public List getPlugins() {
        return plugins;
    }
    public void setPlugins(List plugins) {
        this.plugins = plugins;
    }

    
    /**
     * toString implementation to output basic data.
     * 
     * @return String of ID, Version, Name, URL, Test count
     */
    public String toString() {
        return getId() + " (v" + getVersion() + "), Name: " + getName() + ", BaseURL: " + getUrl() + ", Tests: " + getTests().size();
    }
    
}

Next, you need “Suite.java” which represents the top-level of each TestSuite within the “.side” JSON file:

import com.fasterxml.jackson.databind.JsonNode;

/**
 * Suite
 *  List of mappings between Tests and this Test Suite.
 * 
 * @author bcmoney
 * @since 2019-04-27
 * @version 1.0.0
 */
public class Suite {
    
    private String id;    
    private String name;
    private boolean persistSession;
    private boolean parallel;
    private int timeout;    
    private JsonNode tests;    
    
    // default constructor
    public Suite() {
        
    }
    
    
    // Getters & Setters */
    public String getId() {
        return id;
    }
    public void setId(String id) {
        this.id = id;
    }
    
    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }    

    public boolean getPersistSession() {
        return persistSession;
    }
    public void setPersistSession(boolean persistSession) {
        this.persistSession = persistSession;
    }
    
    public boolean getParallel() {
        return parallel;
    }
    public void setParallel(boolean parallel) {
        this.parallel = parallel;
    }
    
    public int getTimeout() {
        return timeout;
    }
    public void setTimeout(int timeout) {
        this.timeout = timeout;
    }
    
    // represented in the SideX JSON as "tests" this field maps one (or multiple) Tests to a single Test Suite
    public JsonNode getTests() {
        return tests;
    }
    public void setTests(JsonNode tests) {
        this.tests = tests;
    }
    
}

Each TestRecording contains 1 or more Tests which are represented by “Test.java”:

import java.util.List;

/**
 * Test
 *  Model for each individual Test in a given recording.
 * 
 * @author bcmoney
 * @since 2019-04-27
 * @version 1.0.0
 */
public class Test {

    private String id;    
    private String name;
    private List commands;

    // default constructor
    public Test() {
        
    }
    
    
    // Getters & Setters
    public String getId() {
        return id;
    }
    public void setId(String id) {
        this.id = id;
    }
    
    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }    

    public List getCommands() {
        return commands;
    }
    public void setCommands(List commands) {
        this.commands = commands;
    }

    /**
     * toString implementation to output basic data.
     * 
     * @return String of ID, Name, Command count
     */
    public String toString() {
        return "Test #: " + getId() + ", Name: " + getName() + ", Commands: " + getCommands().size();
    }
}

And lastly for the data models, each Test can have 1 or more Commands (i.e. Test Steps) represented by “Command.java”:

import java.util.List;

/**
 * Command
 *   List entry for each command (Test Step) in a given Test Case. NOTE: due to 
 *   the fact that the SideX JSON format does not support named Test Steps within
 *   a given Test Case, that the "comment" field must be used for named access 
 *   to specific selectors, for now this is an unfortunate reality and adherence 
 *   to this convention will be key to the "durable tests via recorded selectors"
 *   strategy.
 * 
 * @author bcmoney
 * @since 2019-04-27
 * @version 1.0.0
 */
public class Command {
    
    private String id;    
    private String comment;
    private String command;
    private String target;
    private List<String[]> targets;
    private String value;
    
    // Getters & Setters
    public String getId() {
        return id;
    }
    public void setId(String id) {
        this.id = id;
    }
    
    public String getComment() {
        return comment;
    }
    public void setComment(String comment) {
        this.comment = comment;
    }    

    public String getCommand() {
        return command;
    }
    public void setCommands(String command) {
        this.command = command;
    }
    
    public String getTarget() {
        return target;
    }
    public void setTarget(String target) {
        this.target = target;
    }
        
    public List<String[]> getTargets() {
        return targets;
    }
    public void setTargets(List<String[]> targets) {
        this.targets = targets;
    }
    
    public String getValue() {
        return value;
    }
    public void setValue(String value) {
        this.value = value;
    }
    

    /**
     * toString implementation to output basic data.
     * 
     * @return String of ID, Name, Command count
     */
    public String toString() {
        return "Test #: " + getId() + ", Comment: " + getComment() + ", Cmomand: " + getCommand() + ", Target: " + getTarget() + ", Value: " + getValue() + ", Additional Targets: " + getTargets().size();
    }
    
}

You then need to provide an interface for an “@Selector” annotation:

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * Selector
 *  Custom Annotation to enable "durable Test locators" by loading the respective
 *  IDs, Names, CSS selectors and/or xPath matches from the most recently recorded 
 *  version rather than any hardcoded values.
 * 
 * <p>
 * USAGE:
 * <code>
 *   @Selector (inPage = LoginPage.PAGE_, testRecording = "EN/PAM/LoginLoginTest.json", name = "checkBox")
 * </code>
 * </p>
 * 
 * @author bcmoney
 */

@Retention(RetentionPolicy.RUNTIME)
@Target (ElementType.FIELD)
public @interface Selector {

    //Page elementToMatch for the Page Object Model being evaluated
    String inPage() default "";

    //Test Recording used to lookup a given Locator within
    String testRecording() default "";
 
    //Name (from comment) of the Test Step whose locator to lookup its corresponding target/value of
    String testStep() default "";
       
}

Next, you need a Locator for the selector lookups “FileBasedElementLocator.java”:

import org.openqa.selenium.By;
import org.openqa.selenium.SearchContext;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.pagefactory.AbstractAnnotations;
import org.openqa.selenium.support.pagefactory.ElementLocator; 
import java.util.List;
 
/**
 * FileBasedElementLocator
 *  Custom ElementLocator used to load selectors via recorded test files.
 * 
 * @author bcmoney
 * @since 2019-04-27
 * @version 1.0.0
 */
public class FileBasedElementLocator implements ElementLocator {
 
    private final SearchContext searchContext;
    private final boolean shouldCache;
    private final By by;
    private WebElement cachedElement;
    private List cachedElementList;
 
 
    public FileBasedElementLocator(SearchContext searchContext, AbstractAnnotations annotations) {
        this.searchContext = searchContext;
        this.shouldCache = annotations.isLookupCached();
        this.by = annotations.buildBy();
    }
 
    @Override
    public WebElement findElement() {
        if (cachedElement != null && shouldCache) {
            return cachedElement;
        }
 
        WebElement element = searchContext.findElement(by);
        if (shouldCache) {
            cachedElement = element;
        }
 
        return element;
    }
 
    @Override
    public List findElements() {
        if (cachedElementList != null && shouldCache) {
            return cachedElementList;
        }
 
        List elements = searchContext.findElements(by);
        if (shouldCache) {
            cachedElementList = elements;
        }
 
        return elements;
    }
}

This SIDE parser can then be combined with the ever-popular “Page Object Model” approach via a custom annotation used to extract selectors from their comment (say for instance “username” for a username text input field), for truly more persistent and easily re-recordable “DURABLE SELECTORS” as follows:

import org.openqa.selenium.WebElement;
import static com.bryancopeland.selenium.AutomatedTestingUtils.getURL;
import static com.bryancopeland.selenium.AutomatedTestingUtils.loadDriver;
import com.bryancopeland.selenium.Selector;
import junit.framework.TestCase;
import static junit.framework.TestCase.assertEquals;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;

/**
 * GoogleSearch
 *   Page Object Model for the standalone Search Page.
 * 
 * @author bcmoney
 * @since 2018-12-27
 * @version 1.0.0
 */
public class GoogleSearch extends TestCase {
    
    private WebDriver driver;
    
    //Search Page name
    public static final String PAGE = "search";
    public static final String PAGE_TEST_NAME = "GoogleSearch";
    
    //dynamically retrieved field DURABLE SELECTORS
    @Selector(inPage = GoogleSearch.PAGE_TEST_NAME, testRecording = "/GoogleSearch.side", testStep = "searchTerm")
    private static WebElement searchTerm = null;

    @Selector(inPage = GoogleSearch.PAGE_TEST_NAME, testRecording = "/GoogleSearch.side", testStep = "searchField")
    private static WebElement searchField = null;   

    @Selector(inPage = GoogleSearch.PAGE_TEST_NAME, testRecording = "/GoogleSearch.side", testStep = "searchButton")
    private static WebElement searchButton = null;

    @Selector(inPage = GoogleSearch.PAGE_TEST_NAME, testRecording = "/GoogleSearch.side", testStep = "searchResult")
    private static WebElement searchResult = null; //the Search result of interest to extract selector of
    
        
    // default constructor
    public GoogleSearch() {   
    }
    


    // Unit Tests
    @Before
    public void setUp() throws Exception {
        driver = loadDriver(null); //loads the default driver, or override to load a specific one
    }
    
    
    /**
     * search
     *  Performs a Search action.
     */
    public void doSearch() {
        //standard Page Object Model reusable Selenium test, now with Durable Selectors
        if (searchButton.isDisplayed()) {
            searchButton.click();
        }
    }
    
    /**
     * search
     *  Populates a Search field and gets back the result.
     */
    public String search(String keywords, By customSelector) {
        //support a keyword override by passing in from any other class that wants to reuse the "search" method more generically
        String query = "";
        if (null == keywords || keywords.isEmpty() || null == customSelector) {
            query = searchTerm.toString().substring(searchTerm.toString().indexOf("\""), searchTerm.toString().lastIndexOf("\""));
            searchField.click();
            searchField.sendKeys(query);            
        } else { 
            query = keywords;
            String searchURL = "/"+PAGE+"?q="+query;
            driver.get(getURL(searchURL));
            doSearch();         
            return driver.findElement(customSelector).getText(); //return custom search result by a specific selector
        }
        return searchResult.toString();
    }
    
    /**
     * search
     *  Performs a Search action.
     */
    @Test
    public void isSearchResultCorrect(String expectedValue) throws Exception {
        String foundValue = search("", null);
        assertEquals(expectedValue, foundValue);        
    }

    
    @After
    public void tearDown() throws Exception {        
        driver.quit();
    }

}

If there’s a high interest in this post, I can start to share some more Test Automation patterns I’ve picked up over the past several years in my mission to understand and contribute to the DevOps movement. The code to build this out via custom annotation-based Durable Selectors is all there though and should be ready to help you build out an Automated Testing framework, so hopefully, by now you’ve gotten some good ideas on how to apply it to your own Dev, Ops & QA teams’ Test Automation suites!

You can download a Maven “starter project” here:

Conclusion

SeleniumIDE was all but vanished from FF 55’s release on 2017-08-08 up until Tomer Steinfeld (and later Dave Haefner) of Applitools came to its rescue with the alpha release of SeleniumIDE 3.x on 2017-12-11 which was a very early attempt to return just a few basic record & playback capabilities. The SeleniumIDE 3.x efforts have geared up substantially since then, with their biggest improvement and probably one of the most requested and long-awaited features of Code Export ot Java jUnit” dropping 2019-04-17.

With plans to add more formats including Python, Ruby, NodeJS, etc.. the future is looking bright again for SeleniumIDE and hopefully this article helped to shed some light on an approach for overcoming one of the most common concerns/shortcomings of “Record & Playback” in general, which is the durability of the selectors. Having durable selectors in all recordings by simply adding a uniquely named comment that will never change (i.e. “username”) will permit the Dev team via code changes, Marketing team via A/B tests, and any other team that needs to change the HTML structure to do so, as long as we can re-record and point to the new selector by that “username” comment, and we have Page Objects setup, then we’ve saved hours of rework from needing to re-write tests to update all the modified selectors that would wrongly fail our builds, or needing to resort to error-prone “mass find/replace refactors”.

Simply re-record in SeleniumIDE and re-label, save the “.side” recording which is a simple JSON file to our Java project, and our tests never need to just because of an HTML tweak, really only need to change at all if the business logic changes drastically. Hopefully you’ll agree this is a huge improvement that can benefit your Dev, Ops & QA teams!

REFERENCES

Leave a Reply

No trackbacks yet.

No post with similar tags yet.

No post with similar categories yet.

BC$ = Behavior, Content, Money

The goal of the BC$ project is to raise awareness and make changes with respect to the three pillars of information freedom - Behavior (pursuit of interests and passions), Content (sharing/exchanging ideas in various formats), Money (fairness and accessibility) - bringing to light the fact that:

1. We regularly hand over our browser histories, search histories and daily online activities to companies that want our money, or, to benefit from our use of their services with lucrative ad deals or sales of personal information.

2. We create and/or consume interesting content on their services, but we aren't adequately rewarded for our creative efforts or loyalty.

3. We pay money to be connected online (and possibly also over mobile), yet we lose both time and money by allowing companies to market to us with unsolicited advertisements, irrelevant product offers and unfairly structured service pricing plans.

  • Archives