Chapter 18. Finishing “My Lists”: Outside-In TDD
In this chapter I’d like to talk about a technique called “Outside-In” TDD. It’s pretty much what we’ve been doing all along. Our “double-loop” TDD process, in which we write the functional test first and then the unit tests, is already a manifestation of outside-in—we design the system from the outside, and build up our code in layers. Now I’ll make it explicit, and talk about some of the common issues involved.
The Alternative: “Inside Out”
The alternative to “Outside In” is to work “Inside Out”, which is the way most people intuitively work before they encounter TDD. After coming up with a design, the natural inclination is sometimes to implement it starting with the innermost, lowest-level components first.
For example, when faced with our current problem, providing users with a “My Lists” page of saved lists, the temptation is to start by adding an “owner” attribute to the List model object, reasoning that an attribute like this is “obviously” going to be required. Once that’s in place, we would modify the more peripheral layers of code, such as views and templates, taking advantage of the new attribute, and then finally add URL routing to point to the new view.
It feels comfortable because it means you’re never working on a bit of code that is dependent on something that hasn’t yet been implemented. Each bit of work on the inside is a solid foundation on which to build the next layer out.
But working inside-out like this also has some weaknesses.
Why Prefer “Outside-In”?
The most obvious problem with inside-out is that it requires us to stray from a TDD workflow. Our functional test’s first failure might be due to missing URL routing, but we decide to ignore that and go off adding attributes to our database model objects instead.
We might have ideas in our head about the new desired behaviour of our inner layers like database models, and often these ideas will be pretty good, but they are actually just speculation about what’s really required, because we haven’t yet built the outer layers that will use them.
One problem that can result is to build inner components that are more general or more capable than we actually need, which is a waste of time, and an added source of complexity for your project. Another common problem is that you create inner components with an API which is convenient for their own internal design, but which later turns out to be inappropriate for the calls your outer layers would like to make…worse still, you might end up with inner components which, you later realise, don’t actually solve the problem that your outer layers need solved.
In contrast, working outside-in allows you to use each layer to imagine the most convenient API you could want from the layer beneath it. Let’s see it in action.
The FT for “My Lists”
As we work through the following functional test, we start with the most outward-facing (presentation layer), through to the view functions (or “controllers”), and lastly the innermost layers, which in this case will be model code.
We know our create_pre_authenticated_session
code works now, so we can just
write our FT to look for a “My Lists” page:
functional_tests/test_my_lists.py.
def
test_logged_in_users_lists_are_saved_as_my_lists
(
self
):
# Edith is a logged-in user
self
.
create_pre_authenticated_session
(
'edith@example.com'
)
# She goes to the home page and starts a list
self
.
browser
.
get
(
self
.
server_url
)
self
.
get_item_input_box
()
.
send_keys
(
'Reticulate splines
\n
'
)
self
.
get_item_input_box
()
.
send_keys
(
'Immanentize eschaton
\n
'
)
first_list_url
=
self
.
browser
.
current_url
# She notices a "My lists" link, for the first time.
self
.
browser
.
find_element_by_link_text
(
'My lists'
)
.
click
()
# She sees that her list is in there, named according to its
# first list item
self
.
browser
.
find_element_by_link_text
(
'Reticulate splines'
)
.
click
()
self
.
assertEqual
(
self
.
browser
.
current_url
,
first_list_url
)
# She decides to start another list, just to see
self
.
browser
.
get
(
self
.
server_url
)
self
.
get_item_input_box
()
.
send_keys
(
'Click cows
\n
'
)
second_list_url
=
self
.
browser
.
current_url
# Under "my lists", her new list appears
self
.
browser
.
find_element_by_link_text
(
'My lists'
)
.
click
()
self
.
browser
.
find_element_by_link_text
(
'Click cows'
)
.
click
()
self
.
assertEqual
(
self
.
browser
.
current_url
,
second_list_url
)
# She logs out. The "My lists" option disappears
self
.
browser
.
find_element_by_id
(
'id_logout'
)
.
click
()
self
.
assertEqual
(
self
.
browser
.
find_elements_by_link_text
(
'My lists'
),
[]
)
If you run it, the first error should look like this:
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate element: {"method":"link text","selector":"My lists"}
The Outside Layer: Presentation and Templates
The test is currently failing saying that it can’t find a link saying “My Lists”. We can address that at the presentation layer, in base.html, in our navigation bar. Here’s the minimal code change:
lists/templates/base.html (ch18l002-1).
{% if user.email %}<ul
class=
"nav navbar-nav"
>
<li><a
href=
"#"
>
My lists</a></li>
</ul>
<a
class=
"btn navbar-btn navbar-right"
id=
"id_logout"
[...]
Of course, that link doesn’t actually go anywhere, but it does get us along to the next failure:
self.browser.find_element_by_link_text('Reticulate splines').click() [...] selenium.common.exceptions.NoSuchElementException: Message: Unable to locate element: {"method":"link text","selector":"Reticulate splines"}
Which is telling us we’re going to have to build a page that lists all of a user’s lists by title. Let’s start with the basics—a URL and a placeholder template for it.
Again, we can go outside-in, starting at the presentation layer with just the URL and nothing else:
lists/templates/base.html (ch18l002-2).
<ul
class=
"nav navbar-nav"
>
<li><a
href=
"{% url 'my_lists' user.email %}"
>
My lists</a></li>
</ul>
Moving Down One Layer to View Functions (the Controller)
That will cause a template error, so we’ll start to move down from the presentation layer and URLs down to the controller layer, Django’s view functions.
As always, we start with a test:
lists/tests/test_views.py (ch18l003).
class
MyListsTest
(
TestCase
):
def
test_my_lists_url_renders_my_lists_template
(
self
):
response
=
self
.
client
.
get
(
'/lists/users/a@b.com/'
)
self
.
assertTemplateUsed
(
response
,
'my_lists.html'
)
That gives:
AssertionError: No templates used to render the response
And we fix it, still at the presentation level, in urls.py:
lists/urls.py.
urlpatterns
=
[
url
(
r'^new$'
,
views
.
new_list
,
name
=
'new_list'
),
url
(
r'^(\d+)/$'
,
views
.
view_list
,
name
=
'view_list'
),
url
(
r'^users/(.+)/$'
,
views
.
my_lists
,
name
=
'my_lists'
),
]
That gives us a test failure, which informs us of what we should do as we move down to the next level:
AttributeError: 'module' object has no attribute 'my_lists'
We move in from the presentation layer to the views layer, and create a minimal placeholder:
lists/views.py (ch18l005).
def
my_lists
(
request
,
):
return
render
(
request
,
'my_lists.html'
)
And, a minimal template:
lists/templates/my_lists.html.
{% extends 'base.html' %} {% block header_text %}My Lists{% endblock %}
That gets our unit tests passing, but our FT is still at the same point, saying that the “My Lists” page doesn’t yet show any lists. It wants them to be clickable links named after the first item:
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate element: {"method":"link text","selector":"Reticulate splines"}
Another Pass, Outside-In
At each stage, we still let the FT drive what development we do.
Starting again at the outside layer, in the template, we begin to write the template code we’d like to use to get the “My Lists” page to work the way we want it to. As we do so, we start to specify the API we want from the code at the layers below.
A Quick Restructure of the Template Inheritance Hierarchy
Currently there’s no place in our base template for us to put any new content. Also, the “My Lists” page doesn’t need the new item form, so we’ll put that into a block too, making it optional:
lists/templates/base.html (ch18l007-1).
<div
class=
"text-center"
>
<h1>
{% block header_text %}{% endblock %}</h1>
{% block list_form %}<form
method=
"POST"
action=
"{% block form_action %}{% endblock %}"
>
{{ form.text }} {% csrf_token %} {% if form.errors %}<div
class=
"form-group has-error"
>
<div
class=
"help-block"
>
{{ form.text.errors }}</div>
</div>
{% endif %}</form>
{% endblock %}</div>
lists/templates/base.html (ch18l007-2).
<div
class=
"row"
>
<div
class=
"col-md-6 col-md-offset-3"
>
{% block table %} {% endblock %}</div>
</div>
<div
class=
"row"
>
<div
class=
"col-md-6 col-md-offset-3"
>
{% block extra_content %} {% endblock %}</div>
</div>
</div>
<script
src=
"http://code.jquery.com/jquery.min.js"
></script>
[...]
Designing Our API Using the Template
Meanwhile, in my_lists.html we override the list_form
and say it should
be empty…
lists/templates/my_lists.html.
{% extends 'base.html' %} {% block header_text %}My Lists{% endblock %} {% block list_form %}{% endblock %}
And then we can just work inside the extra_content
block:
lists/templates/my_lists.html.
[...] {% block list_form %}{% endblock %} {% block extra_content %}
<h2
>
{{ owner.email }}'s lists
</h2>
<ul
>
{% for list in owner.list_set.all %}
<li
>
<a
href=
"{{ list.get_absolute_url }}"
>
{{ list.name }}
</a>
</li>
{% endfor %}
</ul>
{% endblock %}
We’ve made several design decisions in this template which are going to filter their way down through the code:
We want a variable called
owner
to represent the user in our template.We want to be able to iterate through the lists created by the user using
owner.list_set.all
(I happen to know we get this for free from the Django ORM).We want to use
list.name
to print out the “name” of the list, which is currently specified as the text of its first element.
Note
Outside-In TDD is sometimes called “programming by wishful thinking”, and you can see why. We start writing code at the higher levels based on what we wish we had at the lower levels, even though it doesn’t exist yet!
We can rerun our FTs, to check we didn’t break anything, and to see whether we’ve got any further:
$ python3 manage.py test functional_tests
[...]
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: {"method":"link text","selector":"Reticulate splines"}
---------------------------------------------------------------------
Ran 7 tests in 77.613s
FAILED (errors=1)
Well, no further, but at least we didn’t break anything. Time for a commit:
$ git add lists $ git diff --staged $ git commit -m "url, placeholder view, and first-cut templates for my_lists"
Moving Down to the Next Layer: What the View Passes to the Template
lists/tests/test_views.py (ch18l011).
from
django.contrib.auth
import
get_user_model
User
=
get_user_model
()
[
...
]
class
MyListsTest
(
TestCase
):
def
test_my_lists_url_renders_my_lists_template
(
self
):
[
...
]
def
test_passes_correct_owner_to_template
(
self
):
User
.
objects
.
create
(
=
'wrong@owner.com'
)
correct_user
=
User
.
objects
.
create
(
=
'a@b.com'
)
response
=
self
.
client
.
get
(
'/lists/users/a@b.com/'
)
self
.
assertEqual
(
response
.
context
[
'owner'
],
correct_user
)
Gives:
KeyError: 'owner'
So:
lists/views.py (ch18l012).
from
django.contrib.auth
import
get_user_model
User
=
get_user_model
()
[
...
]
def
my_lists
(
request
,
):
owner
=
User
.
objects
.
get
(
=
)
return
render
(
request
,
'my_lists.html'
,
{
'owner'
:
owner
})
That gets our new test passing, but we’ll also see an error from the previous test. We just need to add a user for it as well:
lists/tests/test_views.py (ch18l013).
def
test_my_lists_url_renders_my_lists_template
(
self
):
User
.
objects
.
create
(
=
'a@b.com'
)
[
...
]
OK
The Next “Requirement” from the Views Layer: New Lists Should Record Owner
Before we move down to the model layer, there’s another part of the code at the views layer that will need to use our model: we need some way for newly created lists to be assigned to an owner, if the current user is logged in to the site.
Here’s a first crack at writing the test:
lists/tests/test_views.py (ch18l014).
from
django.http
import
HttpRequest
[
...
]
from
lists.views
import
new_list
[
...
]
class
NewListTest
(
TestCase
):
[
...
]
def
test_list_owner_is_saved_if_user_is_authenticated
(
self
):
request
=
HttpRequest
()
request
.
user
=
User
.
objects
.
create
(
=
'a@b.com'
)
request
.
POST
[
'text'
]
=
'new list item'
new_list
(
request
)
list_
=
List
.
objects
.
first
()
self
.
assertEqual
(
list_
.
owner
,
request
.
user
)
This test uses the raw view function, and manually constructs an
HttpRequest
because it’s slightly easier to write the test that way.
Although the Django test client does have a helper function called login
, it
doesn’t work well with external authentication services. The alternative would
be to manually create a session object (like we do in the functional tests), or
to use mocks, and I think both of those would end up uglier than this version.
If you’re curious, you could have a go at writing it differently.
The test fails as follows:
AttributeError: 'List' object has no attribute 'owner'
To fix this, we can try writing code like this:
lists/views.py (ch18l015).
def
new_list
(
request
):
form
=
ItemForm
(
data
=
request
.
POST
)
if
form
.
is_valid
():
list_
=
List
()
list_
.
owner
=
request
.
user
list_
.
save
()
form
.
save
(
for_list
=
list_
)
return
redirect
(
list_
)
else
:
return
render
(
request
,
'home.html'
,
{
"form"
:
form
})
But it won’t actually work, because we don’t know how to save a list owner yet:
self.assertEqual(list_.owner, request.user) AttributeError: 'List' object has no attribute 'owner'
A Decision Point: Whether to Proceed to the Next Layer with a Failing Test
In order to get this test passing, as it’s written now, we have to move down to the model layer. However, it means doing more work with a failing test, which is not ideal.
The alternative is to rewrite the test to make it more isolated from the level below, using mocks.
On the one hand, it’s a lot more effort to use mocks, and it can lead to tests that are harder to read. On the other hand, imagine if our app was more complex, and there were several more layers between the outside and the inside. Imagine leaving three or four or five layers of tests, all failing while we wait to get to the bottom layer to implement our critical feature. While tests are failing, we’re not sure that layer really works, on its own terms, or not. We have to wait until we get to the bottom layer.
This is a decision point you’re likely to run into in your own projects. Let’s investigate both approaches. We’ll start by taking the shortcut, and leaving the test failing. In the next chapter, we’ll come back to this exact point, and investigate how things would have gone if we’d used more isolation.
Let’s do a commit, and then tag the commit as a way of remembering our position for the next chapter:
$ git commit -am "new_list view tries to assign owner but cant" $ git tag revisit_this_point_with_isolated_tests
Moving Down to the Model Layer
Our outside-in design has driven out two requirements for the model layer:
we want to be able to assign an owner to a list using the attribute
.owner
, and we want to be able to access the list’s owner with
the API owner.list_set.all
.
Let’s write a test for that:
lists/tests/test_models.py (ch18l018).
from
django.contrib.auth
import
get_user_model
User
=
get_user_model
()
[
...
]
class
ListModelTest
(
TestCase
):
def
test_get_absolute_url
(
self
):
[
...
]
def
test_lists_can_have_owners
(
self
):
user
=
User
.
objects
.
create
(
=
'a@b.com'
)
list_
=
List
.
objects
.
create
(
owner
=
user
)
self
.
assertIn
(
list_
,
user
.
list_set
.
all
())
And that gives us a new unit test failure:
list_ = List.objects.create(owner=user) [...] TypeError: 'owner' is an invalid keyword argument for this function
The naive implementation would be this:
from
django.conf
import
settings
[
...
]
class
List
(
models
.
Model
):
owner
=
models
.
ForeignKey
(
settings
.
AUTH_USER_MODEL
)
But we want to make sure the list owner is optional. Explicit is better than implicit, and tests are documentation, so let’s have a test for that too:
lists/tests/test_models.py (ch18l020).
def
test_list_owner_is_optional
(
self
):
List
.
objects
.
create
()
# should not raise
The correct implementation is this:
lists/models.py.
from
django.conf
import
settings
[
...
]
class
List
(
models
.
Model
):
owner
=
models
.
ForeignKey
(
settings
.
AUTH_USER_MODEL
,
blank
=
True
,
null
=
True
)
def
get_absolute_url
(
self
):
return
reverse
(
'view_list'
,
args
=
[
self
.
id
])
Now running the tests gives the usual database error:
return Database.Cursor.execute(self, query, params) django.db.utils.OperationalError: no such column: lists_list.owner_id
Because we need to do make some migrations:
$ python3 manage.py makemigrations
Migrations for 'lists':
0006_list_owner.py:
- Add field owner to list
We’re almost there, a couple more failures:
ERROR: test_redirects_after_POST (lists.tests.test_views.NewListTest) [...] ValueError: Cannot assign "<SimpleLazyObject: <django.contrib.auth.models.AnonymousUser object at 0x7f364795ef90>>": "List.owner" must be a "User" instance. ERROR: test_saving_a_POST_request (lists.tests.test_views.NewListTest) [...] ValueError: Cannot assign "<SimpleLazyObject: <django.contrib.auth.models.AnonymousUser object at 0x7f364795ef90>>": "List.owner" must be a "User" instance.
We’re moving back up to the views layer now, just doing a little
tidying up. Notice that these are in the old test for the new_list
view, when
we haven’t got a logged-in user. We should only save the list owner when the
user is actually logged in. The .is_authenticated()
function we defined in
Chapter 16 comes in useful now (when they’re not logged in,
Django represents users using a class called AnonymousUser
, whose
.is_authenticated()
always returns False
):
lists/views.py (ch18l023).
if
form
.
is_valid
():
list_
=
List
()
if
request
.
user
.
is_authenticated
():
list_
.
owner
=
request
.
user
list_
.
save
()
form
.
save
(
for_list
=
list_
)
[
...
]
And that gets us passing!
$ python3 manage.py test lists
Creating test database for alias 'default'...
.......................................
---------------------------------------------------------------------
Ran 39 tests in 0.237s
OK
Destroying test database for alias 'default'...
This is a good time for a commit:
$ git add lists $ git commit -m "lists can have owners, which are saved on creation."
Final Step: Feeding Through the .name API from the Template
The last thing our outside-in design wanted came from the templates, which wanted to be able to access a list “name” based on the text of its first item:
lists/tests/test_models.py (ch18l024).
def
test_list_name_is_first_item_text
(
self
):
list_
=
List
.
objects
.
create
()
Item
.
objects
.
create
(
list
=
list_
,
text
=
'first item'
)
Item
.
objects
.
create
(
list
=
list_
,
text
=
'second item'
)
self
.
assertEqual
(
list_
.
name
,
'first item'
)
lists/models.py (ch18l025).
@property
def
name
(
self
):
return
self
.
item_set
.
first
()
.
text
And that, believe it or not, actually gets us a passing test, and a working “My Lists” page (Figure 18-1)!
$ python3 manage.py test functional_tests
[...]
Ran 7 tests in 93.819s
OK
But we know we cheated to get there. The Testing Goat is eyeing us suspiciously. We left a test failing at one layer while we implemented its dependencies at the lower layer. Let’s see how things would play out if we were to use better test isolation…
Get Test-Driven Development with Python now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.