Selenium RC (Java): Шаги усовершенствования тестов. Часть 1

Часть 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, который содержал бы в себе следующий функционал:

  1. Загрузка всех *.properties файлов из заданного каталога и всех подкаталогов произвольной вложенности
  2. Извлечение значения свойства по его имени
  3. Объект данного класса должен быть один в системе и должен самостоятельно обеспечить инициализацию свойств без явного указания на данную операцию

А теперь всё то же самое, но пошагово и с примерами.

Для загрузки всех свойств нам нужен метод, которому надо указать каталог. Причем, метод должен вызываться рекурсивно (у нас произвольный уровень вложенности каталогов). Изначально получаем что-то наподобие:

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();
    }
}

Что нового здесь?

  1. Вместо явно заданного времени ожидания была зарезервирована переменная delay, которая в начале теста инициализируется значением из файла конфигурации
  2.  Все явные указания хостов, портов и базового адреса тестируемого приложения уже получаются через обращение к классу Config.

В результате, если мы хотим, чтобы наши тесты работали с разных машин, то при их переносе достаточно просто подкорректировать конйигурационный файл.

Шаг 2: Вынесение общих частей тестовых классов в некий базовый класс

Теперь представим, что мы создаем еще один тест. А также представим, что в нем будет присутствовать тоже, что и в предыдущем тесте, учитывая, что работаем мы с тем же приложением?

  1. Во-первых, это будет инициализация Selenium-а, да и собственно сама переменная, которая содержит в себе Selenium client driver.
  2. Во-вторых, это будет переменная delay, которую полезно бы использовать во всех тестах.
  3. В-третьих, это будет вызов метода, который завершает работу 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

 

© 2009-2010 Портал для автоматизаторов тестирования ПО
Автор проекта Поляруш Михаил | При использовании материалов ссылка на www.automated-testing.info обязательна.
Все замечания и пожелания присылайте на webmaster@automated-testing.info.