Guideline about Page object model on Selenium document

Dahun Yoo·2023년 11월 28일
0

Page Object Model

목록 보기
5/5

Selenium의 공식문서에 기재되어있는, 페이지오브젝트모델에 대한 가이드라인에 대해 살펴봅니다.

What is Page Object Model?

페이지 오브젝트 모델은 어떤 어플리케이션의 UI가 포함되어있는 하나의 "페이지" 에 대해, 코드상에서 객체로 만들어서 테스트 코드내에서 사용하는 것입니다.

중요한 점은 이 페이지 객체를 위한 코드와 테스트 코드를 분리시키는 것입니다. (페이지 코드의 캡슐화를 통하여 테스트코드와의 분리) UI와 관련된 코드 및 로직들을 코드 상의 하나의 부분으로 몰아서 관리한다면, 시스템의 다른 구성요소에 영향을 최소화하면서 UI코드를 수정할 수 있습니다.

페이지 객체를 위한 코드와 테스트 코드를 분리시키는 것으로 인해 많은 코드의 중복을 줄일 수 있고, 테스트 코드와 페이지 코드가 분리되어있어 UI가 변경되어 코드의 수정이 발생하여도 테스트 코드에 영향을 적게 끼칠 수 있게 됩니다.

Example

/***
 * Tests login feature
 */
public class Login {

  public void testLogin() {
    // fill login data on sign-in page
    driver.findElement(By.name("user_name")).sendKeys("userName");
    driver.findElement(By.name("password")).sendKeys("my supersecret password");
    driver.findElement(By.name("sign-in")).click();

    // verify h1 tag is "Hello userName" after login
    driver.findElement(By.tagName("h1")).isDisplayed();
    assertThat(driver.findElement(By.tagName("h1")).getText(), is("Hello userName"));
  }
}

위 코드를 예시로 들자면,

  • 테스트 대상이 되는 프로그램의 로케이터와, 직접적으로 테스트를 수행하는 어떠한 일련의 행위, 테스트 체크 로직이 하나의 메소드 안에 있습니다. 때문에 프로그램의 로케이터나 UI가 변경된다면 메소드 자체가 수정되어야합니다.
  • 동일한 UI요소들이 여러 테스트케이스에서 사용하고 있다면, 해당 케이스(메소드)들을 전부 수정해주어야 합니다.

위 코드는 로그인페이지와, 로그인 후의 홈페이지에 대한 코드가 같이 섞여있는 상태입니다. 위 코드에 대해 페이지오브젝트 모델을 적용한다면 아래와 같이 바꾸어볼 수 있습니다.

/**
 * Page Object encapsulates the Sign-in page.
 */
public class SignInPage {
  protected WebDriver driver;

  // <input name="user_name" type="text" value="">
  private By usernameBy = By.name("user_name");
  // <input name="password" type="password" value="">
  private By passwordBy = By.name("password");
  // <input name="sign_in" type="submit" value="SignIn">
  private By signinBy = By.name("sign_in");

  public SignInPage(WebDriver driver){
    this.driver = driver;
     if (!driver.getTitle().equals("Sign In Page")) {
      throw new IllegalStateException("This is not Sign In Page," +
            " current page is: " + driver.getCurrentUrl());
    }
  }

  /**
    * Login as valid user
    *
    * @param userName
    * @param password
    * @return HomePage object
    */
  public HomePage loginValidUser(String userName, String password) {
    driver.findElement(usernameBy).sendKeys(userName);
    driver.findElement(passwordBy).sendKeys(password);
    driver.findElement(signinBy).click();
    return new HomePage(driver);
  }
}

public class HomePage {
  protected WebDriver driver;

  // <h1>Hello userName</h1>
  private By messageBy = By.tagName("h1");

  public HomePage(WebDriver driver){
    this.driver = driver;
    if (!driver.getTitle().equals("Home Page of logged in user")) {
      throw new IllegalStateException("This is not Home Page of logged in user," +
            " current page is: " + driver.getCurrentUrl());
    }
  }

  /**
    * Get message (h1 tag)
    *
    * @return String message text
    */
  public String getMessageText() {
    return driver.findElement(messageBy).getText();
  }

  public HomePage manageProfile() {
    // Page encapsulation to manage profile functionality
    return new HomePage(driver);
  }
  /* More methods offering the services represented by Home Page
  of Logged User. These methods in turn might return more Page Objects
  for example click on Compose mail button could return ComposeMail class object */
}

테스트 코드는 아래와 같이 간결해질 것 입니다.

/***
 * Tests login feature
 */
public class TestLogin {

  @Test
  public void testLogin() {
    SignInPage signInPage = new SignInPage(driver);
    HomePage homePage = signInPage.loginValidUser("userName", "password");
    assertThat(homePage.getMessageText(), is("Hello userName"));
  }

}

위와 같이 수정한다면, 페이지의 UI가 변경이 되어도 리턴값의 형태만 유지한다면 테스트코드는 수정하지 않아도 되는, 좀 더 유연한 코드를 작성할 수 있습니다.

Assertions in Page object

페이지오브젝트 모델을 이용하여 코드를 작성할 때에는 원칙적으로는 테스트로직과 페이지의 UI로부터 발생하는 Interaction과 그로 인한 행위는 분리하는 것이 맞습니다. 그렇지 않으면 페이지오브젝트모델을 차용하는 의미가 없어지기 때문입니다. 따라서 assert 키워드를 통한 테스트 행위는, 페이지 오브젝트 코드 내에서 작성하면 안되는 것 입니다.

그러나 단 하나, 페이지 코드 내에서 어떠한 체크로직을 넣어야하는데, 그것은 해당 페이지가 제대로 로딩되었는지를 확인하는 로직을 생성자 내부에 작성해 놓는 것입니다.

public class HomePage {
  protected WebDriver driver;

  // <h1>Hello userName</h1>
  private By messageBy = By.tagName("h1");

  public HomePage(WebDriver driver){
    this.driver = driver;
    if (!driver.getTitle().equals("Home Page of logged in user")) {
      throw new IllegalStateException("This is not Home Page of logged in user," +
            " current page is: " + driver.getCurrentUrl());
    }
  }

위 코드를 예시로 든다면, HomePage() 생성자 내부에서 driver.getTitle()을 이용해 특정내용을 확인하고, 해당 페이지가 제대로 로딩이 되었는지를 확인하고, 그렇지 않으면 Exception을 throw하고 있습니다.
이렇게 생성자 내부에서 체크하는 이유는, 이 로직을 통과해서 객체가 생성되어야지만, 이 객체를 이용하여 페이지의 UI와 interact하고 그 결과를 이용해 테스트를 할 수 있습니다.

Page Component Objects

페이지 오브젝트 모델은, 페이지를 하나의 객체로 표현하는 방법이긴 하나, 페이지 내의 모든 요소들에 대해 전부 구현해야할 필요는 없습니다. 약 10여년 전에 마틴 파울러(Martin fowler) 는 panel object 라고도 말했었는데, 페이지의 중요한 요소들에 대해서만 나타내도 되는 것 입니다.

이 것은 전체 페이지에 대해서 객체로 표현하고, 다시 객체 내의 중요한 요소에 대해 다시 객체로 표현하는 것 입니다.

예를 들어 여러 Product를 표현하는 Products 라는 요소가 있다고 해봅시다.

<!-- Products Page -->
<div class="header_container">
    <span class="title">Products</span>
</div>

<div class="inventory_list">
    <div class="inventory_item">
    </div>
    <div class="inventory_item">
    </div>
    <div class="inventory_item">
    </div>
    <div class="inventory_item">
    </div>
    <div class="inventory_item">
    </div>
    <div class="inventory_item">
    </div>
</div>
<!-- Inventory Item -->
<div class="inventory_item">
    <div class="inventory_item_name">Backpack</div>
    <div class="pricebar">
        <div class="inventory_item_price">$29.99</div>
        <button id="add-to-cart-backpack">Add to cart</button>
    </div>
</div>

이것을 객체로 표현한다고 한다면, 어떠한 하나의 페이지에, Product 요소를 여러개 가지고 있는 Products 가 있을 것 입니다.

이것은 아래와 같이 표현할 수 있을 것 입니다.

// Page Object
public class ProductsPage extends BasePage {
    public ProductsPage(WebDriver driver) {
        super(driver);
        // No assertions, throws an exception if the element is not loaded
        new WebDriverWait(driver, Duration.ofSeconds(3))
            .until(d -> d.findElement(By.className​("header_container")));
    }

    // Returning a list of products is a service of the page
    public List<Product> getProducts() {
        return driver.findElements(By.className​("inventory_item"))
            .stream()
            .map(e -> new Product(e)) // Map WebElement to a product component
            .toList();
    }

    // Return a specific product using a boolean-valued function (predicate)
    // This is the behavioral Strategy Pattern from GoF
    public Product getProduct(Predicate<Product> condition) {
        return getProducts()
            .stream()
            .filter(condition) // Filter by product name or price
            .findFirst()
            .orElseThrow();
    }
}

getProducts()Product 객체가 하나 이상 담긴 리스트를 리턴합니다.
다시 Product 객체는 아래와 같이 표현할 수 있을 것 입니다.

public abstract class BaseComponent {
    protected WebElement root;

    public BaseComponent(WebElement root) {
        this.root = root;
    }
}

// Page Component Object
public class Product extends BaseComponent {
    // The root element contains the entire component
    public Product(WebElement root) {
        super(root); // inventory_item
    }

    public String getName() {
        // Locating an element begins at the root of the component
        return root.findElement(By.className("inventory_item_name")).getText();
    }

    public BigDecimal getPrice() {
        return new BigDecimal(
                root.findElement(By.className("inventory_item_price"))
                    .getText()
                    .replace("$", "")
            ).setScale(2, RoundingMode.UNNECESSARY); // Sanitation and formatting
    }

    public void addToCart() {
        root.findElement(By.id("add-to-cart-backpack")).click();
    }
}

테스트 코드는 아래와 같이 작성해볼 수 있을 것 입니다.

public class ProductsTest {
    @Test
    public void testProductInventory() {
        var productsPage = new ProductsPage(driver); // page object
        var products = productsPage.getProducts();
        assertEquals(6, products.size()); // expected, actual
    }
    
    @Test
    public void testProductPrices() {
        var productsPage = new ProductsPage(driver);

        // Pass a lambda expression (predicate) to filter the list of products
        // The predicate or "strategy" is the behavior passed as parameter
        var backpack = productsPage.getProduct(p -> p.getName().equals("Backpack")); // page component object
        var bikeLight = productsPage.getProduct(p -> p.getName().equals("Bike Light"));

        assertEquals(new BigDecimal("29.99"), backpack.getPrice());
        assertEquals(new BigDecimal("9.99"), bikeLight.getPrice());
    }
}

페이지 객체와 페이지 내의 객체는 딱 그 요소들가 수행하는 interaction에 대해서만 메소드로 가지고 있습니다. 이것은 실제 어플리케이션이 수행하는 동작과 동일합니다.

위 방법을 이용한다면 페이지 내의 요소 내의 요소라던지 좀 더 복잡한 요소와 UI형태를 객체로 표현해볼 수 있을 것 입니다.

이러한 모델링 구현방법에서 WebDriver객체를 계속해서 노출시키는 것은 그다지 좋은 구현방법은 아닙니다. 위 구현방법에서의 핵심은 최대한 실제 어플리케이션의 동작방식을 그대로 표현하는 것입니다. 즉, 페이지 객체의 구현 방법은 테스트 코드에서는 최대한 숨기고, 유저가 페이지를 조작할 때의 동작만 페이지 객체의 사용자(테스트 코드)에게 제공하는 것 입니다.

최대한 이 구현방식을 지키기 위해서는, 가능하면 페이지 객체가 페이지 객체를 리턴하도록 구현해야합니다.

예를 들어 로그인 성공 시 결과로는 로그인 성공 후 이동하는 페이지의 객체여야합니다.

  @Test
  public void testLogin() {
    SignInPage signInPage = new SignInPage(driver);
    HomePage homePage = signInPage.loginValidUser("userName", "password");
    assertThat(homePage.getMessageText(), is("Hello userName"));
  }

실제 어플리케이션에서는 유효한 유저로 로그인을 하면 홈페이지로 이동하듯이, 코드에서도 유효한 유저로 로그인을 한다면, HomePage 객체를 리턴하여 HomePage 객체에서 인터렉션을 수행합니다.

Summary

  • The public methods represent the services that the page offers
  • Try not to expose the internals of the page
  • Generally don’t make assertions
  • Methods return other PageObjects
  • Need not represent an entire page
  • Different results for the same action are modelled as different methods
  • 퍼블릭 메소드는 어떤 페이지가 제공하는 서비스(인터렉션)을 나타낸다.
  • 페이지 내부 구현을 (테스트 코드에) 노출시키지마라
  • 페이지 객체 내부에는 assertion을 작성하지 마라
  • (가능한) 메소드는 페이지 객체를 리턴하도록 해라
  • 모든 페이지 객체(페이지 요소)를 구현하려하지 마라
  • 같은 동작에 대해 다른 결과를 리턴하고자 하면 다른 메소드로 구현해라(Over-loading)

ref

profile
QA Engineer

0개의 댓글