2014-02-02

Flask TodoMVC: Testing

This is the fifth article in the Flask TodoMVC tutorial, a series that creates a Backbone.js backend with Flask for the TodoMVC app. In this article, we will use the Flask test client to write unit tests against our todo list API.

Previous articles in this series:

  1. Getting Started
  2. Server Side Backbone Sync
  3. Dataset Persistence
  4. Custom Configuration

We will begin with where we left off in the previous article. If you would like to follow along, but do not have the code, it is available on GitHub.

# Optional, use your own code if you followed the previous article
$ git clone https://github.com/kevinbeaty/flask-todomvc
$ cd flask-todomvc
$ git checkout -b testing config

Let's get testing.

JSON API

In the second article, we defined the routes necessary to support the Backbone.js integration.

ActionMethodRoute
createPOST/todos
readGET/todos/<int:id>
updatePUT, PATCH/todos/<int:id>
deleteDELETE/todos/<int:id>

Up to this point, we never discussed the structure of the JSON document, we simply stored whatever the client sent (again, not a good idea in production, we'll address this later).

When you post a new todo, the JSON object includes the title text, order within the list, and a completed flag.

{
    "title":"Buy groceries",
    "order":1,
    "completed":false
}

The response and all future updates include an id to identify the todo items.

{
    "id":1,
    "title":"Buy groceries",
    "order":1,
    "completed":false
}

Pretty simple. We are now ready to setup the tests.

Test setup

We are going to use the Flask test client in the tests against the API. Let's create this in the setUp method in the TodoTestCase created in the previous article.

# tests.py
def setUp(self):
    self.client = server.app.test_client()
    self.order = 1

The client is an integrated werkzeug Client that easily allows us to test requests against our Flask app. We initialized an order attribute to keep track of the order of the items we add during testing.

We also want to start with a fresh database table for each of our tests. Let's drop the table and create a new one in tearDown. A little dirty, but it works for our purposes.

# tests.py
def tearDown(self):
    server.todos.drop()
    server.todos = server.db['todos']

We're now ready to add our tests.

Test create

Let's start with the create method. We will post a new todo and test that the response contains the same fields with a generated id.

# tests.py
import json
...

def test_create(self):
    todo = {"title": "Pick up kids",
            "order": 1,
            "completed": False}
    response = self.client.post(
        '/todos/',
        data=json.dumps(todo),
        content_type='application/json')
    created = json.loads(response.data)
    assert 'id' in created
    del created['id']
    assert created == todo

We create a todo with a dict and pass this to todos after dumping to JSON to the test client we configured in setUp. We then load the response data JSON object into a dict and ensure it has a server generated id. We delete this key from the dict and check all other fields are equal.

If you run the test you will notice it fails on the assertion for id. It seems the create method did not provide an id in the response. Why is this? Take a look.

# server.py
@app.route('/todos/', methods=['POST'])
def todo_create():
    todo = request.get_json()
    todos.insert(todo)
    return _todo_response(todo)

The call to insert simply inserts the row into the table and returns the generated id. We need to capture this id and set it on our response object.

@app.route('/todos/', methods=['POST'])
def todo_create():
    todo = request.get_json()
    resp = dict(**todo)
    resp['id'] = todos.insert(todo)
    return _todo_response(resp)

Run the tests again to verify we fixed the bug.

All the other routes operate on existing todo items, so let's extract the creation of the item to a convenience method.

def create(self, title, completed=False):
    todo = {"title": title,
            "order": self.order,
            "completed": completed}
    response = self.client.post(
        '/todos/',
        data=json.dumps(todo),
        content_type='application/json')
    created = json.loads(response.data)

    assert 'id' in created
    assert created['title'] == title
    assert created['order'] == self.order
    assert created['completed'] == completed
    self.order += 1

    return created

This is the same code as test_create above, but accepts title and completed as parameters and internally keeps track of the order.

Now update test_create to use this method to create a couple items.

def test_create(self):
    todo1 = self.create('Pick up kids')
    assert todo1['title'] == 'Pick up kids'
    assert todo1['order'] == 1
    assert not todo1['completed']

    todo2 = self.create(
        'Buy groceries', completed=True)
    assert todo2['title'] == 'Buy groceries'
    assert todo2['order'] == 2
    assert todo2['completed']

    assert todo1['id'] != todo2['id']

The test now uses the new convenience method and tests the attributes are set as expected.

Test read

Next, we'll test retrieving todo items. Let's start with a convenience method to request a todo using the read route.

def read(self, id):
    response = self.client.get(
        '/todos/%d' % id,
        content_type='application/json')
    if response.status_code != 200:
        assert response.status_code == 404
        return None
    return json.loads(response.data)

We use the test client to request a todo, passing the id and returning the item as a dict if found or None.

Now, we'll use our convenience methods in test_read.

def test_read(self):
    todo1 = self.create('Pick up kids')
    todo2 = self.create(
        'Buy groceries', completed=True)

    read1 = self.read(todo1['id'])
    read2 = self.read(todo2['id'])

    assert read1 == todo1
    assert read2 == todo2

    read3 = self.read(todo2['id'] + 1)
    assert read3 is None

We create a couple items, read them back and assert they are equal. We also try to query an item that does not exist and ensure we receive a 404.

Finally, we'll test update and delete.

Test update and delete

We'll write a test for update by creating an item, putting to the update route, and reading the same item back.

def test_update(self):
    todo = self.create('Pick up kids')
    id = todo['id']

    updates = dict(**todo)
    updates['completed'] = True
    updates['title'] = 'Pick up *all* kids'

    update1_req = self.client.put(
        '/todos/%d' % id,
        data=json.dumps(updates),
        content_type='application/json')
    updated = json.loads(update1_req.data)

    assert updated == updates
    assert self.read(id) == updates

Finally, we'll test deletion.

def test_delete(self):
    todo = self.create('Pick up kids')
    id = todo['id']
    assert self.read(id) == todo

    self.client.delete(
        '/todos/%d' % id)

    assert self.read(id) is None

We created an item, deleted using the delete route and asserted the item no longer exists when we read it back.

Conclusion

In this article, we setup the Flask test client to test against the Todo API. We extended the test case we setup in the previous article, reviewed the Todo API routes and JSON document, and wrote tests with convenience methods to test those routes.

In the next article we will use these tests to assist us in refactoring our app to use SQLAlchemy.

For further information, please review the Flask testing chapter and also take a look at the werkzeug test documentation.

The code is available on GitHub with tag testing or compared to previous article.