menu

Beginning BDD with Django - Part Two

Part two of a two-part tutorial on Behaviour Driven Development with Django. In this part we use Behave to write and run our tests.

This is the second in a two part series attempting to answer the questions:

  1. Why should I consider using BDD?
  2. What are the key concepts?
  3. How can I use it to test my Django project?

In the first half of this series we outlined the benefits of BDD and scoped and wrote a Gherkin feature file for our Filter Users feature. In this part we’ll use Behave to hook up our feature file to an automated test suite.

This guide has been tested to work with the following stack:

  • Python 3.3
  • Django 1.7
  • Factory Boy 2.4.1
  • Splinter 0.7.0
  • Selenium 2.44.0
  • Django Behave 0.1.2
  • Phantom JS 1.9.8

Contents

Revisiting Our Feature File

For reference, let’s take a quick look at the Filter Users feature we wrote in the first part of this series:

filter_users.feature

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Feature: Filter users by interest
As a standard user
I want to filter users by their listed interests
So I can find users who have similar interests to my own

Background: There are interests and users in the system
    Given there are a number of interests:
        |    interest           |
        |    Django             |
        |    Testing            |
        |    Public Speaking    |
        |    DevOps             |
        |    PHP                |

    And there are many users, each with different interests:
        |    name           |   interests                  |
        |    Billie Jean    |   Django, Testing            |
        |    Rocky Raccoon  |   Django, Public Speaking    |
        |    Major Tom      |   Testing, Devops            |
        |    Bobbie McGee   |   Public Speaking, DevOps    |

Scenario Outline: Filter users
    Given I am a logged in user
    When I filter the list of users by <filter>
    Then I see <num> users

    Examples:
        |    filter             |    num    |
        |    Django             |    2      |
        |    Django, Testing    |    3      |
        |    PHP                |    0      |

Remember that? Great! Let’s get started.

Dependencies

First off, we’ll need to install our dependencies:

  • Behave will run our BDD tests.
  • Django Behave will let us run our Behave tests via the Django test runner.
  • PhantomJS will drive our interactions with the browser.
  • Splinter sits on top of PhantomJS (and others) and will help us write simpler, more elegant test code.
  • Factory Boy will allow us to mock Users and Interests to use in our tests.

After installing all of the above, update settings.py:

  1. Add django-behave to INSTALLED_APPS
  2. Set TEST_RUNNER = 'django_behave.runner.DjangoBehaveTestSuiteRunner'
Note

We'll be using Django's built in test runner throughout this tutorial. But if you prefer to use PyTest, then you should check out pytest-django and pytest-bdd.

Folder Structure

Next we’ll need to create a new bdd app where we can save our existing feature file as filter_users.feature:

1
2
3
4
5
6
7
8
project_root/
  bdd/
    init.py
    features/
      filter_users.feature
      environment.py
      steps/
        filter_users.py
Note

We could instead include feature folders inside individual existing Django applications. However, utilising one central bdd application allows us to share the same environment for all of our tests, whilst accounting for situations where individual tests cases span multiple Django applications.

Remember to add bdd to your INSTALLED_APPS in your settings.py file.

Setting Up Our Test Environment

Creating Factories

Because we’ve already written our feature file, we know that we’ll need Users and Interests in the database to run our test scenarios. To create these, we’ll use Factory Boy - a features replacement tool.

We’ll setup our factories in the same application that our User and Interest models are defined:

1
2
3
4
project_root/
    accounts/
        models.py # Our User and Interest models live here
        factories.py # This is where we'll create our Factory Boy factories

factories.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import factory
from django.contrib.auth.hashers import make_password
from .models import Interest, User

class UserFactory(factory.django.DjangoModelFactory):
    """
    Creates a standard active user.
    """
    class Meta:
        model = User

    first_name = 'Standard'
    last_name = 'User'
    # Emails must be unique - so use a sequence here:
    email = factory.Sequence(lambda n: 'user.{}@test.test'.format(n))
    password = make_password('pass')
    is_active = True

    @factory.post_generation
    def interests(self, create, extracted, **kwargs):
        """
        Where 'interests' are defined, add them to this user.
        """
        if not create:
            return

        if extracted:
            for interest in extracted:
                self.interest.add(interest)

class InterestFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = Interest

    name = factory.Sequence(lambda n: 'interest{}'.format(n))

Let’s go over whats going on here:

First, we define the model we want to instantiate by setting the model inside the class Meta block.

Next, we define defaults for the corresponding model fields. In our example, all of our users will have the first name of ‘Standard’ unless we specify otherwise.

For the email field (in our UserFactory) and name field (in our InterestFactory), we can use a factory sequence, so that each object in our factory is unique. If we now create two instances of InterestFactory, they will each have a unique name - the first will be interest1, the second interest2.

Finally, to define the many-to-many relationship between User and Interest, we need to setup our interests as a method using the post_generation hook.

Voila! Now we’re all set to create mock objects in our tests. For example, we can:

1
2
3
4
5
6
7
8
9
10
11
# Create a User with the default settings
user = UserFactory() # Will generate a user with the name 'Standard User'

# Create a User with a custom name
major_tom = UserFactory(first_name='Major', last_name='Tom')

# Create a User with Interests
django = InterestFactory(name='Django')
public_speaking = InterestFactory(name='Public Speaking')
lucy_diamond = UserFactory(first_name='Lucy', last_name='Diamond',
                           interests=(django, public_speaking))

Configuring environment.py

We can use our environment.py file to define what should happen before and after certain points in our tests. There are several hooks we can utilise, but for our example, we’re going to focus on:

  • before_all Code defined here will run before all of our tests begin. We’ll use this hook to set up our browser.
  • after_all Code defined here will run after all of our tests finish. We’ll use this hook to quit our browser.
  • before_scenario Code defined here runs before each individual scenario. We’ll use this to setup (and teardown) our database. This will help us keep our data clean between each scenario.

Our example:

environment.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
from behave import *
from splinter.browser import Browser
from django.core import management

def before_all(context):
    # Unless we tell our test runner otherwise, set our default browser to PhantomJS
    if context.config.browser:
        context.browser = Browser(context.config.browser)
    else:
        context.browser = Browser('phantomjs')

    # When we're running with PhantomJS we need to specify the window size.
    # This is a workaround for an issue where PhantomJS cannot find elements
    # by text - see: https://github.com/angular/protractor/issues/585
    if context.browser.driver_name == 'PhantomJS':
        context.browser.driver.set_window_size(1280, 1024)

def before_scenario(context, scenario):
    # Reset the database before each scenario
    # This means we can create, delete and edit objects within an
    # individual scenerio without these changes affecting our
    # other scenarios
    management.call_command('flush', verbosity=0, interactive=False)

    # At this stage we can (optionally) mock additional data to setup in the database.
    # For example, if we know that all of our tests require a 'SiteConfig' object,
    # we could create it here.

def after_all(context):
    # Quit our browser once we're done!
    context.browser.quit()
    context.browser = None

The context variable is an instance of behave.runner.Context. This variable holds additional contextual information during the running of tests, so we could also pass it additional information and retreive that value later.

Running Our Tests

Now, we’ve setup our environment, we’re ready to run our tests! In your terminal run:

1
python manage.py test bdd

You’ll see:

1
2
3
4
5
6
7
8
9
Failing scenarios:
  bdd/features/filter_users.feature:22  Filter users
  bdd/features/filter_users.feature:22  Filter users
  bdd/features/filter_users.feature:22  Filter users

0 features passed, 1 failed, 0 skipped
0 scenarios passed, 3 failed, 0 skipped
0 steps passed, 0 failed, 0 skipped, 15 undefined
Took 0m0.000s

Why? Because Behave can’t find any instructions (known as steps) for each of our scenarios. Conveniently, Behave provides us with some default snippets. Copy these from your terminal and paste them into the filter_users.py file - grouping common steps together:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
from behave import * # We'll need to import all from behave first

# Then we can copy the snippets into our file
@given('there are a number of interests')
def impl(context):
    assert False

@given('there are many users, each with different interests')
def impl(context):
    assert False

@given('I am a logged in user')
def impl(context):
    assert False

@when('I filter the list of users by Django')
def impl(context):
    assert False

@when('I filter the list of users by Django, Testing')
def impl(context):
    assert False

@when('I filter the list of users by PHP')
def impl(context):
    assert False

@then('I see 3 users')
def impl(context):
    assert False

@then('I see 2 users')
def impl(context):
    assert False

@then('I see 0 users')
def impl(context):
    assert False

Step functions are defined using step decorators, here shown as @given, @then and @when. These are universally imported when you import Behave; you do not need to import them individually.

Step decorators use a string to match your Gherkin feature file step - this must be an exact match for the test to run correctly.

The decorated function (in this case def impl()) can be named anything - It doesn’t matter. The only thing you must do is pass it the context that we mentioned earlier.

Writing Test Code

Let’s go through each of our steps and write our test code.

1. Given there are a number of interests

For this step, we’ll need to use our InterestFactory to create the interests listed in our feature file. We can access the name of our interests by looping over each row in our context.table using the interest column heading as a key.

filter_users.py

1
2
3
4
5
6
from behave import *
from accounts.factories import InterestFactory

@given('there are a number of interests')
def impl(context):
    interests = [InterestFactory(name=row['interest']) for row in context.table]

2. And there are many users, each with different interests

In this step we create our users by:

  1. Splitting the items listed under our ‘interest’ heading into list items
  2. Fetching the interests (that we created in our last step) from the database
  3. Creating a new user with our UserFactory, passing in the the interest objects

filter_users.py

1
2
3
4
5
6
7
8
9
from accounts.factories import UserFactory
from accounts.models import Interest

@given('there are many users, each with different interests')
def impl(context):
    for row in context.table:
        interest_names = row['interests'].split(', ')
        interests = Interest.objects.filter(name__in=interest_names)
        UserFactory(email=row['email'], interests=interests)

3. Given I am a logged in user

To log in a user, we navigate to the login page and interact with the login form. Here we can start to appreciate the power of Splinter for browsing, finding and filling in form fields.

filter_users.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from accounts.factories import UserFactory

@given('I am a logged in user')
def impl(context):
    # First we need to create the user to login.
    user_to_login = UserFactory(email='log.me.in@test.test')
    # All properties (other than email) will be inherited from our UserFactory.
    # Therefore our password for this user will be 'pass'.

    # We visit the login page
    # context.config.server_url is by default set to http://localhost:8081
    # (Thanks to Cynthia Kiser for pointing this out.)
    # In this example we're visiting http://localhost:8081/accounts/login/
    context.browser.visit(context.config.server_url + 'accounts/login/')

    # Next, we log in our user by interacting with the login form
    # Splinter has a handy fill function that helps us fill form fields based
    # on their name.  We'll use it to fill in the username and password fields.
    context.browser.fill('username', user_to_login.email)
    context.browser.fill('password', 'pass')

    # Finally we find the submit button (by its CSS attribute) and click on it!
    context.browser.find_by_css('form input[type=submit]').first.click()

At this point it might be helpful to see our tests running in a ‘real’ browser. To do this, we need to install Selenium.

Now we can tell Splinter to run our tests using Firefox (rather than the default PhantomJS):

1
$ python ./manage.py test bdd --behave_browser firefox
Note

Running with Firefox is significantly slower than with PhantomJS, so unless I'm debugging, I tend to stick with running PhantomJS locally and test with other, heavier browsers on my continuous integration server.

4. When I filter the list of users by …

We can combine each of our filter steps into one single step by using Behave’s step parameters.

First, we need to change our feature file, wrapping our filter variable in string formatting:

filter_users.feature

1
2
3
4
Scenario Outline: Filter users
    ...
    When I filter the list of users by "<filter>"
    ...

This allows us to write one (and only one) step for each filter step:

filter_users.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@when('I filter the list of users by "{checked}"')
def impl(context, checked):

    # First we visit the page where we see all the users.
    # In our example, this happens to be the root domain.
    context.browser.visit(context.context.config.server_url)

    # Then we get the list of interests
    checked = checked.split(', ');
    for check in checked:
        # And click on each label.
        # This code assumes we have a form where each interest is listed as a
        # label containing a checkbox.
        path = "//label[contains(.,'{}')]/input".format(check)
        context.browser.find_by_xpath(path).click()

    # Finally, we submit the form
    context.browser.find_by_css('form input[type=submit]').first.click()

5. Then I see … users

Finally, we can use the same pattern to count the number of users in our results.

filter_users.feature

1
2
3
4
Scenario Outline: Filter users
    ...
    ...
    Then I see "<num>" users

And in our python file:

filter_users.py

1
2
3
4
5
6
7
8
@then('I see "{count}" users')
def impl(context, count):
    # Assuming there is a <div class="user-card"></div> for each user
    users = context.browser.find_by_css('.user-card')

    # We can now assert that the number of users on the page
    # is equal to the number we expect
    assert len(users) == int(count)

Wrapping Up

That’s it for our test code! Now it’s over to you to write application code to make these failing scenarios pass.

I hope you’ve enjoyed reading these articles as much as I’ve enjoyed writing them. If you have any questions or comments, don’t hesitate to leave them below.

19th March, 2015

More Articles

Contact Me

Get In Touch!

I am available for new design and development work via Kabu Creative. To discuss a particular project, please contact me at n.harris [at] kabucreative.com.

I tweet @nlhkabu - feel free to say "hi"!