霍格沃兹测试开发

电子商务产品实战
PageObject设计模式

霍格沃兹测试开发学社

ceshiren.com

目录

  • 产品分析
  • 测试用例分析
  • PageObjectModel简介
  • 编写POM测试脚本
  • 测试脚本的优化

产品分析

  • 产品:Litemall商城系统
  • 功能:商品类目管理

http://litemall.hogwarts.ceshiren.com

测试用例-新增类目

  • 用户登录
  • 进入商品类目菜单
  • 点击添加
  • 创建商品类目
  • 获取操作结果
  • 断言测试结果

@startuml
scale 400 height
autonumber
participant 登录页面 as login
participant 首页 as home
participant 类目列表页面 as cate_list
participant 创建类目页面 as cate_create
login -> home: 用户登录
home -> cate_list: 进入商品类目
cate_list -> cate_create: 点击添加
cate_create -> cate_list: 创建类目
cate_list -> cate_list: 获取创建结果
@enduml

测试用例-删除类目

  • 用户登录
  • 进入商品类目菜单
  • 点击添加
  • 创建商品类目
  • 点击删除
  • 获取操作结果
  • 断言测试结果
@startuml
scale 400 height
autonumber
participant 登录页面 as login
participant 首页 as home
participant 类目列表页面 as cate_list
participant 创建类目页面 as cate_create
login -> home: 用户登录
home -> cate_list: 进入商品类目
cate_list -> cate_create: 点击添加
cate_create -> cate_list: 创建类目
cate_list -> cate_list: 点击删除
cate_list -> cate_list: 获取删除结果
@enduml

传统线性测试脚本(Python)

"""
__author__ = '霍格沃兹测试开发学社'
__desc__ = '更多测试开发技术探讨,请访问:https://ceshiren.com/t/topic/15860'
"""

import time
import allure
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions
from selenium.webdriver.support.wait import WebDriverWait
from utils.log_utils import logger


class TestLitemall:

    # 前置动作
    def setup_class(self):
        self.driver = webdriver.Chrome()
        self.driver.implicitly_wait(3)
        # 登录
        self.driver.get("http://litemall.hogwarts.ceshiren.com/")
        # 问题,输入框内有默认值,此时send——keys不回清空只会追加
        # 解决方案: 在输入信息之前,先对输入框完成清空
        # 输入用户名密码
        self.driver.find_element(By.NAME, "username").clear()
        self.driver.find_element(By.NAME, "username").send_keys("manage")
        self.driver.find_element(By.NAME, "password").clear()
        self.driver.find_element(By.NAME, "password").send_keys("manage123")
        # 点击登录按钮
        self.driver.find_element(By.CSS_SELECTOR, 
                ".el-button--primary").click()
        # 窗口最大化
        self.driver.maximize_window()

    # 后置动作
    def teardown_class(self):
        self.driver.quit()

    def get_screen(self):
        timestamp = int(time.time())
        # 注意:!! 一定要提前创建好images 路径
        image_path = f"./images/image_{timestamp}.PNG"
        # 截图
        self.driver.save_screenshot(image_path)
        # 讲截图放到报告的数据中
        allure.attach.file(image_path, name="picture",
                           attachment_type=allure.attachment_type.PNG)

    # 新增功能
    def test_add_type(self):
        # 点击商场管理/商品类目,进入商品类目页面
        # 进入商品类目页面
        self.driver.find_element(By.XPATH, "//*[text()='商场管理']").click()
        self.driver.find_element(By.XPATH, "//*[text()='商品类目']").click()
        # 添加商品类目操作
        self.driver.find_element(By.XPATH, "//*[text()='添加']").click()
        self.driver.find_element(By.CSS_SELECTOR, 
                ".el-input__inner").send_keys("新增商品测试")
        #==============显示等待优化方案2: 自定义显式等待条件
        def click_exception(by, element, max_attempts=5):
            def _inner(driver):
                # 多次点击按钮
                actul_attempts = 0 # 实际点击次数
                while actul_attempts<max_attempts:
                    # 进行点击操作
                    actul_attempts += 1 # 每次循环,实际点击次数加1
                    try:
                        # 如果点击过程报错,则直接执行 except 逻辑,并切继续循环
                        # 没有报错,则直接return 循环结束
                        driver.find_element(by, element).click()
                        return True
                    except Exception:
                        logger.debug("点击的时候出现了一次异常")
                # 当实际点击次数大于最大点击次数时,结束循环并抛出异常
                raise Exception("超出了最大点击次数")
            # return _inner() 错误写法
            return _inner

        WebDriverWait(self.driver, 10).until(click_exception(By.CSS_SELECTOR, 
                        ".dialog-footer .el-button--primary"))

        # ===========================使用显式等待优化
        # 如果没找到,程序也不应该报错
        res = self.driver.find_elements(By.XPATH, 
                            "//*[text()='新增商品测试']")
        self.get_screen()
        # 数据的清理一定到放在断言操作之后完成,要不然可能会影响断言结果
        self.driver.find_element(By.XPATH, 
            "//*[text()='新增商品测试']/../..//*[text()='删除']").click()
        logger.info(f"断言获取到的实际结果为{res}")
        # 断言产品新增后是否成功找到
        assert res != []

    # 删除功能
    def test_delete_type(self):
        # ================ 造数据步骤
        # 点击商场管理/商品类目,进入商品类目页面
        # 进入商品类目页面
        self.driver.find_element(By.XPATH, "//*[text()='商场管理']").click()
        self.driver.find_element(By.XPATH, "//*[text()='商品类目']").click()
        # 添加商品类目操作
        self.driver.find_element(By.XPATH, "//*[text()='添加']").click()
        self.driver.find_element(By.CSS_SELECTOR, 
        ".el-input__inner").send_keys("删除商品测试")
        ele = WebDriverWait(self.driver,10).until(
            expected_conditions.element_to_be_clickable(
                (By.CSS_SELECTOR, ".dialog-footer .el-button--primary")))
        ele.click()
        # ============完成删除步骤
        self.driver.find_element(By.XPATH, 
        "//*[text()='删除商品测试']/../..//*[text()='删除']").click()
        # 断言
        WebDriverWait(self.driver, 10).until_not(
            expected_conditions.visibility_of_any_elements_located((By.XPATH, 
                        "//*[text()='删除商品测试']")))
        # 问题: 因为代码执行速度过快,元素还未消失就捕获了。
        # 解决: 确认该元素不存在后,再捕获
        res = self.driver.find_elements(By.XPATH, 
                "//*[text()='删除商品测试']")
        logger.info(f"断言获取到的实际结果为{res}")
        assert res == []

传统线性测试脚本(Java)

/*
 * @Author: 霍格沃兹测试开发学社
 * @Desc: '更多测试开发技术探讨,请访问:https://ceshiren.com/t/topic/15860'
 */

package com.ceshiren.litemall;

import io.qameta.allure.Allure;
import org.apache.commons.io.FileUtils;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.*;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.FluentWait;
import org.openqa.selenium.support.ui.WebDriverWait;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.File;
import java.io.FileInputStream;
import java.time.Duration;
import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;


public class LitemallPlusTest {
    private static WebDriver driver;
    private static WebDriverWait wait;
    private static FluentWait<WebDriver> fluentWait;

    private static Logger logger;
    // 前置
    @BeforeAll
    static void setUpClass() throws InterruptedException {
        driver = new ChromeDriver();
        wait = new WebDriverWait(driver, Duration.ofSeconds(10));
        logger = LoggerFactory.getLogger(LitemallPlusTest.class);

        fluentWait = new FluentWait<WebDriver>(driver).
                withTimeout(Duration.ofSeconds(10)).
                pollingEvery(Duration.ofMillis(500)).
                ignoring(ElementClickInterceptedException.class);
        // 隐式等待配置,不要加太长
        driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(3));

        logger.debug("开始自动化测试");
        // 登录行为式所有用例执行前的准备工作,所以需要放到最前面, before all
        driver.get("http://litemall.hogwarts.ceshiren.com/#/login");
        driver.manage().window().maximize();
        // 输入用户名密码
        driver.findElement(By.name("username")).clear();
        driver.findElement(By.name("username")).sendKeys("manage");
        driver.findElement(By.name("password")).clear();
        driver.findElement(By.name("password")).sendKeys("manage123");
        driver.findElement(By.cssSelector("button")).click();
        // 进入商品类目(被测页面)
        logger.debug("切换到商品类目页面");
//        Thread.sleep(1000);
        wait.until(ExpectedConditions.elementToBeClickable(By.xpath("//ul//*[text()='商场管理']")))
                .click();
//        driver.findElement(By.xpath("//ul//*[text()='商场管理']")).click();
        driver.findElement(By.xpath("//ul//*[text()='商品类目']")).click();
    }
    // 后置
    @AfterAll
    static void tearDownClass(){
        driver.quit();
    }
    // 保存截图
    void saveScreen(){
        try{
            // 生成时间戳
            long nowTime = System.currentTimeMillis();
            String fileName = "./files/"+nowTime+".png";
            // 生成截图文件
            File currentScreen = ((TakesScreenshot)driver).getScreenshotAs(OutputType.FILE);
            FileUtils.copyFile(currentScreen, new File(fileName));
            // 将截图文件配置到allure中
            Allure.addAttachment("picture", "image/png", new FileInputStream(fileName), ".jpg");
        }catch (Exception e){
            logger.warn("截图错误"+e);
        }
    }


    @Test
    void addProductType() throws InterruptedException {
        driver.findElement(By.cssSelector(".el-icon-edit")).click();
        driver.findElement(By.cssSelector(".el-form-item:nth-child(1) input")).sendKeys("测试添加商品");
        fluentWait.until(driver1->{
            driver1.findElement(By.cssSelector(".el-dialog__footer .el-button--primary")).click();
            return driver.findElement(By.cssSelector(".el-notification__title"));
        });

        List<WebElement> eles =  driver.findElements(By.xpath("//*[text()='测试添加商品']"));
        driver.findElement(By.xpath("//*[text()='测试添加商品']/../..//*[text()='删除']")).click();
        logger.info("断言获取到的元素的大小为"+eles.size());
        assertEquals(1, eles.size());

    }

    @Test
    void deleteProductType() throws InterruptedException {

        driver.findElement(By.cssSelector(".el-icon-edit")).click();

        driver.findElement(By.cssSelector(".el-form-item:nth-child(1) input")).sendKeys("测试删除商品");
        fluentWait.until(driver1->{
            driver1.findElement(By.cssSelector(".el-dialog__footer .el-button--primary")).click();
            return driver.findElement(By.cssSelector(".el-notification__title"));
        });

        driver.findElement(By.xpath("//*[text()='测试删除商品']/../..//*[text()='删除']")).click();

        wait.until(ExpectedConditions.invisibilityOfElementLocated(By.xpath("//*[text()='测试删除商品']")));
        List<WebElement> eles =  driver.findElements(By.xpath("//*[text()='测试删除商品']"));
        logger.info("断言获取到的元素的大小为"+eles.size());
        saveScreen();
        assertEquals(0, eles.size());
    }

}

PageObjectModel简介

  • 页面对象模型

PO模式设计原则

  • 不要暴露页面内部的元素给外部
  • 不需要建模 UI 内的所有元素
  • 要用公共方法代表 UI 所提供的功能
  • 同样的行为不同的结果可以建模为不同的方法
  • 方法应该返回其他的 PageObject ,或者返回用于断言的数据
  • 不要在方法内加断言

PO模式改造

@startmindmap
* HowToDo
** 梳理测试用例
*** 业务操作流程
*** 前置后置动作
** 构造PO模型
*** 页面类和方法
** 编写测试用例
*** 链式调用
** 业务具体实现
*** 封装BasePage
*** 实现页面方法
*** 封装页面元素
@endmindmap

梳理业务操作流程

@startuml
scale 400 height
autonumber
participant 登录页面 as login
participant 首页 as home
participant 类目列表页面 as cate_list
participant 创建类目页面 as cate_create
login -> home: 用户登录
home -> cate_list: 进入商品类目
cate_list -> cate_create: 点击添加
cate_create -> cate_list: 创建类目
cate_list -> cate_list: 获取操作结果
@enduml
class TestLitemall

    # 测试用例
    # 添加商品类型:

        """登录页面:用户登录"""
        # 访问登录页
        # 输入“用户名”
        # 输入“密码”
        # 点击“登录”按钮
        # ==》首页

        """系统首页:进入商品类目"""
        # 点击菜单“商场管理”
        # 点击菜单“商品类目”
        # ==》类目列表页面

        """类目列表页面:点击添加"""
        # 点击“添加”按钮
        # ==》创建类目页面

        """创建类目页面:创建类目"""
        # 输入“类目名称”
        # 点击“确定”按钮
        # ==》类目列表页面

        """类目列表页面:获取操作结果"""
        # 获取冒泡消息文本
        # ==》返回消息文本

梳理前置和后置

  • setup_class/@BeforeAll
  • teardown_class/@AfterAll
class TestLitemall:

    # 前置动作
    def setup_class(self):
        # 初始化开始页面
        pass

    # 后置动作
    def teardown_class(self):
        # 退出浏览器
        pass
class ProductTypePageTest {
    public static LitemallPage liteMall;
    // 前置动作
    @BeforeAll
    public static void setUpClass(){
        // 初始化开始页面
    }


    // 后置动作
    @AfterAll
    public static void tearDownClass(){
        // 退出浏览器

    }

}

构造PO模型

  • 创建页面类
  • 定义页面方法
@startuml

class BasePage{

  构造方法
  find()
  finds()s
  
}


class LoginPage{
    _BTN_LOGIN元素
    login()
}

class HomePage{
    _MENU_MALL_MANAGE元素
    go_to_category()
}

class CategoryPage{
    _BTN_ADD元素
    get_list()
}


BasePage <|-- LoginPage: 继承
BasePage <|-- HomePage: 继承
BasePage <|-- CategoryPage: 继承

@enduml

编写测试用例

  • 链式调用
    • 新增功能
    # 演示代码,直接复制不可用
    # Python版本代码
    # 新增功能
    def test_add_type(self):
      # 链式调用
      res = self.home\
          .go_to_category()\
          .click_add()\
          .create_category()\
          .get_res()
// 演示代码,直接复制不可用
// java版本代码
@Test
void addType() {
String res = liteMall.goToMainPage().
                goToCategoryPage().
                createCategory("添加商品").
                get_res();
    }

业务实现:BasePage(Python)

  • 封装driver
  • 封装selenium api
"""
__author__ = '霍格沃兹测试开发学社'
__desc__ = '更多测试开发技术探讨,请访问:https://ceshiren.com/t/topic/15860'
"""
import time
import allure
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions
from selenium.webdriver.support.wait import WebDriverWait
from utils.web_utils import click_exception


class BasePage:

    _BASE_URL = ""

    def __init__(self, base_driver=None):

        if base_driver:
            self.driver = base_driver
        else:
            self.driver = webdriver.Chrome()
            self.driver.implicitly_wait(5)
            self.driver.maximize_window()

        if not self.driver.current_url.startswith("http"):
            self.driver.get(self._BASE_URL)

    def do_find(self, by, locator=None):
        """获取单个元素"""
        if locator:
            return self.driver.find_element(by, locator)
        else:
            return self.driver.find_element(*by)

    def do_finds(self, by, locator=None):
        """获取多个元素"""
        if locator:
            return self.driver.find_elements(by, locator)
        else:
            return self.driver.find_elements(*by)

    def do_send_keys(self, value, by, locator=None):
        """获取单个元素并输入内容"""
        ele = self.do_find(by, locator)
        ele.clear()
        ele.send_keys(value)

    def do_quit(self):
        """退出浏览器"""
        self.driver.quit()


    def wait_element_until_visible(self, locator: tuple):
        return WebDriverWait(self.driver, 10).until(
            expected_conditions.visibility_of_element_located(locator))

业务实现:BasePage(Java)

  • 封装driver
  • 封装selenium api
public class BasePage {
    public WebDriver driver;
    public Logger logger = LoggerFactory.getLogger(BasePage.class);
    public String baseURL;

    public BasePage() {
    }

    public BasePage(WebDriver baseDriver){
            driver = baseDriver;
    }

    public BasePage(String url) {
        baseURL = url;
        driver = new ChromeDriver();
        driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(3));
        driver.get(baseURL);
    }

    public WebDriverWait waitFor(){
        return new WebDriverWait(driver, Duration.ofSeconds(10));
    }

    public FluentWait<WebDriver> fluentWaitFor(){
        return new FluentWait<>(driver).
                withTimeout(Duration.ofSeconds(10)).
                pollingEvery(Duration.ofMillis(500)).
                ignoring(ElementClickInterceptedException.class);
    }

    public WebElement find(By by){
        logger.info("查找的元素为"+by);
        return driver.findElement(by);
    }

    public List<WebElement> finds(By by){
        return driver.findElements(by);
    }

    public void quitDriver(){
        driver.quit();
    }

    public void saveScreen(){
        
    }
}

脚本优化

  • 测试断言
  • 数据清理
  • 参数化
  • 添加日志
  • 测试报告