1 跨浏览器测试
1.1 配置selenium standalone Server
下载地址:http://www.seleniumhq.org/download/

1.3 selenium grid2工作原理
Grid 是用于设计帮助我们进行分布式测试的工具,其整个结构有一个hub主节点和若干个node代理节点组成。hub用来管理各个子节点的注册和状态信息,并接收远程客户端代码的请求调用,然后把请求的命令再转发给代理节点来执行。使用Grid远程执行测试的代码与直接调用Selenium Server是一样的,只是环境启动的方式不一样,需要同时启动一个hub和至少一个node。
1.3.1 启动主节点
以hub形式启动Server就是一个Gird Server,可以通过浏览器查看Grid控制台的信息。
1.3.2 启动代理节点
1.4 Remote应用
启动Selenium Server
from selenium.webdriver import Remote import time driver = Remote(command_executor='http://localhost:4444/wd/hub',desired_capabilities= {'platfrom':'ANY','browserName':'firefox','version':'','javascriptEnabled':True}) driver.get('http://baidu.com') driver.find_element_by_id('kw').send_keys('remote') driver.find_element_by_id('su').click() time.sleep(3) driver.quit()
通过Remote()可以参数化使用的浏览器。
FireFox = {'platform':'ANY', 'browserName':'firefox', 'version':'', 'javascriptEnabled':True, 'marionette':False } Chrome = {'platform':'ANY', 'browserName':'chrome', 'version':'', 'javascriptEnabled':True } Opera= {'platform':'ANY', 'browserName':'opera', 'version':'', 'javascriptEnabled':True } Iphone= {'platform':'MAC', 'browserName':'iPhone', 'version':'', 'javascriptEnabled':True } Android = {'platform':'ANDROID', 'browserName':'android', 'version':'', 'javascriptEnabled':True }
1.5 参数化平台及浏览器
1.5.1 启动本地node
先创建list 字典,定义不同的主机ip,端口号及浏览器。然后,通过for循环读取lists字典中的数据作为Remote()的配置信息,从而使脚本在不同的节点及浏览器下执行。
from selenium.webdriver import Remote import time lists = {'http://localhost:4444/wd/hub':'chrome','http://localhost:5555/wd/hub':'firefox'} for host,browser in lists.items(): print (host,browser) driver = Remote(command_executor=host,desired_capabilities={'platform': 'ANY','browserName': browser,'version': '','javascriptEnabled': True}) driver.get('http://www.baidu.com') driver.find_element_by_id('kw').send_keys('remote') driver.find_element_by_id('su').click() time.sleep(3) driver.quit()
1.5.2 启动远程node
步骤:
from selenium.webdriver import Remote from threading import Thread import time lists = {'http://localhost:4444/wd/hub':'chrome','http://localhost:5555/wd/hub':'firefox'} def WebTest(host,browser): driver = Remote(command_executor=host, desired_capabilities={'platform': 'ANY', 'browserName': browser, 'version': '', 'javascriptEnabled': True}) driver.get('http://www.baidu.com') driver.find_element_by_id('kw').send_keys('remote') driver.find_element_by_id('su').click() time.sleep(3) driver.quit() if __name__ == '__main__': threads=[]
#创建线程 for host, browser in lists.items(): print(host, browser) t = Thread(target=WebTest,args=(host,browser)) threads.append(t)
#启动线程 for thr in threads: thr.start() print(time.strftime('%Y%m%d%H%M%S'))
2 数据驱动测试与Page Object
使用Python下的数据驱动模式(ddt)库,结合unittest库以数据驱动模式创建百度搜索的测试。
pip命令进行下载并安装:pip install ddt
2.1 一个简单的数据驱动测试
为了创建数据驱动测试,需要在测试类上使用@ddt装饰符,在测试方法上使用@data装饰符。@data装饰符把参数当作测试数据,参数可以是单个值、列表、元组、字典。对于列表,需要用@unpack装饰符把元组和列表解析成多个参数。
import unittest,time from selenium import webdriver from ddt import ddt,data,unpack @ddt class WebTest(unittest.TestCase): @classmethod def setUpClass(cls): cls.driver = webdriver.Firefox() cls.driver.implicitly_wait(3) cls.driver.get("http://baidu.com") @classmethod def tearDownClass(cls): cls.driver.quit() @data(("图书馆","图书馆_百度搜索"),("博客","博客_百度搜索")) @unpack # 搜索 def test_search_info(self,search_value, expected_result): self.search = self.driver.find_element_by_xpath("//*[@id='kw']") self.search.clear() self.search.send_keys(search_value) self.search.submit() time.sleep(1.5) self.result = self.driver.title self.assertEqual(expected_result,self.result) if __name__ == '__main__': unittest.main(verbosity=2)
在test_search()方法中,search_value与expected_result两个参数用来接收元组解析的数据。当运行脚本时,ddt把测试数据转换为有效的python标识符,生成名称为更有意义的测试方法。结果如下:
2.2 使用外部数据的数据驱动测试
2.2.1 通过CSV获取数据
同上在@data装饰符使用解析外部的CSV(testdata.csv)来作为测试数据(代替之前的测试数据)。其中数据如下:
接下来,先要创建一个get_data()方法,其中包括路径(这里默认使用当前路径)、CSV文件名。调用CSV库去读取文件并返回一行数据。再使用@ddt及@data实现外部数据驱动测试百度搜索,代码如下:
import csv,unittest,time from selenium import webdriver from ddt import ddt,data,unpack def GetData(filename): # create an empty list to store rows rows = [] # open the CSV file data_file = open(filename, "r",encoding='utf-8') # create a CSV Reader from CSV file reader = csv.reader(data_file) # skip the headers next(reader, None) # add rows from reader to list for row in reader: rows.append(row) return rows @ddt class WebTest(unittest.TestCase): @classmethod def setUpClass(cls): cls.driver = webdriver.Firefox() cls.driver.implicitly_wait(3) cls.driver.get("http://baidu.com") @classmethod def tearDownClass(cls): cls.driver.quit()
@data(*GetData("testdata.csv")) #@data(*GetData(dir+'\\_test'+'\\testdata.csv')),dir=os.path.dirname(os.path.abspath(__file__)) @unpack # 搜索 def test_search_info(self,search_value, expected_result): self.search = self.driver.find_element_by_xpath("//*[@id='kw']") self.search.clear() self.search.send_keys(search_value) self.search.submit() time.sleep(1.5) self.result = self.driver.title self.assertEqual(expected_result,self.result) if __name__ == '__main__': unittest.main(verbosity=2)
测试执行时,@data将调用get_data()方法读取外部数据文件,并将数据逐行返回给@data。执行的结果如下:
2.2.2 通过excel获取数据
读取excel文件需要用到xlrd的库,安装命令:pip install xlrd
创建excel文件如图:
import xlrd,unittest,time from selenium import webdriver from ddt import ddt,data,unpack import os,sys def GetData(filename): # create an empty list to store rows rows = [] data_file = xlrd.open_workbook(filename,encoding_override='utf-8') sheet = data_file.sheet_by_index(0) #通过索引顺序获取 for row_idx in range(1,sheet.nrows): #从第1行开始获取 rows.append(list(sheet.row_values(row_idx,0,sheet.ncols))) #从第0列开始获取 print(rows) return rows @ddt class WebTest(unittest.TestCase): @classmethod def setUpClass(cls): cls.driver = webdriver.Firefox() cls.driver.implicitly_wait(3) cls.driver.get("http://baidu.com") @classmethod def tearDownClass(cls): cls.driver.quit() dir = os.path.dirname(os.path.abspath(__file__)) @data(*GetData("testdata.xlsx")) @unpack # 搜索 def test_search_info(self,search_value, expected_result): self.search = self.driver.find_element_by_xpath("//*[@id='kw']") self.search.clear() self.search.send_keys(search_value) self.search.submit() time.sleep(1.5) self.result = self.driver.title self.assertEqual(expected_result,self.result) if __name__ == '__main__': unittest.main(verbosity=2)
如果想从数据库的库表中获取数据,同样也需要一个get_data()方法,并且通过DB相关的库来连接数据库、SQL查询来获取测试数据。
3 Page Object设计模式
Page Object模式,创建一个对象来对应页面的一个应用。故我们可以为每个页面定义一个类,并为每个页面的属性和操作构建模型。体现在对界面交互细节的封装,测试在更上层使用页面对象,在底层的属性或者操作的更改不会中断测试。减少代码重复,提高测试代码的可读性和可维护性。
from selenium import webdriver from selenium.webdriver.common.by import By from time import sleep #创建基础类 class BasePage(object): #初始化 def __init__(self, driver): self.base_url = 'https://mail.qq.com/' self.driver = driver self.timeout = 30 #定义打开登录页面方法 def _open(self): url = self.base_url self.driver.get(url) self.driver.switch_to.frame('login_frame') #切换到登录窗口的iframe #定义定义open方法,调用_open()进行打开 def open(self): self._open() #定位方法封装 def find_element(self,*loc): return self.driver.find_element(*loc) #创建LoginPage类 class LoginPage(BasePage): username_loc = (By.ID, "u") password_loc = (By.ID, "p") login_loc = (By.ID, "login_button") #输入用户名 def type_username(self,username): self.find_element(*self.username_loc).clear() self.find_element(*self.username_loc).send_keys(username) #输入密码 def type_password(self,password): self.find_element(*self.password_loc).send_keys(password) #点击登录 def type_login(self): self.find_element(*self.login_loc).click() #创建test_user_login()函数 def test_user_login(driver, username, password): """测试用户名/密码是否可以登录""" login_page = LoginPage(driver) login_page.open() login_page.type_username(username) login_page.type_password(password) login_page.type_login() #创建main()函数 def main(): driver = webdriver.Edge() username = '3494xxxxx' #qq号码 password = 'kemixxxx' #qq密码 test_user_login(driver, username, password) sleep(3) driver.quit() if __name__ == '__main__': main()
首先创建一个基础BasePage类,在初始化方法__init__()中定义驱动(driver),基本的URL(base_url)和超时时间(timeout)等。定义open()方法用于打开URL,这里是由_open()方法来实现,而find_element()方法用于元素定位。
接下来的BasePage类中定义的方法都是页面操作的基本方法。LoginPage类并继承BasePage类,这也是Page Object设计模式中最重要的对象层。LoginPage类中主要对登录页面上元素进行封装,使其成为具体的操作方法。如对用户名、密码框和登录按钮都封装成方法。
然后定义test_user_login()函数将单个元素操作组成一个完整的动作,包含打开浏览器、输入用户名、密码并点击登录按钮等。使用时将driver、username、password作为函数的入参,这样的函数具有很强的可重用性。
最后使用main()函数进行用户操作行为,现在只关心用哪个浏览器、登录的用户名和密码是什么,至少输入框、按钮是如何定位的,则不关心。即实现了不同层关心不同问题。如果有多个用户名/密码需要登录,那么只用改写main()方法的参数即可。
5 自动化测试框架
Test_framework |--config(配置文件) |--data(数据文件) |--drivers(驱动) |--log(日志) |--report(报告) |--test(测试用例) |--utils(公共方法) |--ReadMe.md(加个说明性的文件,告诉团队成员框架需要的环境以及用法)
把配置抽出来放到config.yml中:
URL: http://www.baidu.com
为了读取yaml文件,需要一个封装YamlReader类,在utils中创建file_reader.py文件:
import yaml import os class YamlReader: def __init__(self, yamlf): if os.path.exists(yamlf): self.yamlf = yamlf else: raise FileNotFoundError('文件不存在!') self._data = None @property def data(self): # 如果是第一次调用data,读取yaml文档,否则直接返回之前保存的数据 if not self._data: with open(self.yamlf, 'rb') as f: self._data = list(yaml.safe_load_all(f)) # load后是个generator,用list组织成列表 return self._data
需要一个Config类来读取配置,config.py:
""" 读取配置。这里配置文件用的yaml,也可用其他如XML,INI等,需在file_reader中添加相应的Reader进行处理。 """ import os from utils.file_reader import YamlReader # 通过当前文件的绝对路径,其父级目录一定是框架的base目录,然后确定各层的绝对路径。如果你的结构不同,可自行修改。 # 之前直接拼接的路径,修改了一下,用现在下面这种方法,可以支持linux和windows等不同的平台,使用os.path.split()和os.path.join(),不要直接+'\\xxx\\ss'这样 BASE_PATH = os.path.split(os.path.dirname(os.path.abspath(__file__)))[0] #os.path.dirname(os.path.dirname(os.path.abspath(__file__))) CONFIG_FILE = os.path.join(BASE_PATH, 'config', 'config.yaml') DATA_PATH = os.path.join(BASE_PATH, 'data') DRIVER_PATH = os.path.join(BASE_PATH, 'drivers') LOG_PATH = os.path.join(BASE_PATH, 'log') REPORT_PATH = os.path.join(BASE_PATH, 'report') class Config: def __init__(self, config=CONFIG_FILE): self.config = YamlReader(config).data def get(self, element, index=0): """ yaml是可以通过'---'分节的。用YamlReader读取返回的是一个list,第一项是默认的节,如果有多个节,可以传入index来获取。 把框架相关的配置放在默认节,其他的关于项目的配置放在其他节中。可以在框架中实现多个项目的测试。 """ return self.config[index].get(element)
test.py
import time,os,sys import unittest from selenium import webdriver from selenium.webdriver.common.by import By PATH = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.append(PATH) from utils.config import Config class TestBaiDu(unittest.TestCase): URL = Config().get('URL') locator_kw = (By.ID, 'kw') locator_su = (By.ID, 'su') locator_result = (By.XPATH, '//div[contains(@class, "result")]/h3/a') def setUp(self): self.driver = webdriver.Firefox() self.driver.get(self.URL) def tearDown(self): self.driver.quit() def test_search_0(self): self.driver.find_element(*self.locator_kw).send_keys('防弹少年团') self.driver.find_element(*self.locator_su).click() time.sleep(2) links = self.driver.find_elements(*self.locator_result) for link in links: print(link.text) def test_search_1(self): self.driver.find_element(*self.locator_kw).send_keys('复仇者联盟') self.driver.find_element(*self.locator_su).click() time.sleep(2) links = self.driver.find_elements(*self.locator_result) for link in links: print(link.text) if __name__ == '__main__': unittest.main()
在utils中创建一个log.py文件,Python有很方便的logging库,对其进行简单的封装,使框架可以很简单地打印日志(输出到控制台以及日志文件)。
import os,sys import logging from logging.handlers import TimedRotatingFileHandler PATH = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.append(PATH) from utils.config import LOG_PATH class Logger(object): def __init__(self, logger_name='framework'): self.logger = logging.getLogger(logger_name) logging.root.setLevel(logging.NOTSET) self.log_file_name = 'test.log' self.backup_count = 5 # 日志输出级别 self.console_output_level = 'WARNING' self.file_output_level = 'DEBUG' # 日志输出格式 self.formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') def get_logger(self): """在logger中添加日志句柄并返回,如果logger已有句柄,则直接返回""" if not self.logger.handlers: # 避免重复日志 console_handler = logging.StreamHandler() console_handler.setFormatter(self.formatter) console_handler.setLevel(self.console_output_level) self.logger.addHandler(console_handler) # 每天重新创建一个日志文件,最多保留backup_count份 file_handler = TimedRotatingFileHandler(filename=os.path.join(LOG_PATH, self.log_file_name), when='D', interval=1, backupCount=self.backup_count, delay=True, encoding='utf-8' ) file_handler.setFormatter(self.formatter) file_handler.setLevel(self.file_output_level) self.logger.addHandler(file_handler) return self.logger logger = Logger().get_logger()
test.py
import time,os,sys import unittest from selenium import webdriver from selenium.webdriver.common.by import By PATH = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.append(PATH) from utils.config import Config from utils.log import logger class TestBaiDu(unittest.TestCase): URL = Config().get('URL') locator_kw = (By.ID, 'kw') locator_su = (By.ID, 'su') locator_result = (By.XPATH, '//div[contains(@class, "result")]/h3/a') def setUp(self): self.driver = webdriver.Firefox() self.driver.get(self.URL) def tearDown(self): self.driver.quit() def test_search_0(self): self.driver.find_element(*self.locator_kw).send_keys('防弹少年团') self.driver.find_element(*self.locator_su).click() time.sleep(2) links = self.driver.find_elements(*self.locator_result) for link in links: logger.info(link.text) def test_search_1(self): self.driver.find_element(*self.locator_kw).send_keys('复仇者联盟') self.driver.find_element(*self.locator_su).click() time.sleep(2) links = self.driver.find_elements(*self.locator_result) for link in links: logger.info(link.text) if __name__ == '__main__': unittest.main()
执行test.py,打印的信息都输出到log文件夹的test.log文件。
可以把log的设置放到config中,修改config.yml
URL: http://www.baidu.com log: file_name: test.log backup: 5 console_level: WARNING file_level: DEBUG pattern: '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
log.py文件
import os,sys import logging from logging.handlers import TimedRotatingFileHandler PATH = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.append(PATH) from utils.config import LOG_PATH,Config class Logger(object): def __init__(self, logger_name='framework'): self.logger = logging.getLogger(logger_name) logging.root.setLevel(logging.NOTSET) c = Config().get('log') self.log_file_name = c.get('file_name') if c and c.get('file_name') else 'test.log' # 日志文件 self.backup_count = c.get('backup') if c and c.get('backup') else 5 # 保留的日志数量 # 日志输出级别 self.console_output_level = c.get('console_level') if c and c.get('console_level') else 'WARNING' self.file_output_level = c.get('file_level') if c and c.get('file_level') else 'DEBUG' # 日志输出格式 pattern = c.get('pattern') if c and c.get('pattern') else '%(asctime)s - %(name)s - %(levelname)s - %(message)s' self.formatter = logging.Formatter(pattern) def get_logger(self): """在logger中添加日志句柄并返回,如果logger已有句柄,则直接返回""" if not self.logger.handlers: # 避免重复日志 console_handler = logging.StreamHandler() console_handler.setFormatter(self.formatter) console_handler.setLevel(self.console_output_level) self.logger.addHandler(console_handler) # 每天重新创建一个日志文件,最多保留backup_count份 file_handler = TimedRotatingFileHandler(filename=os.path.join(LOG_PATH, self.log_file_name), when='D', interval=1, backupCount=self.backup_count, delay=True, encoding='utf-8' ) file_handler.setFormatter(self.formatter) file_handler.setLevel(self.file_output_level) self.logger.addHandler(file_handler) return self.logger logger = Logger().get_logger()
修改file_reader.py文件,添加ExcelReader类,实现读取excel内容的功能:
import yaml import os from xlrd import open_workbook class YamlReader: def __init__(self, yamlf): if os.path.exists(yamlf): self.yamlf = yamlf else: raise FileNotFoundError('文件不存在!') self._data = None @property def data(self): # 如果是第一次调用data,读取yaml文档,否则直接返回之前保存的数据 if not self._data: with open(self.yamlf, 'rb') as f: self._data = list(yaml.safe_load_all(f)) # load后是个generator,用list组织成列表 return self._data class SheetTypeError(Exception): pass class ExcelReader: def __init__(self, excel, sheet=0, title_line=True): if os.path.exists(excel): self.excel = excel else: raise FileNotFoundError('文件不存在!') self.sheet = sheet self.title_line = title_line self._data = list() @property def data(self): if not self._data: workbook = open_workbook(self.excel) if type(self.sheet) not in [int, str]: raise SheetTypeError('Please pass in <type int> or <type str>, not {0}'.format(type(self.sheet))) elif type(self.sheet) == int: s = workbook.sheet_by_index(self.sheet) else: s = workbook.sheet_by_name(self.sheet) if self.title_line: title = s.row_values(0) # 首行为title for col in range(1, s.nrows): # 依次遍历其余行,与首行组成dict,拼到self._data中 self._data.append(dict(zip(title, s.row_values(col)))) else: for col in range(0, s.nrows): # 遍历所有行,拼到self._data中 self._data.append(s.row_values(col)) return self._data ''' 内部测试 ''' if __name__ == '__main__': y = 'C:\\Users\\zhouxy\\PycharmProjects\\untitled\\TestFramework\\config\\config.yaml' reader = YamlReader(y) print(reader.data) e = 'C:\\Users\\zhouxy\\PycharmProjects\\untitled\\TestFramework\\data\\data.xlsx' reader = ExcelReader(e, title_line=True) print(reader.data)
test.py
import time,os,sys import unittest from selenium import webdriver from selenium.webdriver.common.by import By PATH = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.append(PATH) from utils.config import Config,DATA_PATH from utils.log import logger from utils.file_reader import ExcelReader class TestBaiDu(unittest.TestCase): URL = Config().get('URL') excel = DATA_PATH + '//data.xlsx' locator_kw = (By.ID, 'kw') locator_su = (By.ID, 'su') locator_result = (By.XPATH, '//div[contains(@class, "result")]/h3/a') def setUp(self): self.driver = webdriver.Firefox() self.driver.get(self.URL) def tearDown(self): self.driver.quit() def test_search(self): datas = ExcelReader(self.excel).data for d in datas: with self.subTest(data=d): self.setUp() self.driver.find_element(*self.locator_kw).send_keys(d['search']) self.driver.find_element(*self.locator_su).click() time.sleep(2) links = self.driver.find_elements(*self.locator_result) for link in links: logger.info(link.text) self.tearDown() if __name__ == '__main__': unittest.main()
test目录再次进行分层,创建page、common、case、suite四个目录:
test |--case(用例文件) |--common(跟项目、页面无关的封装) |--page(页面) |--suite(测试套件,用来组织用例)
结合PageObject进行封装。