Travis CI: Getting Started With Testing Django Sites Using Travis CI And Selenium


TravisCI is a very powerful testing tool. It allows you to easily implement Continuous Integration into your Git/Github workflow. On a basic level, CI is running tests for every push in a repo. This is useful because if you write good tests, you can catch new bugs as they are created. Often times that code you pushed that fixes three issues, can create 10 more. CI helps to keep your repo clean from these tainted commits. Today we are going to be diving into CI by using Selenium and a Firefox headless browser to test a Django site. Lets assume we have a Django project structured like so...

myproject/
  .travis.yml
  requirements.txt
  myproject/
    wsgi.py
    settings.py
    urls.py
  home/
    models.py
    views.py
    tests.py
    urls.py
  templates/
    index.html
    404.html

Since this is an example, we are going to be using a very basic django site. The only url paths the site supports will be, /page1/, /page2/, /page3/. We will use page_id to refer to the integer pointing to the page number.

from django.urls import path

from . import views

urlpatterns = [
    path('page<int:page_id>/', views.index),
]

Each page contains two buttons, next and previous. The next button sends the user to the page pointing to page_id +1, previous button points to page_id - 1. Buttons should be disabled if the page_id points to an invalid number. For example, on page1 the previous button should be disabled because there is no page 0. Here is an example of our view. One thing to note is that we will be raising the Http404 exception if the page_id is invalid. This was added to show an example of how we can test for a 404 status code. The template used to display 404 messages will be 404.html

from django.shortcuts import render
from django.http import Http404


def index(request, page_id=1):
    if page_id > 3 or page_id < 1:
        raise Http404('Invalid page id')

    prev_btn = page_id - 1 if page_id - 1 > 0 else None
    next_btn = page_id + 1 if page_id + 1 <= 3 else None
    context = {'page_id': page_id, 'prev_btn': prev_btn, 'next_btn': next_btn}

    return render(request, 'index.html', context)


def page_not_found(request, exception):
    return render(request, '404.html')

And here is what our index.html file looks like.

<body>
  <h3>The current page is {{ page_id }}</h3>
  {% if prev_btn is None %}
  <a type="button" id="prev" disabled>Prev</a>
  {% else %}
  <a type="button" id="prev" href="/page{{ prev_btn }}/">Prev</a>
  {% endif %}
  
  {% if next_btn is None %}
  <a type="button" id="next" disabled>Next</a>
  {% else %}
  <a type="button" id="next" href="/page{{ next_btn }}/">Next</a>
  {% endif %}
</body>

Now we are ready to setup the testing class. Our tests will be placed in home/tests.py. Since this is a simple example, we will only be testing links and that each page appropriately serves 200/404 status codes. If you were to be testing a real Django site you would definitely want to test your models. However, we do not have anything in home/models.py and testing models is almost identical to typical python testing. The StaticLiveServerTestCase class includes plenty of the built-in assertion methods from unittest.TestCase, so we will be avoiding testing models today.

Assume we have a basic testing class shown below.

from django.contrib.staticfiles.testing import StaticLiveServerTestCase
from selenium import webdriver
from selenium.webdriver.firefox.options import Options


class BasicSiteTestCase(StaticLiveServerTestCase):
    def setUp(self):
        options = Options()
        options.add_argument('-headless')

        self.driver = webdriver.Firefox(firefox_options=options)
        self.driver.implicitly_wait(5)
        self.driver.maximize_window()
        super(BasicSiteTestCase, self).setUp()

    def tearDown(self):
        self.driver.quit()
        super(BasicSiteTestCase, self).tearDown()

    def assert_404(self, url):
        response = self.client.get(url)
        self.assertTemplateUsed(response, '404.html')

    def assert_not_404(self, url):
        response = self.client.get(url)
        self.assertTemplateNotUsed(response, '404.html')

    def back(self):
        """Simulates clicking the back button in the browser"""
        self.driver.execute_script("window.history.go(-1)")

When using selenium it can be tricky to determine if a page returns a 404 response code. When raising Http404 exception, Django returns the preselected view that renders 404.html. This causes the response code to be 200 (even though the page does not exist), since Django is successfully returning a response from a view. Therefore, I believe it is useful to test frontend code with Selenium and check responses using django.test.Client and SimpleTestCase.assertTemplateUsed / SimpleTestCase.assertTemplateNotUsed assertions (which can be seen in the assert_404 method above). As for the actual test methods, we will continue to keep it simple by writing two small tests.

def test_basic_usage(self):
    """
    Go to each valid page. Assert page isn't 404.
    Click prev/next button, assert url did or didn't change.
    If the url changed, click the back button.
    """
    valid_ids = list(range(1, 4))  # [1, 2, 3]
    base = self.live_server_url + "/page{}/"

    for page_id in valid_ids:
        curr_url = base.format(page_id)
        self.assert_not_404(curr_url)

        self.driver.get(curr_url)

        el = self.driver.find_element_by_id("prev")
        el.click()

        if page_id - 1 >= 1:  # Prev button is clickable
            self.assertEqual(base.format(page_id - 1), self.driver.current_url)
            self.back()
        else:  # button not clickable, assert url never changed after click
            self.assertEqual(curr_url, self.driver.current_url)

        el = self.driver.find_element_by_id("next")
        el.click()

        if page_id + 1 <= 3:  # Next button is clickable
            self.assertEqual(base.format(page_id + 1), self.driver.current_url)
            self.back()
        else:  # button not clickable, assert url never changed after click
            self.assertEqual(curr_url, self.driver.current_url)

def test_404(self):
    """
    Test that invalid pages return 404.
    """
    self.assert_404(f"{self.live_server_url}/page0/")
    self.assert_404(f"{self.live_server_url}/page4/")

Finally, we are ready to setup our .travis.yml file. This file is used by Travis to simplify the testing process. With your travis file you can configure testing settings. For example you can set items like the language used, version used, environmental variables, Travis addons, a pre-installation process, an installation process, and of course the commands to run the tests. For more information about the .travis.yml file, read the Travis docs. Here is what our .travis.yml file will look like.

language: python
python:
 - "3.6.2"
cache: pip
env:
 - MOZ_HEADLESS=1
addons:
 firefox: latest
before_install:
 - wget https://github.com/mozilla/geckodriver/releases/download/v0.18.0/geckodriver-v0.18.0-linux64.tar.gz
 - mkdir geckodriver
 - tar -xzf geckodriver-v0.18.0-linux64.tar.gz -C geckodriver
 - export PATH=$PATH:$PWD/geckodriver
 - geckodriver --version
 - export MOZ_HEADLESS=1
install:
 - pip install -r requirements.txt
script:
 - python manage.py makemigrations
 - python manage.py migrate
 - python manage.py test
sudo: false

Now the only thing left to do is to configure github to communicate with Travis. To be honest, I am not going to write steps to integrate git with Travis because Travis has some fantastic documentation for exactly that. Read the documentation then refer back to this page.

Congrats! You've done it. You're site is now ready for CI testing with travis. Test this by pushing some code and checking travis-ci.com/<GIT_USERNAME>/ for results. If you're quick enough, you can watch your build run/log in realtime. Here is an example below.

When your test build completes and hopefully passes it will look something like this.

Now when we check our logs we can see the "script" section of our travis file running and spitting out data like below.

Please contact me using one of contact methods listed on the sidebar of the homepage if you need any help. I strongly recommend implementing Travis into any project running in Production Mode.