Om författaren

Mikael Silvén är en ingenjör som brinner för mjukvaruutveckling. Entusiastisk och intresserad för allt relaterat till programmering och teknik har han byggt upp en omfångsrik kompetens. Som konsult strävar han efter att sprida kunskap och höja kvalitén hos kunder och kollegor i branschen.

Med allt fler och alltmer avancerade hemsidor växer kraven på testning av den användarnära koden dramatiskt. Ett verktyg som länge använts för detta syfte heter selenium. Selenium fungerar genom att skicka kommandon till en riktig webbläsare som får utföra jobbet.

Men det är fortfarande många som inte kommit igång med att använda det. Kanske för att många guider som finns på internet involverar Selenium Grid, som är ett stort system för att centralisera access till webbläsare att köra testerna i, med mycket komplicerad infrastruktur som är långsam och kan gå fel. Jag gillar när saker är enkla. Så i detta blogginlägg tänkte jag visa hur man snabbt kan komma igång med att använda selenium i formen av enhetstester skrivna i Python samt dela med mig av några tricks jag hittat på. Selenium går att använda från flera olika språk, men Python är min favorit för denna sortens uppgifter.

Installation och basklass

Selenium-paketet installeras enkelt med pip. python3 -m pip install selenium. Sedan kan vi börja skriva kod.

För detta ändamål tycker jag att en gemensam basklass för att återanvända funktionalitet är en bra utgångspunkt. Det enda som behöver göras är några enkla metodanrop för att starta och stänga ner vår webdriver, den komponenten som kommunicerar med webbläsaren. I denna bloggpost kommer jag använda mig av Chrome.

from selenium import webdriver
from selenium.webdriver.chrome.options import Options

class ChromeTest(unittest.TestCase):
    """Base class for all selenium based tests.
    It uses setUpClass to init the browser. Meaning that all tests 
    within the same class share a session.

    One can use the regular setUp() function and members to handle
    login and ensure you're only logged in once.
    """

    @classmethod
    def setUpClass(cls):
        chrome_options = Options()
        width = os.environ.get('SCREEN_WIDTH', 1920)
        height = os.environ.get('SCREEN_HEIGHT', 1080)
        chrome_options.add_argument(
            "--window-size={},{}".format(width, height))  
        chrome_driver = _get_driver()
        cls.driver = webdriver.Chrome(options=chrome_options,
                                      executable_path=chrome_driver)
        super().setUpClass()

    @classmethod
    def tearDownClass(cls):
        cls.driver.quit()
        super().tearDownClass()

Funktionen _get_driver har i uppgift att hämta rätt webdriver-binär. De kan hämtas direkt från internet om man tänker att man vågar det. Min hjälpfunktion för detta följer nedan.

def _get_driver(version=None):
    """Helper function to download the webdriver required to run
    selenium tests in a Chrome browser."""
    if version is None:
        version_info_file = "https://chromedriver.storage.googleapis.com/LATEST_RELEASE"
        with urllib.request.urlopen(version_info_file) as f:
            version = f.read().decode('utf-8').strip()
    # Multi-platform support!
    platform = {
        'linux': "linux64",
        'win32': "win32",
        'darwin': "mac64",
    }[sys.platform]
    os.makedirs(_DRIVER_DIR, exist_ok=True)
    local_file = os.path.join(_DRIVER_DIR,
                              "chromedriver-{}".format(version))
    driver_file = "chromedriver"
    # Append .exe for windows platform
    if platform == "win32":
        local_file += ".exe"
        driver_file += ".exe"
    if os.path.exists(local_file):
        return local_file
    driver_zip = ("https://chromedriver.storage.googleapis.com"
                  + "/{}/chromedriver_{}.zip".format(
        version, platform))
    with urllib.request.urlopen(driver_zip) as src:
        buffer = io.BytesIO()
        shutil.copyfileobj(src, buffer)
        with zipfile.ZipFile(buffer, 'r') as archive:
            with archive.open(driver_file) as member:
                with open(local_file, 'wb') as dst:
                    shutil.copyfileobj(member, dst)
    # Zip files do not preserve file permissions,
    # must set +x for linux support
    st = os.stat(local_file)
    os.chmod(local_file, st.st_mode | stat.S_IEXEC)
    return local_file

Välj en _DRIVER_DIR där du vill spara dina nedladdningar.

Ansluta till annan instans

Selenium måste inte kommunicera med en webbläsare på den lokala datorn, utan kan istället kommunicera med en på en annan maskin. Detta gör att man köra sina tester under t.ex. Linux, men testa mot en webbläsare som kör under Windows. Då kan du istället starta din webdriver så här.

from selenium import webdriver
from selenium.webdriver.common.desired_capabilities import DesiredCapabilities
cls.driver = webdriver.Remote(
    remote_ip = _get_ip_of_remote_seleneium_worker()
    command_executor='http://{}:4444/wd/hub'.format(remote_ip),
    desired_capabilities=DesiredCapabilities.CHROME)

En annan fördel med detta är att om du har ett flöde för automatiska tester kan du med fördel använda docker image selenium/standalone-chrome:latest och på så sätt hålla din egen docker image minimal.

Hjälpmetoder

Innan vi fortsätter lägger vi till några fler hjälpmetoder i vår basklass som vi kommer vilja ha strax.

from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait
    def locate(self, by, what):
        """Helper function to locate an element and wait
        a while for it to properly show up.
        Good for dynamically added/hidden/shown elements."""
        WebDriverWait(self.driver, 2).until(
            EC.visibility_of_element_located((by, what)),
            "Could not find element by '{}'='{}'".format(by, what))
        return self.driver.find_element(by, what)
    @contextmanager
    def reloads_page(self):
        """Helper function to wait for a page to reload."""
        body = self.driver.find_element_by_tag_name("body")
        yield
        WebDriverWait(self.driver, 5).until(
            EC.staleness_of(body),
            "Waited for page to reload but it didn't")

Den första, locate, hjälper oss hitta ett visst element. Den väntar också en liten stund på att elementet ska hinna ladda och dyka upp. Mycket hjälpsamt när sidan innehåller dynamiskt material.

Den andra, reloads_page, är hjälpsam när en handling kommer få hemsidan att laddas om. Den inväntar då på att body-taggen ska laddas ur. Vi är nu redo att skriva ett snabbt litet test!

Exempel

Vårt första riktiga test: Vad sägs om att söka efter Attentecs hemsida på Google?

from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
class ExampleTest(ChromeTest):
    def test_searching_works(self):
        self.driver.get('http://google.com')
        search_bar = self.locate(By.XPATH, '//input[@name="q"]')
        search_bar.send_keys("Attentec.se")
        with self.reloads_page():
            search_bar.send_keys(Keys.ENTER)
        self.assertIn("Attentec utvecklar lösningar",
                      self.driver.page_source)

Här använder vi oss av locateoch XPath för att hitta den välkända sökrutan på Googles förstasida. Sedan skriver vi lite text och med reloads_page hanterar vi omladdningen som sker av att vi skickar ett Enter. Vi kan sedan testa någonting meningsfullt, t.ex. att en viss text ska finnas på sidan.

Hantera fel

Selenium kommer glatt att kasta undantag om minsta lilla sak går fel, ibland med felmeddelanden som lämnar en del att önska. Exempelvis om någonting inte var synligt eller inte gick att klicka på. Då kan det vara hjälpsamt att ta en skärmdump, något som selenium också kan hjälpa till med, om vi bara frågar.

Så antingen kan vi omsluta alla våra testfunktioner med try-except. Eller så kan vi använda en av de coolaste funktionerna i Python: metaklasser! Metaklasser låter oss köra kod på klassdefinitioner av subklasser, utan att utvecklaren av subklassen märker någonting. Så låt oss definiera en metaklass, som ser till att spara en skärmdump om ett test misslyckas.

class TakesScreenshot(type):
    """Metaclass to automatically wrap all test method so
    developers' doesn't risk forgetting. """
    def __new__(cls, name, bases, attrs):
        for attrname, attrvalue in attrs.items():
            if attrname.startswith('test') and callable(attrvalue):
                attrs[attrname] = _screenshot_wrapper(name, attrvalue)
        return super().__new__(cls, name, bases, attrs)
def _screenshot_wrapper(name, fn):
    """Python function decorator to capture
    screenshots before passing exceptions up the call stack. """
    def _inner(testclass, *args, **kwargs):
        try:
            fn(testclass, *args, **kwargs)
        except Exception as e:
            timestamp = int(time.time())
            filename = "screenshot-{}-{}-{}-{}.png".format(
                name, fn.__name__, e.__class__.__name__, timestamp)
            # Maybe not the cleanest way to access the driver
            testclass.driver.save_screenshot(filename)
            raise e
    return _inner

Vi använder information från undantaget som kastades för att ge filen ett beskrivande namn. Allt vi behöver göra nu är att deklarera att vi använder TakesScreenshot som metaklass i vår tidigare basklass, så kommer alla misslyckade testfall att ge oss en skärmdump. Mycket smidigt när någonting går snett i ett automatiserat flöde långt från sin egen dator.

class ChromeTest(unittest.TestCase, metaclass=TakesScreenshot):

Jag hoppas detta inlägg varit upplysande och hjälper er att komma igång att testa era hemsidor. Länk till dokumentationen för Pythongränssnittet till selenium hittar ni här.

Uppdatering 2019-10-18: Argumentet för att sätta fönsterstorleken till Chrome verkar använda ett kommatecken numera.