Classic simple fluent builder usually suffers from some annoyances.
Let's look a this example:ComplexClass cc = ComplexClass.Builder() .addThis(42).setThat("I'm that").addSomethingOther("I'm other") .addYetAnother("yet yet yet").mixPudding(true).setChickenFeedingDevice(device) .addThis(99).withTimeout(5000).setThat("I'm another that").build()Disregarding silly and inconsistent method naming...
- large number of builder methods confuse user
- builder method can be mistakenly called multiple times
Having way to enforce order into method chaining will allow to build something more like wizzard or workflow which wiil simplify Builder usage greatly.
Let's introduce some interfaces according following rules- Any interface declares only subset of Builder methods
- Interface method return value is another interface instead of Builder instance
- Builder itself implements all interfaces
Together this basicaly forms very simple example of formal grammar where order in interface chaining represents production rules
To demonstrate idea just described, I built Selenium2/WebDriver WaitBuilder. It uses WbBegin interface as initial building point, WbAnd interface allowing to add multiple multiple conditions and finaly WbEnd with .seconds(int seconds) method instead of traditional .build().
Selenium has WebDriverWait class allowing conditional waits, which is very useful for testing pages, where elements appear dynamicaly or to perform assertion of Post/Redirect/Get (redirect after form submission) in time-boxed manner. SeleniumWaitBuilder allows to combine multiple conditions together.
It enables to write such cool chains such as...//pass test if "results-table" element will appear in 5 seconds, fail otherwise SeleniumWaitBuilder.with(driver) .passOn().element(By.id("results-table")).seconds(5); //pass test if title become "Example Domain" and in 5 seconds or fail immediately if it happen to contain "error" string SeleniumWaitBuilder.with(driver).passOn() .title().equals("Example Domain") .and().failOn() .title().contains("error") .seconds(5); SeleniumWaitBuilder.with(driver).passOn() .title().endsWith("Example Domain") .element(By.id("result-table")) .and().failOn() .title().contains("error") .url().contains("/500") .seconds(5);
And finally, hero of today's blog post - mighty SeleniumWaitBuilder itself!
I admit that this is overkill for most of the builders but still, it is neat...
package com.nature.quickstep.test; | |
import java.util.ArrayList; | |
import java.util.List; | |
import org.openqa.selenium.By; | |
import org.openqa.selenium.WebDriver; | |
import org.openqa.selenium.WebDriverException; | |
import org.openqa.selenium.WebElement; | |
import org.openqa.selenium.support.ui.ExpectedCondition; | |
import org.openqa.selenium.support.ui.ExpectedConditions; | |
import org.openqa.selenium.support.ui.WebDriverWait; | |
/** | |
* | |
* @author martin.vanek | |
* | |
*/ | |
public class SeleniumWaitBuilder { | |
public static WbBegin with(WebDriver driver) { | |
return new WaitBuilder(driver); | |
} | |
public interface WbBegin { | |
/** | |
* Positive condition outcome pass | |
*/ | |
public WbCondition passOn(); | |
/** | |
* Negative condition outcome kill | |
*/ | |
public WbCondition failOn(); | |
} | |
/** | |
* Terminal - execute conditional waiting | |
*/ | |
public interface WbEnd { | |
public void seconds(int seconds); | |
} | |
public interface WbAnd { | |
public WbBegin and(); | |
} | |
public interface WbNext extends WbAnd, WbCondition, WbEnd { | |
} | |
public interface WbCondition { | |
/** | |
* Wait for presence of element | |
*/ | |
public WbNext element(By locator); | |
/** | |
* Wait for url becomes... | |
* | |
* public WaitTime url(StringConditionType type, String value); | |
*/ | |
public StringCondition url(); | |
public StringCondition title(); | |
/** | |
* Wait for root tag become $lt;hmtl$gt; - use to skip redirects, because Selenium hides http status codes | |
*/ | |
public WbNext html(); | |
/** | |
* Generic condition method | |
*/ | |
public WbEnd condition(ExpectedCondition<?> condition); | |
} | |
public interface StringCondition { | |
public WbNext equals(String value); | |
public WbNext contains(String value); | |
public WbNext endsWith(String value); | |
public WbNext matches(String value); | |
} | |
public static class WaitBuilder implements WbBegin, WbCondition, WbNext, WbEnd { | |
private final WebDriver driver; | |
private boolean positive; | |
private ConditionsList pass = new ConditionsList(true); | |
private ConditionsList fail = new ConditionsList(false); | |
public WaitBuilder(WebDriver driver) { | |
this.positive = true; //important | |
this.driver = driver; | |
} | |
@Override | |
public WbCondition passOn() { | |
this.positive = true; | |
return this; | |
} | |
@Override | |
public WbCondition failOn() { | |
this.positive = false; | |
return this; | |
} | |
@Override | |
public WbBegin and() { | |
return this; | |
} | |
public WbNext element(By locator) { | |
if (locator == null) { | |
throw new IllegalArgumentException("Null element locator"); | |
} | |
ExpectedCondition<WebElement> condition = ExpectedConditions.presenceOfElementLocated(locator); | |
condition(condition); | |
return this; | |
} | |
public WbNext html() { | |
ExpectedCondition<WebElement> condition = ExpectedConditions.presenceOfElementLocated(By.xpath("/html")); | |
condition(condition); | |
return this; | |
} | |
public StringCondition url() { | |
return new WaitUrlImpl(); | |
} | |
public StringCondition title() { | |
return new WaitTitleImpl(); | |
} | |
@Override | |
public WbEnd condition(ExpectedCondition<?> condition) { | |
if (condition == null) { | |
throw new IllegalArgumentException("Null condition"); | |
} | |
if (positive) { | |
pass.add(condition); | |
} else { | |
fail.add(condition); | |
} | |
return this; | |
} | |
@Override | |
public void seconds(int seconds) { | |
WebDriverWait wait = new WebDriverWait(driver, seconds); | |
if (pass.size() != 0) { | |
if (fail.size() != 0) { | |
wait.until(new DualityCollectionCondition(pass, fail));//true or ErrorConditionException or TimeoutException | |
} else { | |
wait.until(pass); //true or TimeoutException | |
} | |
} else { | |
if (fail.size() != 0) { | |
try { | |
wait.until(fail); //ErrorConditionException or TimeoutException | |
} catch (org.openqa.selenium.TimeoutException tx) { | |
//SUCCESS - Error condition did not happened so we can leave | |
} | |
} else { | |
throw new IllegalStateException("Neither pass nor fail conditions"); | |
} | |
} | |
} | |
class WaitUrlImpl implements StringCondition { | |
@Override | |
public WbNext equals(String value) { | |
URLCondition condition = new URLCondition(value, StringConditionType.EQUALS); | |
condition(condition); | |
return WaitBuilder.this; | |
} | |
@Override | |
public WbNext contains(String value) { | |
URLCondition condition = new URLCondition(value, StringConditionType.CONTAINS); | |
condition(condition); | |
return WaitBuilder.this; | |
} | |
@Override | |
public WbNext endsWith(String value) { | |
URLCondition condition = new URLCondition(value, StringConditionType.ENDS_WITH); | |
condition(condition); | |
return WaitBuilder.this; | |
} | |
@Override | |
public WbNext matches(String value) { | |
URLCondition condition = new URLCondition(value, StringConditionType.MATCH_REGEX); | |
condition(condition); | |
return WaitBuilder.this; | |
} | |
} | |
class WaitTitleImpl implements StringCondition { | |
@Override | |
public WbNext equals(String value) { | |
TitleCondition condition = new TitleCondition(value, StringConditionType.EQUALS); | |
condition(condition); | |
return WaitBuilder.this; | |
} | |
@Override | |
public WbNext contains(String value) { | |
TitleCondition condition = new TitleCondition(value, StringConditionType.CONTAINS); | |
condition(condition); | |
return WaitBuilder.this; | |
} | |
@Override | |
public WbNext endsWith(String value) { | |
TitleCondition condition = new TitleCondition(value, StringConditionType.ENDS_WITH); | |
condition(condition); | |
return WaitBuilder.this; | |
} | |
@Override | |
public WbNext matches(String value) { | |
TitleCondition condition = new TitleCondition(value, StringConditionType.MATCH_REGEX); | |
condition(condition); | |
return WaitBuilder.this; | |
} | |
} | |
} | |
static class DualityCollectionCondition implements ExpectedCondition<Boolean> { | |
private ConditionsList passOn; | |
private ConditionsList failOn; | |
public DualityCollectionCondition(ConditionsList passOn, ConditionsList failOn) { | |
this.passOn = passOn; | |
this.failOn = failOn; | |
} | |
@Override | |
public Boolean apply(WebDriver driver) { | |
failOn.apply(driver); //false or ErrorConditionException | |
return passOn.apply(driver); //true or TimeoutException | |
} | |
@Override | |
public String toString() { | |
StringBuilder sb = new StringBuilder(); | |
if (passOn.conditions.size() == 1) { | |
sb.append(passOn.conditions.iterator().next()); | |
} else { | |
sb.append(passOn.conditions); | |
} | |
if (failOn.conditions.size() != 0) { | |
if (sb.length() != 0) { | |
sb.append(" and"); | |
} | |
sb.append(" not "); | |
if (failOn.conditions.size() == 1) { | |
sb.append(failOn.conditions.iterator().next()); | |
} else { | |
sb.append(failOn.conditions); | |
} | |
} | |
return sb.toString(); | |
} | |
} | |
static class ConditionsList implements ExpectedCondition<Boolean> { | |
private final boolean positive; | |
private List<ExpectedCondition<?>> conditions = new ArrayList<ExpectedCondition<?>>(); | |
public ConditionsList(boolean positive) { | |
this.positive = positive; | |
} | |
public int size() { | |
return conditions.size(); | |
} | |
public boolean isPositive() { | |
return positive; | |
} | |
@Override | |
public String toString() { | |
return "ConditionsList [positive=" + positive + ", conditions=" + conditions + "]"; | |
} | |
public void add(ExpectedCondition<?> condition) { | |
if (condition == null) { | |
throw new IllegalArgumentException("Null condition"); | |
} | |
conditions.add(condition); | |
} | |
@Override | |
public Boolean apply(WebDriver driver) { | |
if (positive) { | |
return applyPositive(driver); | |
} else { | |
return applyNegative(driver); | |
} | |
} | |
/** | |
* All conditions must be satisfied to return true | |
*/ | |
private boolean applyPositive(WebDriver driver) { | |
for (ExpectedCondition<?> condition : conditions) { | |
Object apply = condition.apply(driver); | |
if (apply == null) { | |
return false; //expected element not returned | |
} else { | |
if (apply instanceof Boolean) { | |
if (!(Boolean) apply) { | |
return false; //well false is false | |
} | |
} | |
} | |
} | |
return true; //nobody said no -> go! | |
} | |
/** | |
* return false or throw ErrorConditionException | |
*/ | |
private boolean applyNegative(WebDriver driver) { | |
for (ExpectedCondition<?> condition : conditions) { | |
Object result = condition.apply(driver); | |
if (result != null) { | |
if (result instanceof Boolean) { | |
if ((Boolean) result) { | |
throw new ErrorConditionException(condition); | |
} else { | |
return false; //keep checking | |
} | |
} else { | |
//anything else not null | |
throw new ErrorConditionException(result, condition); | |
} | |
} else { | |
return false; | |
} | |
} | |
return false; //nobody failed | |
} | |
} | |
public static class ErrorConditionException extends WebDriverException { | |
private static final long serialVersionUID = 1L; | |
private final ExpectedCondition<?> condition; | |
public ErrorConditionException(ExpectedCondition<?> condition) { | |
super(String.valueOf(condition)); | |
this.condition = condition; | |
} | |
public ErrorConditionException(Object applyResult, ExpectedCondition<?> condition) { | |
super("Result: " + applyResult + " for: " + condition); | |
this.condition = condition; | |
} | |
public ExpectedCondition<?> getCondition() { | |
return condition; | |
} | |
} | |
public static enum StringConditionType { | |
EQUALS, CONTAINS, ENDS_WITH, MATCH_REGEX; | |
} | |
static abstract class AbstractStringCondition implements ExpectedCondition<Boolean> { | |
private final String name; | |
private final String expectedValue; | |
private final StringConditionType type; | |
private final boolean positive; | |
private transient String currentValue; | |
protected abstract String getCurrentValue(WebDriver driver); | |
public AbstractStringCondition(String name, String value, StringConditionType type) { | |
this(name, value, type, true); | |
} | |
public AbstractStringCondition(String name, String value, StringConditionType type, boolean positive) { | |
if (name == null || name.length() == 0) { | |
throw new IllegalArgumentException("Blank condition name " + name); | |
} | |
this.name = name; | |
if (value == null || value.length() == 0) { | |
throw new IllegalArgumentException("Blank condition value " + value); | |
} | |
this.expectedValue = value; | |
if (type == null) { | |
throw new IllegalArgumentException("Null condition type"); | |
} | |
this.type = type; | |
this.positive = positive; | |
} | |
@Override | |
public Boolean apply(WebDriver driver) { | |
currentValue = getCurrentValue(driver); | |
if (currentValue == null) { | |
return false; | |
} | |
boolean result; | |
switch (type) { | |
case EQUALS: | |
result = currentValue.equals(expectedValue); | |
break; | |
case CONTAINS: | |
result = currentValue.contains(expectedValue); | |
break; | |
case ENDS_WITH: | |
result = currentValue.endsWith(currentValue); | |
break; | |
case MATCH_REGEX: | |
result = currentValue.matches(expectedValue); | |
break; | |
default: | |
throw new IllegalStateException("Unsupported type: " + type); | |
} | |
return positive ? result : !result; | |
} | |
@Override | |
public String toString() { | |
return String.format("%s \"%s\" %s \"%s\"", name, currentValue, type, expectedValue); | |
} | |
} | |
static class URLCondition extends AbstractStringCondition { | |
public URLCondition(String value, StringConditionType type, boolean positive) { | |
super("URL", value, type, positive); | |
} | |
public URLCondition(String value, StringConditionType type) { | |
super("URL", value, type, true); | |
} | |
@Override | |
protected String getCurrentValue(WebDriver driver) { | |
return driver.getCurrentUrl(); | |
} | |
} | |
static class TitleCondition extends AbstractStringCondition { | |
public TitleCondition(String value, StringConditionType type, boolean positive) { | |
super("Title", value, type, positive); | |
} | |
public TitleCondition(String value, StringConditionType type) { | |
super("Title", value, type, true); | |
} | |
@Override | |
protected String getCurrentValue(WebDriver driver) { | |
return driver.getTitle(); | |
} | |
} | |
} |