Introduction à Selenium


Apéro Web Nancy


19 septembre 2013


Auteur Stéphane Klein

http://stephane-klein.info

Qui suis-je ?

Stéphane Klein

Stéphane Klein

Activité professionnelle
Développeur / Admin-sys
depuis 2001

Actuellement
télétravailleur à Metz

une SCOP à Paris
Hébergement, infogérance, développement d'app. web

www.bearstech.com

Je suis Libriste

Passionné par le dev web
Frontend / Backend

Pongiste, runner

Plus d'info :
http://stephane-klein.info http://cv.stephane-klein.info @klein_stephane

Plan

  1. Un peu d'histoire
  2. Demo
  3. Installation
  4. Intégration dans les tests
  5. API Python Selenium 2
  6. Astuces

Un peu d'histoire

Mon parcours personnel dans la mise en oeuvre des tests UI

Vous allez voir,
mon parcours a été long

avec de nombreux échecs

J'ai beaucoup cherché,
posé des questions…

Dans la confidence

je trouvais personne qui faisait des tests UI pour de vrai

Début de l'histoire

2006

J'assiste à une présentation
Selenium
au FOSDEM 2006

J'étais dans une période où je n'arrivais plus à avoir confiance en mes développements.

Cette présentation m'a fait prendre conscience de l'utilité et de l'importance des tests unitaire…

Si vous essayez de sensibiliser des collaborateurs aux tests,
le test UI est un bon point d'entré

Difficultés

de mise en oeuvre de Selenium en 2006

Mes scripts étaient écrits
dans un tableau HTML

Exécutés dans une page html,
avec une iframe en bas de la page

  • Lent
  • Pas pratique à écrire
  • Pas headless
  • Difficile à lancer
  • Problème avec les boites de dialogues alert
  • Problème avec file upload
  • Mise au point pénible car très lent

Selenium 1 Abandon

Pendant ce temps

Je faisais mes tests UI avec Webtest


res = app.get('/form.html')
form = res.forms['myform']
form['firstname'] = u'Stéphane'
form['lastname'] = u'Klein'
res = form.submit('submit')
self.assertEqual(res.status_int, 200)

form = res.forms['myform']
self.assertEqual(form['firstname'].value, u'Stéphane')
                        

Inconvénient, en 2013, dans les UI modernes presque tout dépend du Javascript coté client

  • Ember
  • AngularJS
  • ...

2008

Expérimentation de Windmill

En Python, plus ou moins un clone de Selenium

+ intégration dans Test Suite

Inconvénients

  • Lent
  • Pas headless
  • Problème avec les boites de dialogues alert
  • Manque de maturité à l'époque
  • Mise au point pénible car très lent

Après quelques contribution…, pas de solution pour la lenteur

Windmill Abandon

Toujours en 2008

Expérimentation de DOH Robot (Dojo Toolkit)

  • Une Applet Java
  • La souris bouge pour de vrai

Inconvénients

  • Lent
  • Il ne faut pas toucher la souris ni le clavier pendant les tests
  • Pas pratique à lancer

DOH Robot Abandon

2010

Selenium 2

  • une API plus propre en Python
  • support File Upload
  • gestion des Alert
  • lancement automatique que Firefox, Chrome...

Mais

  • Lent
  • Pas headless
  • Mise au point pénible car très lent

Selenium 2 Abandon

car trop lent

Début 2012

Je découvre PhantomJS

  • Pratique pour générer du PDF
  • Headless
  • Très rapide
  • Pas pratique pour des tests UI

PhantomJS

je n'ai pas fait de mise en pratique

Juin 2012

Je découvre CasperJS

  • Simple à lancer
  • API orienté pour les tests
  • Rapide
  • Headless

CasperJS Succès

Depuis 2006, c'est la première fois que je trouve une bonne solution

J'ai réussi à l'intégrer dans ma stack

Package webtest-casperjs

Debug pas simple car 100% headless

J'ai continué à l'utiliser

Mais je n'ai pas aimé la syntax

Fin 2012

Je découvre Ghost.py

  • Comme PhantomJS mais en Python
  • Basé sur Qt
  • Syntax linéaire en Python
  • S'installe assez simplement (sous Linux)
  • Debug pas simple car 100% headless
  • Beaucoup de segfault sous OS X

Ghost.py Succès

J'ai continué à l'utiliser
à la place de CasperJS

J'ai essayé de fixer les Segfault

difficile

Juillet 2013

Après de nombreux segfault, je regarde de nouveau Selenium

Maintenant, Selenium permet d'utiliser PhantomJS

Pratique pour :

  • headless
  • la vitesse

Point positif :
depuis la version 1.8, PhantomJS intègre directement

WebDriver Wire Protocol

très simple à installer


$ pip install selenium
                        

self.browser = webdriver.Firefox()
                        

ou


self.browser = webdriver.PhantomJS(phantomjs_binary)
                        

Autre bonne surprise…

Selenium avec Firefox ou Chrome
est maintenant très rapide !

  • Simple à installer
  • Bonne API Python
  • Rapide
  • Headless
  • Sans headless pour debug
  • Stable
  • Support Alert
  • File upload

Selenium 2 en 2013 Succès

Demo

Installation

(contexte Python)

Première bonne nouvelle...

...pas besoin d'installer :

Simplement :


$ pip install selenium
                        

Ou dans votre projet Python :


$ cat devel-requirements.txt
...
selenium
...
                        

Et pour l'installation de PhantomJS ?

J'utilise le morceau de code suivant au lancement des tests

def download_phantomjs():
    if not os.path.exists(phantomjs_binary):
        print('download phantomjs...')
        tmp_dir = mkdtemp()
        target_zip_file = os.path.join(
            tmp_dir, 'phantomjs-1.9.1-macosx.zip'
        )
        f = open(target_zip_file, 'w')
        f.write(urllib2.urlopen(
            'http://phantomjs.googlecode.com/files/phantomjs-1.9.1-macosx.zip'
        ).read())
        f.close()

        z = ZipFile(target_zip_file)
        z.extract(
            'phantomjs-1.9.1-macosx/bin/phantomjs',
            tmp_dir
        )
        z.close()
        os.rename(
            os.path.join(tmp_dir, 'phantomjs-1.9.1-macosx', 'bin', 'phantomjs'),
            phantomjs_binary
        )
        os.chmod(phantomjs_binary, '777')
        rmtree(tmp_dir)
        print('phantomjs installed')
                        

Intégration

dans les tests

En Python, on utilise :

Exemple simple

import unittest

class TestBasic(unittest.TestCase):
    def setUp(self):
        # Par exemple :
        #   * initialisation de mes objets
        #   * lancement de fixtures

    def test_basic(self):
        self.assertEqual(1, 1)
        self.assertEqual(1, 2)

    def test2(self):
        pass

    def tearDown(self):
        # nettoyage...

$ bin/nose2 -v
test2 (test_basic.TestBasic) ... ok
test_basic (test_basic.TestBasic) ... FAIL

======================================================================
FAIL: test_basic (test_basic.TestBasic)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/private/tmp/exemple_test/test_basic.py", line 10, in test_basic
    self.assertEqual(1, 2)
AssertionError: 1 != 2

----------------------------------------------------------------------
Ran 2 tests in 0.000s

FAILED (failures=1)

Ce dont j'ai besoin pour
mes tests Selenium :

  • Initialiser une instance de test de mon app
  • Injecter des fixtures
  • Lancer mon application
  • Quitter l'application à la fin d'un test

Ces étapes sont rejouées pour chaque test Selenium

from selenium import webdriver

class SeleniumBaseTestCase(BaseTestCase):
    def setUp(self):
        initialize_sql(self.settings, drop_all=True)
        self.load_demo_data()
        self.load_menus()
        self.create_admin_user('admin', 'password')

        self.app = main({}, **self.settings)
        self.test_app = TestApp(self.app)

        port = get_free_port()
        self.server = subprocess.Popen(
            ['', 'test.ini', 'http_port=%s' % port],
            executable='bin/pserve',
            cwd=os.path.abspath(os.path.join(here, '..')),
            stdout=subprocess.PIPE
        )
        self.application_url = 'http://127.0.0.1:%s/' % port
        check(self.application_url)

        download_phantomjs()
        self.browser = webdriver.Firefox()
        #self.browser = webdriver.PhantomJS(phantomjs_binary)

    def tearDown(self):
        super(SeleniumBaseTestCase, self).tearDown()
        self.server.terminate()
                        
import time
from pyquery import PyQuery as pq

from base import SeleniumBaseTestCase


class TestUI(SeleniumBaseTestCase):
    def __frontoffice_login(self, login, password):
        self.browser.get(self.application_url + 'login.html')
        self.browser.find_element_by_name("adresse_mail").send_keys(login)
        self.browser.find_element_by_name("mot_de_passe").send_keys(password)
        self.browser.find_element_by_name("login").click()

    def __backoffice_login(self, login, password):
        self.browser.get(self.application_url + 'admin/login.html')
        self.assertIn(u'Servivit - Page de connexion au backoffice', self.browser.page_source)
        self.browser.find_element_by_name("adresse_mail").send_keys(login)
        self.browser.find_element_by_name("password").send_keys(password)
        self.browser.find_element_by_name("login").click()

    def test_basic(self):
        self.delete_all_commandes()
        self.__frontoffice_login("brody_gleichner@grant.co.uk", "19b41x9p")
        self.assertIn('commander-selection-menus.html', self.browser.current_url)
        self.browser.wait_for_element(css_selector='BODY.loaded')

        d = pq(self.browser.page_source)
        self.assertEqual(
            [pq(day_item)('.date').text() for day_item in d('.day-item')],
            [
                u'jeudi 01 août 2013', u'vendredi 02 août 2013', u'lundi 05 août 2013',
                u'mardi 06 août 2013', u'mercredi 07 août 2013', u'jeudi 08 août 2013',
                u'vendredi 09 août 2013'
            ]
        )

        self.browser.execute_script(dedent(
            """
            Ember.run(function() {
                $('.day-item:eq(5) INPUT:eq(2)').click();
                $('.day-item:eq(5) INPUT:eq(5)').click();
                $('.day-item:eq(5) INPUT:eq(11)').click();
                $('.day-item:eq(5) INPUT:eq(13)').click();
            });
            $('.day-item:eq(5) INPUT[type="text"]').simulate("key-sequence", {sequence: "{selectall}{del}Stéphane Klein"});
            Ember.run(function() {
                $('.day-item:eq(5) .commit-order-button').click();
            });
            """
        ))
        result = self.browser.execute_script(
            "return $('.day-item:eq(5) .validation-row').text()"
        )
        self.assertIn(u'9.80 €', result)
        self.assertIn(u'Commande validé', result)

        result = self.browser.execute_script(
            """return $('.day-item:eq(5) INPUT[type="text"]').val()"""
        )
        self.assertEqual(u'Stéphane Klein', result)
        time.sleep(3)
        self.browser.get(self.application_url + 'logout.html')

        self.__backoffice_login('bad@example.com', 'bad')
        self.assertIn(u'Adresse mail ou mot de passe invalide !', self.browser.page_source)
        self.__backoffice_login('admin', 'password')
        self.assertNotIn(u'Adresse mail ou mot de passe invalide !', self.browser.page_source)
        self.assertIn(u'Servivit - Backoffice - Accueil', self.browser.page_source)

        self.browser.get(self.application_url + 'admin/commandes/')
        self.assertIn(u'Servivit - Backoffice - Liste des commandes', self.browser.page_source)

        d = pq(self.browser.page_source)
        result = [''.join(list(td.itertext())) for td in d('.commande-item')[0]]
        self.assertEqual(result[0], '2013-08-08')
        self.assertEqual(result[1], 'Malachi Fritsch')
        self.assertEqual(result[2], u'Stéphane Klein')
        self.assertIn('2-3-1-3-0', result[3])
                    
$ bin/nose2 -v
test_import_v1 (test_import_menus.ModelTest) ... ok
test_import_v2 (test_import_menus.ModelTest) ... ok
test_carte_du_jour (test_models.ModelTest) ... ok
test_commande (test_models.ModelTest) ... ok
test_user (test_models.ModelTest) ... ok
test_get_cartes (test_rest.RestTestCase) ... ok
test_set_and_get_commandes (test_rest.RestTestCase) ... ok
test_basic (test_ui.TestUI) ... ok

----------------------------------------------------------------------
Ran 8 tests in 52.770s

API Python

de Selenium 2

Les documentations :

  • Officiel : doc de l'API auto généré depuis de code source
  • Non officiel : doc plus narrative

Quelques commandes de base


self.browser.get(self.application_url + 'login.html')
                        
self.assertNotIn(
    u'Adresse mail ou mot de passe invalide !',
    self.browser.page_source
)
self.assertIn('commander-selection-menus.html', self.browser.current_url)
self.browser.find_element_by_name("adresse_mail").send_keys(login)

Traitement des balises <select>


self.assertTrue(
    self.browser.find_elements_by_xpath(
        "//*[@name='application_id'] // option[@value=2]"
    )[0].is_selected()
)
                        

self.browser.find_elements_by_xpath(
    "//*[@name='application_id'] // option[@value=1]"
)[0].click()
                        

Liste des méthodes "find_element..." :

  • find_element(by='id', value=None)
  • find_element_by_class_name(name)
  • find_element_by_css_selector(css_selector)
  • find_element_by_id(id_)
  • find_element_by_name(name)
  • find_element_by_tag_name(name)
  • find_element_by_xpath(xpath)
  • ...
self.browser.wait(timeout).until(lambda driver: drive.find_element_by_css_selector('BODY.loaded'), message='Timeout message')

Avec la lib webdriverwrapper :

self.browser.wait_for_element(css_selector='BODY.loaded')

Exécution de Javascript dans la page


self.browser.execute_script(dedent(
    """
    Ember.run(function() {
        $('.day-item:eq(5) INPUT:eq(2)').click();
        $('.day-item:eq(5) INPUT:eq(5)').click();
        $('.day-item:eq(5) INPUT:eq(11)').click();
        $('.day-item:eq(5) INPUT:eq(13)').click();
    });
    $('.day-item:eq(5) INPUT[type="text"]').simulate("key-sequence", {sequence: "{selectall}{del}Stéphane Klein"});
    Ember.run(function() {
        $('.day-item:eq(5) .commit-order-button').click();
    });
    """
))
                        

Test d'une valeur retournée par Javascript


result = self.browser.execute_script(
    "return $('.day-item:eq(5) .validation-row').text()"
)
self.assertIn(u'9.80 €', result)
self.assertIn(u'Commande validé', result)
                        
self.browser.capture_entire_page_screenshot(filename, kwargs)
self.browser.capture_screenshot(filename)

Dans l'API il manque une méthode pour prendre un screenshot d'un élément

Mes commentaires à propos de l'API

  • Il manque quelques méthodes
  • Manque une solution simple pour gérer les <select>
  • On peut simplifier des choses

Je vais voir si on peut pousser les ajouts de webdriverwrapper directement dans la lib selenium

Astuces

Utilisation de PyQuery
pour parser le contenu HTML


from pyquery import PyQuery
d = PyQuery(self.browser.page_source)
self.assertEqual(
    [pq(day_item)('.date').text() for day_item in d('.day-item')],
    [
        u'jeudi 01 août 2013', u'vendredi 02 août 2013', u'lundi 05 août 2013',
        u'mardi 06 août 2013', u'mercredi 07 août 2013', u'jeudi 08 août 2013',
        u'vendredi 09 août 2013'
    ]
)

Je peux informer mon application
que je suis en mode TEST


$ cat test.ini
...
test_mode=True
test.date.today = 2013-08-01
webassets.base_dir=%(here)s/servivit/static
webassets.base_url=/static
webassets.debug=False
webassets.updater=timestamp
webassets.cache=True
scheduler_enabled=False
...

Cela me permet par exemple :

  • désactiver l'envoi des mails (ça va vers un fichier)
  • fixer la date du browser
  • charger les lib JS supplémentaire
  • ...

En mode Test,
je change la datetime du browser


% if 'date.today' in request.registry.settings:
<%
fixed_date = request.registry.settings['date.today'].split('-')
%>

% endif

Un de mes rêves

Réaliser des screencasts
de doc via les tests

Pour cela il me manque une lib pour simuler (visuellement)
le curseur de la souris + click

Gros point : les fixtures

Pour cela, j'utilise une lib Faker.
Inspiré par FactoryBoy/FactoryGirl

>>> from faker import firstname
>>> firstname()
u'Judith'

>>> firstname(gender='m')
u'Sean'

>>> firstname(gender='f')
u'Shirley'

>>> from faker import lastname
>>> lastname()
u'Potts'

>>> from faker import name
>>> name()
(u'Joe', u'Richmond')

>>> name(gender='f')
(u'Beverly', u'Matthews')
>>> settings['lang'] = 'en'
>>> from faker import companies
>>> companies.name()
u'Bank of America Corp.'

>>> companies.city()
u'Framingham'

>>> companies.domain()
u'dow.com'
>>> from faker import mail
>>> mail()
u'joshua.page@cbre.com'

>>> mail(firstname='linus', lastname='torvalds')
u'linus.torvalds@albemarle.com'

Une lib custom de Random


import faker
from faker.utils import UUID, FakeRandom

uuid = UUID()
random = FakeRandom()

faker.settings['lang'] = 'fr'
faker.settings['random'] = random

...

Mon but : avoir du random, mais le même à chaque lancement


>>> random.randint(0, 10)
6
>>> random.randint(0, 10)
7
>>> random.randint(0, 10)
3
>>> random.choice([17, 18, 19])
19
>>> random.choice([17, 18, 19])
18
>>> random.choice([17, 18, 19])
19
>>> random.random_date(delta=30)
datetime.datetime(2013, 10, 15, 12, 34, 0, 744048)
>>> random.random_date(delta=30)
datetime.datetime(2013, 9, 25, 12, 34, 0, 744048)

Cela me permet de faire facilement mes populates

Et ensuite faire des asserts sur

  • des valeurs qui changent pas à chaque populate
  • des dates qui ne changent pas (tip browser)

Difficultés

Lors de la modification des Fixtures… éviter d'avoir trop de changements

C'est sur ce point que je n'ai pas trouvé de solution optimale

Tests fonctionnels
vs
tests unitaires

Tests fonctionnels

plus « vrai »

Tests unitaires

plus utile lors de refactoring
ou comprendre le code…

Et vous ?

Vos expériences dans le domaine ?

Vous avez d'autres méthodes ?

Contact