This walkthrough builds the login test suite for the Sauce Labs Demo App from scratch. Follow along to see how the chapter concepts fit together in practice, then extend the same pattern for the product and checkout tests.
Step 1: Verify the session starts
Before writing any page objects, confirm the driver can create a session and find one element:
@Test
public void sessionCheck() {
AndroidDriver driver = (AndroidDriver) getDriver();
System.out.println("Current activity: " + driver.currentActivity());
WebElement usernameField = driver.findElement(
AppiumBy.accessibilityId("test-Username")
);
assertThat(usernameField.isDisplayed()).isTrue();
}Run this first. If it passes, your DriverManager, capabilities, and Appium connection are all working. Fix any failures here before moving on.
Step 2: Inspect the element tree
Open Appium Inspector with the same capabilities as your test. On the login screen, identify:
- Username field:
accessibility id = "test-Username" - Password field:
accessibility id = "test-Password" - Login button:
accessibility id = "test-LOGIN" - Error message:
accessibility id = "test-Error message"
Write these as constants in LoginPage.
Step 3: Build LoginPage
package com.saucelabs.pages;
import com.saucelabs.base.BasePage;
import io.appium.java_client.AppiumBy;
import io.appium.java_client.AppiumDriver;
import org.openqa.selenium.By;
public class LoginPage extends BasePage {
private static final By USERNAME = AppiumBy.accessibilityId("test-Username");
private static final By PASSWORD = AppiumBy.accessibilityId("test-Password");
private static final By LOGIN_BTN = AppiumBy.accessibilityId("test-LOGIN");
private static final By ERROR_MSG = AppiumBy.accessibilityId("test-Error message");
public LoginPage(AppiumDriver driver) {
super(driver);
}
public ProductListPage loginAs(String username, String password) {
waitForClickable(USERNAME).sendKeys(username);
waitForClickable(PASSWORD).sendKeys(password);
waitForClickable(LOGIN_BTN).click();
return new ProductListPage(driver);
}
public String getErrorMessage() {
return waitForVisible(ERROR_MSG).getText();
}
public boolean isErrorDisplayed() {
return isPresent(ERROR_MSG);
}
}Step 4: Write the login tests
package com.saucelabs.tests;
import com.saucelabs.base.BaseTest;
import com.saucelabs.pages.LoginPage;
import io.qameta.allure.*;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
import static org.assertj.core.api.Assertions.assertThat;
@Epic("Authentication")
@Feature("Login")
public class LoginTest extends BaseTest {
private LoginPage loginPage;
@BeforeMethod
public void openLoginPage() {
loginPage = new LoginPage(getDriver());
}
@Test(groups = {"smoke", "regression"})
@Story("Standard user login")
@Severity(SeverityLevel.BLOCKER)
public void standardUserCanLogin() {
ProductListPage products = loginPage.loginAs("standard_user", "secret_sauce");
assertThat(products.getProductCount())
.as("Product list should show at least 1 item after login")
.isGreaterThan(0);
}
@Test(groups = "regression")
@Story("Locked out user")
@Severity(SeverityLevel.CRITICAL)
public void lockedOutUserSeesError() {
loginPage.loginAs("locked_out_user", "secret_sauce");
assertThat(loginPage.getErrorMessage())
.contains("Sorry, this user has been locked out");
}
@DataProvider(name = "invalidCredentials")
public Object[][] invalidCreds() {
return new Object[][] {
{ "invalid_user", "secret_sauce", "Username and password do not match" },
{ "standard_user", "wrong_pass", "Username and password do not match" },
{ "", "", "Username is required" },
{ "standard_user", "", "Password is required" },
};
}
@Test(dataProvider = "invalidCredentials", groups = "regression")
@Story("Invalid credentials")
@Severity(SeverityLevel.NORMAL)
public void invalidCredentialsShowError(String username, String password, String expectedError) {
loginPage.loginAs(username, password);
assertThat(loginPage.getErrorMessage())
.as("Error for user='%s'", username)
.contains(expectedError);
}
}Step 5: Handle the keyboard on Android
On Android, the soft keyboard appears after sendKeys. If it covers the Login button, the click fails. Dismiss it before tapping:
public ProductListPage loginAs(String username, String password) {
WebElement user = waitForClickable(USERNAME);
user.clear();
user.sendKeys(username);
WebElement pass = waitForClickable(PASSWORD);
pass.clear();
pass.sendKeys(password);
// Dismiss keyboard
driver.hideKeyboard();
waitForClickable(LOGIN_BTN).click();
return new ProductListPage(driver);
}driver.hideKeyboard() works on both Android and iOS. If the keyboard isn't showing, it throws — wrap in a try-catch if needed.
Step 6: Run the smoke suite
testng-smoke.xml:
<suite name="Smoke" parallel="tests" thread-count="2">
<listeners>
<listener class-name="com.saucelabs.listeners.ScreenshotListener"/>
<listener class-name="com.saucelabs.listeners.RetryTransformer"/>
</listeners>
<test name="Android Login Smoke">
<parameter name="platform" value="Android"/>
<groups><run><include name="smoke"/></run></groups>
<classes><class name="com.saucelabs.tests.LoginTest"/></classes>
</test>
<test name="iOS Login Smoke">
<parameter name="platform" value="iOS"/>
<groups><run><include name="smoke"/></run></groups>
<classes><class name="com.saucelabs.tests.LoginTest"/></classes>
</test>
</suite>mvn test -DsuiteFile=testng-smoke.xmlExpected output: 1 test method × 2 platforms = 2 results, both passing in parallel.
Step 7: Interpret the Allure report
mvn allure:serveOpen the report and verify:
- Epic "Authentication" > Feature "Login" > Story "Standard user login" shows in the sidebar
- The failed test scenario (if any) has a screenshot attachment
- Environment shows the platform parameter value
If all scenarios pass and the report renders correctly, the login suite is complete. Apply the same structure to ProductTest and CheckoutTest using the locators you find in Appium Inspector.