Selenium RC (Java): Шаги усовершенствования тестов. Часть 1
Опубликовано KaNoN в Пт, 07/16/2010 - 16:32
Часть 2
Миф о том, что для автоматизированного тестирования не нужны навыки программирования и тесты можно вполне легко записывать, развенчан настолько, что сам факт того, что кто-то еще так считает, уже кажется мифом или, как минимум, чем-то странным. Понятное дело, что записанные тесты и тесты, которые могли бы эффективно использоваться на регулярной основе – это, как говорят в Одессе, «две большие разницы». Очевидно, что исходный тест, да и решение по автоматизации в целом, должны пройти некоторые стадии улучшения, прежде чем прийти к пригодному к использованию виду. Давайте рассмотрим некоторые типовые шаги, которые так или иначе приходится проходить для построения стабильного, расширяемого и поддерживаемого решения.
И рассмотрим мы эти шаги на примере Selenium-RC (Java). На самом деле, данная тематика не сильно завязана на конкретный инструментарий. Всё то же самое можно сделать практически любым средством, которое в себе содержит возможность программирования тестов. Просто чтобы не уходить в абстрактную теорию, а показать на конкретных примерах, как оно работает, нужно привязаться к какой-то конкретно реализации. Но подобные практики имеют место быть в любых аналогичных решениях. Поэтому, тут можно усматривать не только особенность работы с каким-то конкретным средством, но и стоит обратить внимание на практики в целом, которые будут задействованы.
Итак, путем записи и незначительной корректировки был получен следующий тест:
package com.mycompany.selenium.tests;
import java.util.Date;
import com.thoughtworks.selenium.*;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
/**
* @author KaNoN
*/
public class SampleTestStage00 {
private Selenium selenium = null;
private String genTaskName() {
Date dt = new Date();
String result = dt.toString();
result = result.replaceAll( ":" , "" );
result = result.replaceAll( " " , "" );
return result;
}
@BeforeMethod(alwaysRun=true)
public void init() throws Exception {
selenium = new DefaultSelenium( "localhost" , 4444 , "*iexplore" , "http:// localhost:8080" );
selenium.start();
selenium.open("http://localhost:8080/hudson");
}
@Test(groups = {"sample","sample0"})
public void testCreateJob() throws Exception {
selenium.click("link=New Job");
selenium.waitForPageToLoad("30000");
selenium.type("name", "SampleTask" + genTaskName() );
selenium.click("//input[@value='Build a free-style software project']");
selenium.click("//button[@type='button']");
selenium.waitForPageToLoad("30000");
selenium.click("//button[text()='Save']");
selenium.waitForPageToLoad("30000");
selenium.click("link=Hudson");
selenium.waitForPageToLoad("30000");
}
@AfterMethod(alwaysRun=true)
public void stop() throws Exception {
selenium.stop();
}
}Что он делает? Он запускает веб-приложение (в данном примере это Hudson) и создает в нем новую задачу. Тест реализован с использованием TestNG в качестве тестового движка.
Как видно, тест еще далек от совершенства, как минимум из-за того, что много hard-coded значений. Да и вот так на первый взгляд трудно понять, что этот тест детает вообще. В-общем, непонятных вещей много, нужно усовершенствовать. Вопрос в том, что, когда и (самое главное) как.
Шаг 1: Вынесение величин, специфических для окружения в конфигурационный файл
Стоит мне вот этот тест запустить с другой машины, врядли я могу расчитывать на то, что он выдаст мне тот же результат. Хотя бы потому, что на этой машине скорее всего не окажется локально установленного приложения. И не факт, что сам сервер Selenium-a будет запущен локально и на том же порту. И наконец, я бы не хотел править все тесты, если я захочу запустить их под другим браузером. Для этого мне нужно сделать изменение максимум в одном файле.
Для таких задач существуют конфигурационные файлы и соответственно объекты, которые могли бы хранить эти данные. В Java для таких задач имеются *.properties файлы и соответствующие стандартные средства для работы с ними. В других языках программирования всё аналогично. Например, для Ruby используется YAML-формат. Опять же, никто не отменял те же ini-файлы. В крайнем случае, всегда можно написать свой считыватель конфигурационных данных.
Итак, нам нужно где-то задать конфигурацию и создать класс, который бы предоставлял возможность, как считывания, так и возвращения значений параметров конфигурации. В корневом каталоге проекта создаем отдельный каталог config, в нем создадим файл env.properties (можно любое другое имя, лишь бы было понятно, что там находится) и добавим туда наши параметры конфигурации. Получится что-то вида:
host=localhost port=4444 url=http://localhost:8080 browser=*iexplore delay=30000
Здесь мы задали параметры для запуска Selenium-а, а также время ожидания, которое достаточно интенсивно используется в методе waitForPageToLoad в предыдущем примере. Теперь нам надо это считать. Для этого мы создадим отдельный класс Config, который содержал бы в себе следующий функционал:
- Загрузка всех *.properties файлов из заданного каталога и всех подкаталогов произвольной вложенности
- Извлечение значения свойства по его имени
- Объект данного класса должен быть один в системе и должен самостоятельно обеспечить инициализацию свойств без явного указания на данную операцию
А теперь всё то же самое, но пошагово и с примерами.
Для загрузки всех свойств нам нужен метод, которому надо указать каталог. Причем, метод должен вызываться рекурсивно (у нас произвольный уровень вложенности каталогов). Изначально получаем что-то наподобие:
package com.mycompany.selenium.lib;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FilenameFilter;
import java.io.FileInputStream;
import java.io.IOException;
import java.util.Properties;
/**
* @author KaNoN
*/
public class Config {
private Properties prop = null;
FilenameFilter filter = null;
FilenameFilter dirFilter = null;
public void loadProperties( String path ) throws Exception {
File dir = new File( path );
filter = new FilenameFilter() { public boolean accept(File dir, String name) { return name.endsWith(".properties"); } };
dirFilter = new FilenameFilter() { public boolean accept(File dir, String name) { return dir.isDirectory(); } };
String files[] = dir.list(filter);
if( prop == null ){
prop = new Properties();
}
for( String file:files ){
System.out.println( file );
File localFile = new File( "config\\" + file );
if( localFile.isDirectory() ) continue;
FileInputStream fis = new FileInputStream( localFile.getAbsolutePath() );
prop.load( fis );
fis.close();
}
String dirs[] = dir.list(dirFilter);
for( String directory:dirs ){
loadProperties( path + "\\" + directory );
}
}
}Теперь осталось выполнить второй шаг, так как это собственно один из основных функционалов, который используется при написании тестов. Собственно:
package com.mycompany.selenium.lib;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FilenameFilter;
import java.io.FileInputStream;
import java.io.IOException;
import java.util.Properties;
/**
* @author KaNoN
*/
public class Config {
private Properties prop = null;
FilenameFilter filter = null;
FilenameFilter dirFilter = null;
public void loadProperties( String path ) throws Exception {
File dir = new File( path );
filter = new FilenameFilter() { public boolean accept(File dir, String name) { return name.endsWith(".properties"); } };
dirFilter = new FilenameFilter() { public boolean accept(File dir, String name) { return dir.isDirectory(); } };
String files[] = dir.list(filter);
if( prop == null ){
prop = new Properties();
}
for( String file:files ){
System.out.println( file );
File localFile = new File( "config\\" + file );
if( localFile.isDirectory() ) continue;
FileInputStream fis = new FileInputStream( localFile.getAbsolutePath() );
prop.load( fis );
fis.close();
}
String dirs[] = dir.list(dirFilter);
for( String directory:dirs ){
loadProperties( path + "\\" + directory );
}
}
public String getProperty( String propertyName ) throws Exception {
return prop.getProperty( propertyName );
}
}Теперь мы можем уже использовать данный класс в тестах. Для этого где-то вначале можно сделать вызов
Config conf = new Config(); conf.loadProperties( “config” );
А затем везде, где нужно использовать значение некоторого свойства, подставляем
conf.getProperty( “some_property” );
Но теперь представьте, что такую инициализацию и использование локальной переменной надо осуществлять во всех тестах. Как минимум это избыточные строки. Хотелось бы как-то от этого избавиться. К тому же, данный объект обычно нужен тестам в единичном экземпляре. Поэтому, подредактируем данный класс так:
package com.mycompany.selenium.lib;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FilenameFilter;
import java.io.FileInputStream;
import java.io.IOException;
import java.util.Properties;
/**
* @author KaNoN
*/
public class Config {
private static Config instance = null;
private Properties prop = null;
FilenameFilter filter = null;
FilenameFilter dirFilter = null;
private void loadProperties( String path ) throws Exception {
File dir = new File( path );
filter = new FilenameFilter() { public boolean accept(File dir, String name) { return name.endsWith(".properties"); } };
dirFilter = new FilenameFilter() { public boolean accept(File dir, String name) { return dir.isDirectory(); } };
String files[] = dir.list(filter);
if( prop == null ){
prop = new Properties();
}
for( String file:files ){
System.out.println( file );
File localFile = new File( "config\\" + file );
if( localFile.isDirectory() ) continue;
FileInputStream fis = new FileInputStream( localFile.getAbsolutePath() );
prop.load( fis );
fis.close();
}
String dirs[] = dir.list(dirFilter);
for( String directory:dirs ){
loadProperties( path + "\\" + directory );
}
}
private Config() throws Exception {
loadProperties( "config" );
}
public static String getProperty( String propertyName ) throws Exception {
if( instance == null ){
instance = new Config();
}
return instance.prop.getProperty( propertyName );
}
}Как видно из изменений, у нас есть только один доступный метод getProperty, который теперь статический. Более того, конструктор объявлен как private. То есть, мы уже не сможем сделать что-то наподобие:
Config conf = new Config();
за пределами текущего класса. Но при этом обратите внимание на метод getProperty, а точнее на изменения, которые были внесены.
Во-первых, данный класс сейчас содержит еще одно поле instance, которое является экземпляром данного класса. Приватный конструктор по-прежнему позволяет создавать экземпляры класса в пределах текущего класса. Во-вторых, поле instance инициализируется один раз за время работы тестов. То есть, в тесте мы можем сделать вызов вида:
Config.getProperty( “some_property” );
При этом нас не должно беспокоить, загружены ли свойства или нет. Об этом заботится сам класс. В итоге, когда мы реализовали подобный класс, мы можем переписать наш тест уже в таком виде:
package com.mycompany.selenium.tests;
import java.util.Date;
import com.mycompany.selenium.lib.Config;
import com.thoughtworks.selenium.*;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
import org.testng.Assert;
/**
* @author KaNoN
*/
public class SampleTestStage01 {
private Selenium selenium = null;
private String delay = "";
private String genTaskName() {
Date dt = new Date();
String result = dt.toString();
result = result.replaceAll( ":" , "" );
result = result.replaceAll( " " , "" );
return result;
}
@BeforeMethod(alwaysRun=true)
public void init() throws Exception {
delay = Config.getProperty( "delay" );
selenium = new DefaultSelenium( Config.getProperty( "host" ) , new Integer( Config.getProperty( "port" ) ) , Config.getProperty( "browser" ) , Config.getProperty( "url" ) );
selenium.start();
selenium.open( Config.getProperty( "url" ) + "/hudson/");
}
@Test(groups = {"sample","sample1"})
public void testCreateJob() throws Exception {
selenium.click("link=New Job");
selenium.waitForPageToLoad(delay);
selenium.type("name", "SampleTask" + genTaskName() );
selenium.click("//input[@value='Build a free-style software project']");
selenium.click("//button[@type='button']");
selenium.waitForPageToLoad(delay);
selenium.click("//button[text()='Save']");
selenium.waitForPageToLoad(delay);
selenium.click("link=Hudson");
selenium.waitForPageToLoad(delay);
}
@AfterMethod(alwaysRun=true)
public void stop() throws Exception {
selenium.stop();
}
}Что нового здесь?
- Вместо явно заданного времени ожидания была зарезервирована переменная delay, которая в начале теста инициализируется значением из файла конфигурации
- Все явные указания хостов, портов и базового адреса тестируемого приложения уже получаются через обращение к классу Config.
В результате, если мы хотим, чтобы наши тесты работали с разных машин, то при их переносе достаточно просто подкорректировать конйигурационный файл.
Шаг 2: Вынесение общих частей тестовых классов в некий базовый класс
Теперь представим, что мы создаем еще один тест. А также представим, что в нем будет присутствовать тоже, что и в предыдущем тесте, учитывая, что работаем мы с тем же приложением?
- Во-первых, это будет инициализация Selenium-а, да и собственно сама переменная, которая содержит в себе Selenium client driver.
- Во-вторых, это будет переменная delay, которую полезно бы использовать во всех тестах.
- В-третьих, это будет вызов метода, который завершает работу Selenium-a.
Создадим некоторый базовый класс для тестов и пусть наш тестовый класс будет наследником от этого базового класса. Итак, базовый класс:
package com.mycompany.selenium.lib;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
/**
* @author KaNoN
*/
public class BaseTestClass extends SecurityManager {
protected Selenium selenium = null;
protected String delay = "";
@BeforeMethod(alwaysRun=true)
public void init() throws Exception {
delay = Config.getProperty( "delay" );
selenium = new ExtendedSelenium( Config.getProperty( "host" ) , new Integer( Config.getProperty( "port" ) ) , Config.getProperty( "browser" ) , Config.getProperty( "url" ) );
selenium.start();
selenium.open( Config.getProperty( "url" ) + "/hudson" );
mainPage = new MainPage( selenium );
}
@AfterMethod(lastTimeOnly=true)
public void stop() throws Exception {
selenium.stop();
}
}Если мы наш тест отнаследуем от данного базового класса, то у нас код упростится до такого вида:
package com.mycompany.selenium.tests;
import com.mycompany.selenium.lib.BaseTestClass;
import java.util.Date;
import org.testng.annotations.Test;
/**
* @author KaNoN
*/
public class SampleTestStage02 extends BaseTestClass {
private String genTaskName() {
Date dt = new Date();
String result = dt.toString();
result = result.replaceAll( ":" , "" );
result = result.replaceAll( " " , "" );
return result;
}
@Test(groups = {"sample","sample2"})
public void testCreateJob() throws Exception {
this.selenium.click("link=New Job");
selenium.waitForPageToLoad(delay);
selenium.type("name", "SampleTask" + genTaskName() );
selenium.click("//input[@value='Build a free-style software project']");
selenium.click("//button[@type='button']");
selenium.waitForPageToLoad(delay);
selenium.click("//button[text()='Save']");
selenium.waitForPageToLoad(delay);
selenium.click("link=Hudson");
selenium.waitForPageToLoad(delay);
}
}То есть, теперь мы оперируем уже только с кодом, который специфичен именно для данного теста.
Шаг 3: Маппинг локаторов и расширение используемых библиотек
Поскольку наш тест работает на уровне ГУИ, то его работоспособность чувствительна к изменениям элементов страницы. Наиболее часто меняются сами локаторы. И если один и тот же локатор используется в разных тестах и по многу раз, то при изменении соответствующего элемента придется править все тесты, которые были завязаны на использование данного уже неактуального локатора. А что будет, если таких элементов будет множество? Даже представить страшно.
Другой момент – удобство понимания того, над каким элементом производится действие. Если еще первый клик по ссылке и ввод текста в данном тесте как-то можно увязать с некоторой страницей, на которой Selenium находится на момент выполнения, то конструкция вида:
selenium.click("//button[@type='button']");может ввести в лугкий ступор. И наконец, просто неудобно постоянно вставлять подобную конструкцию:
selenium.waitForPageToLoad(delay);
Особенно это неудобно, если подобная конструкция используется достаточно часто, причем в комбинации с некоторым фиксированным набором команд. В нашем случае, удобно было бы иметь разновидность метода click, которая бы еще и ждала загрузки страницы. И под шумок можно добавить еще ряд полезного функционала, которого изначально у Selenium-а нет.
И маппинг и расширение Selenium-а рассматриваются в одном контексте, так как это взаимнодополняющие друг друга решения. Например, мне бы хотелось, чтобы я мог передать в метод строку и если она соответствует некоторому псевдониму элемента, то этот псевдоним подхватился бы и подставился фактический локатор. А если такого псевдонима не нашлось, то данная строка бы использовалась как явно заданный локатор.
А теперь решим эту задачу последовательно. Для маппинга нам вполне могут пригодиться те же *.properties файлы. Хотя бы потому, что по сути нам нужно некоторое представление пар: «псевдоним-локатор», что технически эквивалентно паре «свойство-значение». И к тому же, мы уже описали класс, который мы можем использовать для данной задачи.
В том же каталоге config, что мы использовали для хранения конфигурационных файлов, мы можем добавить подкаталог maps, в которых будут храниться карты элементов. Поскольку псевдоним можно задавать произвольно, для удобства их использования можно выработать некоторые правила их именования, например: <page_name>.<element_name>
То есть, мы указываем не только что это за элемент, но и к какой странице он относится. Дальше можно добавить различные соглашения по именованию страниц, элементов определенного типа и т.д. Это уже особенности конкретных проектов.
Для примера добавим в каталог config/maps файл mainpage.properties со следующим содержимым:
leftpanel.newjob=link=New Job leftpanel.hudson=link=Hudson newjobpage.name=name newjobpage.freestyleradio=//input[@value='Build a free-style software project'] newjobpage.ok=//button[@type='button'] configurejob.save=//button[text()='Save']
Полей пока немного, но по мере разрастания имеет смысл разделять map-файлы по модулям, более мелким блокам, когда количество элементов маппинга будет достаточно большим. В любом случае, класс Config при инициализации подхватит эти свойства. Теперь надо как-то сделать так, чтобы команды Selenium-а могли работать и с картами элементов. По сути, для каждой команды должна быть конструкция вида:
public void <selenium_command>( String arg0 ){
try {
if( Config.getProperty( arg0 ) != null ){
arg0 = Config.getProperty( arg0 );
}
}
catch( Exception e ){;}
selenium.<selenium_command>( arg0 );
}И подобные конструкции должны быть для всех методов. Для этого нужно расширить класс DefaultSelenium-а. Причем не просто расширить, а перегрузить практически все методы, у которых количество аргументов больше 0.
Сразу хочу предупредить, что подобный класс будет достаточно большим по объему кода (порядка 800-900 строк), причем весьма однообразного. Поэтому, для минимизации копирования/вставки кода удобно сделать кодогенератор. В Java есть такая вещь, как рефлексия, которая позволяет получить доступ к информации о классе, его методах, полях. Для таких языков как Ruby, Python есть даже более гибкие средства. В Java нам вначале достаточно сделать что-то наподобие:
Class<?> clazz = Selenium.class; Method[] methods = clazz.getDeclaredMethods();
А потом уже в цикле пройтись по всем методам и сгенерировать текст нашего класса, выведя его в консоль. То есть сам кодогенератор выглядит так:
package com.mycompany.selenium.utils;
import com.thoughtworks.selenium.Selenium;
import java.lang.reflect.Method;
/**
* @author KaNoN
*/
public class ExtGenerator {
/**
* @param args
*/
public static void main(String[] args) {
Class<?> clazz = Selenium.class;
Method[] methods = clazz.getDeclaredMethods();
String classText = "package com.mycompany.selenium.lib;\r\n" +
"\r\nimport com.thoughtworks.selenium.*;" +
"\r\n\r\n\r\npublic class ExtendedSelenium{\r\n" +
"\r\n\tprotected Selenium selenium = null;\r\n\r\n" +
"\tpublic ExtendedSelenium(String serverHost, int serverPort,\r\n" +
"\t\tString browserStartCommand, String browserURL) {\r\n" +
"\t\t\tselenium = new DefaultSelenium(serverHost, serverPort, browserStartCommand, browserURL);\r\n" +
"\t}\r\n\r\n";
for( Method method:methods ){
classText = classText + "\tpublic " + method.getReturnType().getName() + " " + method.getName() + "(";
Class<?> classes[] = method.getParameterTypes();
for( int i = 0 ; i < classes.length ; i++ ){
classText = classText + " " + classes[i].getName() + " arg" + i;
if( i < classes.length - 1 ){
classText = classText + " , ";
}
}
classText = classText + " ){\r\n";
if( classes.length == 0 ){
classText = classText + "\t\t" +
((method.getReturnType().toString().equals( "void" ) )?( "" ):( "return " )) +
"selenium." + method.getName() + "();\r\n\t}\r\n\r\n";
}
else {
classText = classText + "\t\ttry {\r\n\t\t\tif( Config.getProperty( arg0 ) != null )" +
"{\r\n\t\t\t\targ0 = Config.getProperty( arg0 );\r\n\t\t\t}\r\n\t\t}\r\n" +
"\t\tcatch( Exception e ){;}\r\n";
classText = classText + "\t\t" +
((method.getReturnType().toString().equals( "void" ) )?( "" ):( "return " )) +
"selenium." + method.getName() + "(";
for( int i = 0 ; i < classes.length ; i++ ){
classText = classText + " arg" + i;
if( i < classes.length - 1 ){
classText = classText + " , ";
}
}
classText = classText + " );\r\n\t}\r\n\r\n";
}
}
classText = classText.replaceAll( "\\[Ljava.lang.String;" , "String[]" );
classText = classText.replaceAll( "java.lang." , "" );
classText = classText + "}";
System.out.println( classText );
}
}Запустив это приложение мы потом можем создать класс com.mycompany.selenium.lib.ExtendedSelenium. Единственное, что в этот класс еще можно добавить, это некоторые расширенные методы. Например, методы, которые при клике или выборе элемента из списка еще ждали бы загрузку страницы. Дополним этот класс следующим кодом:
public void clickAndWait( String locator ) throws Exception {
click( locator );
waitForPageToLoad( Config.getProperty( "delay" ) );
}
public void selectAndWait( String locator , String option ) throws Exception {
select( locator , option );
waitForPageToLoad( Config.getProperty( "delay" ) );
}После этого мы обновим com.mycompany.selenium.lib.BaseTestClass, заменив все вхождения DefaultSelenium на ExtendedSelenium. В результате этих изменений, мы уже можем использовать карту элементов вместо явно заданных локаторов, а совместные вызовы click и waitForPageToLoad можно заменить на clickAndWait. После таких преобразований наш тест приобретает вид:
package com.mycompany.selenium.tests;
import com.mycompany.selenium.lib.BaseTestClass;
import java.util.Date;
import org.testng.annotations.Test;
/**
* @author KaNoN
*/
public class SampleTestStage03 extends BaseTestClass {
private String genTaskName() {
Date dt = new Date();
String result = dt.toString();
result = result.replaceAll( ":" , "" );
result = result.replaceAll( " " , "" );
return result;
}
@Test(groups = {"sample","sample3"})
public void testCreateJob() throws Exception {
selenium.clickAndWait( "leftpanel.newjob" );
selenium.type("newjobpage.name", "SampleTask" + genTaskName() );
selenium.click("newjobpage.freestyleradio");
selenium.clickAndWait("newjobpage.ok");
selenium.clickAndWait("configurejob.save");
selenium.clickAndWait("leftpanel.hudson");
}
}Уже намного проще читать и понимать, что же выполняется, да и объем кода теста заметно уменьшился. То есть улучшения хорошо заметны. Но на этом останавливаться рано.
(Продолжение следует…)
Часть 2
»
- Войдите или зарегистрируйтесь, чтобы получить возможность отправлять комментарии



