Use streams for lists of web elements

2179048[1]

Every time you need to find multiple elements in your Selenium code, you get a list of web elements, so a collection.

Streams and predicates, introduced in Java 8, are the best way of working with collections.

When you use them, you don’t only get code that is shorter and easier to understand but also better performance.

Why is this useful for you, a Selenium WebDriver developer?

It is useful because instead of getting texts and attributes of all elements (matched by the same locator) with

private List< String > textValues(By locator) {
  List elements = driver.findElements(locator);

  List< String > values = new ArrayList<>();

  for (WebElement e : elements)
    values.add(e.getText());

  return values;
}

private List< String > attrValues(By locator, String name) {
  List elements = driver.findElements(locator);

  List< String > values = new ArrayList<>();

  for (WebElement e : elements)
    values.add(e.getAttribute(name));

  return values;
}

you can use

private List< String > textValues(By loc) {
  return getValues(loc, e -> e.getText());
}

private List< String > attrValues(By loc, String name) {
  return getValues(loc, e -> e.getAttribute(name));

private List< String > getValues(
By loc, Function<WebElement,String > pred) {

  List< WebElement > elements = driver.findElements(loc);       

  List< String > values = elements.stream().map(pred)
                                  .collect(Collectors.toList());

  return values;


}

Read along to understand why the second code version is much better.

How to use collections the old way

Assume that you have a Result class for results of a page:

public class Result {

  private String name;
  private int price;
  private String owner;
  private boolean onlineOnly;

  public Result(String name, int price, String owner, boolean onlineOnly) {
    this.name = name;
    this.price = price;
    this.owner = owner;
    this.onlineOnly = onlineOnly;
  }

  public String name() {
    return this.name;
  }

  public int price() {
    return this.price;
  }

  public String owner () {
    return this.owner;
  }

  public boolean onlineOnly() {
    return this.onlineOnly;
  }

  @Override
  public String toString() {
    return name + " - " + price + " - " + owner + " - " + onlineOnly;
  }

}

You need also a Results class (for lists of results):

public class Results {

  List< Result > results;

  public Results(List results) {
    this.results = results;
  }

  public List< Result > get() {
    return this.results;
  }

  public Result getResult(int i) {
    return this.results.get(i);
  }

  public int size() {
    return this.results.size();
  }

  @Override
  public String toString() {
    return this.results.toString();
  }

}

So far, Results class has methods for returning

  • the list of results
  • an element of the list
  • the size of the list
  • a String value for the list

Pretty basic, so far.

We would like to add filtering methods to the Results class such as:

public Results filterByOnlineOnly() {
  List< Result > list = new ArrayList()<>;

  for (Result r : this.results)
    if (r.onlineOnly() == true)
      list.add(r);

  return new Results(list);
}

public Results filterByOwner(String name) {
  List< Result > list = new ArrayList()<>;

  for (Result r : this.results)
    if (r.owner().equals(name))
      list.add(r);

  return new Results(list);
}

public Results filterByName(String name) {
  List< Result > list = new ArrayList()<>;

  for (Result r : this.results)
    if (r.name().equals(name))
      list.add(r);

  return new Results(list);
}

public Results filterByPrice(int price) {
  List< Result > list = new ArrayList()<>;

  for (Result r : this.results)
    if (r.price() == price)
      list.add(r);

  return new Results(list);
}

So far, we have filtering methods by name, owner, price and online only.

But, what should we do if we need more filtering methods such as

  • by price in a range
  • with a keyword in the name
  • with a keyword in the name and a specific owner

Adding more methods to the Results class is not the solution.

The more filters we need, the more methods.

One thing that is easy to notice is that all filtering methods have very similar code.

The only exception is the condition that the list elements should match.

How to use collections with streams and predicates

We can replace all methods with one method that uses a predicate parameter (for the filtering condition).

public Results filterBy(Predicate predicate) {

  List< Result > list = this.results.stream()
                  .filter(predicate)
                  .collect(Collectors.toList());

  return new Results(list);
}

How does this work?

  1. First, the results list is converted to a stream by the stream() method.
  2. Then, the filter() method filters the stream with the predicate . This is done by going through each element of the stream, applying the predicate to each element and selecting only the elements that match the predicate.
  3. Finally, collect() collects all selected elements and returns them as another list.

Amazing, isn’t it?

How can this method be used?

Results products = results.filterBy(r -> r.onlineOnly() == true);

Results products = results.filterBy(r -> r.name().equals("IPHONE 8"));

Results products = results.filterBy(r -> r.price() == 500);

We can get really creative now:

Results list = results.filterBy(r -> r.name().contains("IPHONE"));

Results products = results.filterBy(r -> r.name().contains("IPHONE") &&
                                         r.price() > 500 &&
                                         r.price() < 1000 &&
                                         r.onlineOnly() == false);

What else can you do with streams?

How about creating the list of all names, or all prices or all owners?

public List < String > values(Predicate predicate) {
  List list = this.results.stream()
                          .map(predicate)
                          .collect(Collectors.toList());

  return list;
}

Notice that the filter() method is replaced by the map().
map() extracts the value as specified by the predicate.
The method returns a list of Strings since names and owners are Strings:

List< String > owners = results.values(r -> r.owner());

List< String > names = results.values(r -> r.names());

There are many other things that can be done with streams and predicates:

Results onlineProducts = results.stream()
                                .filter(Result::onlineOnly)
                                .skip(1)
                                .collect(Collectors.toList());

Results cheapProducts = results.stream()
                           .filter(r -> r.price() <= 100)                                               .filter(r -> r.owner().equals("Rogers"))
                           .collect(Collectors.toList());

List< String > distinctOwners = results.stream()
                             .map(Result::owner)
                             .distinct()
                             .collect(Collectors.toList());

int priceSum = results.stream()
                      .map(Result::price)
                      .reduce(0, Integer::sum);

assertTrue(results.stream()
                  .anyMatch(r -> r.owner().equals("Bell")));

Java 8 In Action is a great book for learning more on streams and predicates.

Advertisements