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:
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.
| Action | Method | Route |
|---|---|---|
| create | POST | /todos |
| read | GET | /todos/<int:id> |
| update | PUT, PATCH | /todos/<int:id> |
| delete | DELETE | /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.
simplectic