Skip to content

Latest commit

 

History

History
2227 lines (1707 loc) · 62.4 KB

chapter_19_mocking.asciidoc

File metadata and controls

2227 lines (1707 loc) · 62.4 KB

Using Mocks to Test External Dependencies or Reduce Duplication

Warning, Chapter Under Construction.

🚧 Warning, this Chapter is freshly updated for Django 4 + Python 3.12.

The code listings should have valid syntax, and I’ve been through and sense-checked the chapter text, but a few things might still be off! So let me know what you think of the chapter, via obeythetestinggoat@gmail.com

In this chapter we’ll start testing the parts of our code that send emails. In the FT, you saw that Django gives us a way of retrieving any emails it sends by using the mail.outbox attribute. But in this chapter, I want to demonstrate a very important testing technique called mocking, so for the purpose of these unit tests, we’ll pretend that this nice Django shortcut doesn’t exist.

Am I telling you not to use Django’s mail.outbox? No; use it, it’s a neat shortcut. But I want to teach mocks because they’re a useful general-purpose tool for unit testing external dependencies. You may not always be using Django! And even if you are, you may not be sending email—​any interaction with a third-party API is a good candidate for testing with mocks.

Note
I once gave a talk called "Stop Using Mocks!". Have a look for that, and my book on architecture patterns for a deeper look at the trade-offs of mocking, and some alternatives.

Before We Start: Getting the Basic Plumbing In

Let’s just get a basic view and URL set up first. We can do so with a simple test that our new URL for sending the login email should eventually redirect back to the home page:

Example 1. src/accounts/tests/test_views.py (ch19l001)
from django.test import TestCase


class SendLoginEmailViewTest(TestCase):
    def test_redirects_to_home_page(self):
        response = self.client.post(
            "/accounts/send_login_email", data={"email": "edith@example.com"}
        )
        self.assertRedirects(response, "/")

Wire up the include in 'superlists/urls.py', plus the url in accounts/urls.py, and get the test passing with something a bit like this:

Example 2. src/accounts/views.py (ch19l004)
from django.core.mail import send_mail
from django.shortcuts import redirect


def send_login_email(request):
    return redirect("/")

I’ve added the import of the send_mail function as a placeholder for now:

$ python src/manage.py test accounts
[...]
Ran 4 tests in 0.015s

OK

OK, now we have a starting point, so let’s get mocking!

Mocking Manually, aka Monkeypatching

When we call send_mail in real life we expect Django to be making a connection to our email provider, and sending an actual email across the public internet. That’s not something we want to happen in our tests. It’s a similar problem whenever you have code that has external side effects—calling an API, sending out a tweet or an SMS or whatever it may be. In our unit tests, we don’t want to be sending out real tweets or API calls across the internet. But we would still like a way of testing that our code is correct. Mocks[1] are the answer.

Actually, one of the great things about Python is that its dynamic nature makes it very easy to do things like mocking, or what’s sometimes called monkeypatching. Let’s suppose that, as a first step, we want to get to some code that invokes send_mail with the right subject line, from address, and to address. That would look something like this:

Example 3. src/accounts/views.py
def send_login_email(request):
    email = request.POST['email']
    # send_mail(
    #     'Your login link for Superlists',
    #     'body text tbc',
    #     'noreply@superlists',
    #     [email],
    # )
    return redirect('/')

How can we test this, without calling the 'real' send_mail function? The answer is that our test can ask Python to replace the send_mail function with a fake version, at runtime, before we invoke the send_login_email view. Check this out:

Example 4. src/accounts/tests/test_views.py (ch19l005)
from django.test import TestCase

import accounts.views  # (2)


class SendLoginEmailViewTest(TestCase):
    [...]

    def test_sends_mail_to_address_from_post(self):
        self.send_mail_called = False

        def fake_send_mail(subject, body, from_email, to_list):  # (1)
            self.send_mail_called = True
            self.subject = subject
            self.body = body
            self.from_email = from_email
            self.to_list = to_list

        accounts.views.send_mail = fake_send_mail  # (2)

        self.client.post(
            "/accounts/send_login_email", data={"email": "edith@example.com"}
        )

        self.assertTrue(self.send_mail_called)
        self.assertEqual(self.subject, "Your login link for Superlists")
        self.assertEqual(self.from_email, "noreply@superlists")
        self.assertEqual(self.to_list, ["edith@example.com"])
  1. We define a fake_send_mail function, which looks like the real send_mail function, but all it does is save some information about how it was called, using some variables on self.

  2. Then, before we execute the code under test by doing the self.client.post, we swap out the real accounts.views.send_mail with our fake version—it’s as simple as just assigning it.

It’s important to realise that there isn’t really anything magical going on here; we’re just taking advantage of Python’s dynamic nature and scoping rules.

Up until we actually invoke a function, we can modify the variables it has access to, as long as we get into the right namespace (that’s why we import the top-level accounts module, to be able to get down to the accounts.views module, which is the scope that the accounts.views.send_login_email function will run in).

This isn’t even something that only works inside unit tests. You can do this kind of "monkeypatching" in any kind of Python code!

That may take a little time to sink in. See if you can convince yourself that it’s not all totally crazy, before reading a couple of bits of further detail.

  • Why do we use self as a way of passing information around? It’s just a convenient variable that’s available both inside the scope of the fake_send_mail function and outside of it. We could use any mutable object, like a list or a dictionary, as long as we are making in-place changes to an existing variable that exists outside our fake function. (Feel free to have a play around with different ways of doing this, if you’re curious, and see what works and doesn’t work.)

  • The "before" is critical! I can’t tell you how many times I’ve sat there, wondering why a mock isn’t working, only to realise that I didn’t mock 'before' I called the code under test.

Let’s see if our hand-rolled mock object will let us test-drive some code:

$ python src/manage.py test accounts
[...]
    self.assertTrue(self.send_mail_called)
AssertionError: False is not true

So let’s call send_mail, naively:

Example 5. src/accounts/views.py (ch19l006-1)
def send_login_email(request):
    send_mail()
    return redirect("/")

That gives:

TypeError: SendLoginEmailViewTest.test_sends_mail_to_address_from_post.<locals>
.fake_send_mail() missing 4 required positional arguments: 'subject', 'body',
'from_email', and 'to_list'

Looks like our monkeypatch is working! We’ve called send_mail, and it’s gone into our fake_send_mail function, which wants more arguments. Let’s try this:

Example 6. src/accounts/views.py (ch19l006-2)
def send_login_email(request):
    send_mail("subject", "body", "from_email", ["to email"])
    return redirect("/")

That gives:

    self.assertEqual(self.subject, "Your login link for Superlists")
AssertionError: 'subject' != 'Your login link for Superlists'

That’s working pretty well. And now we can work all the way through to something like this:

Example 7. src/accounts/views.py (ch19l006)
def send_login_email(request):
    email = request.POST["email"]
    send_mail(
        "Your login link for Superlists",
        "body text tbc",
        "noreply@superlists",
        [email],
    )
    return redirect("/")

and passing tests!

$ python src/manage.py test accounts

Ran 5 tests in 0.016s

OK

Brilliant! We’ve managed to write tests for some code, that ordinarily[2] would go out and try to send real emails across the internet, and by "mocking out" the send_email function, we’re able to write the tests and code all the same.

The Python Mock Library

The popular 'mock' package was added to the standard library as part of Python 3.3.[3] It provides a magical object called a Mock; try this out in a Python shell:

>>> from unittest.mock import Mock
>>> m = Mock()
>>> m.any_attribute
<Mock name='mock.any_attribute' id='140716305179152'>
>>> type(m.any_attribute)
<class 'unittest.mock.Mock'>
>>> m.any_method()
<Mock name='mock.any_method()' id='140716331211856'>
>>> m.foo()
<Mock name='mock.foo()' id='140716331251600'>
>>> m.called
False
>>> m.foo.called
True
>>> m.bar.return_value = 1
>>> m.bar(42, var='thing')
1
>>> m.bar.call_args
call(42, var='thing')

A magical object that responds to any request for an attribute or method call with other mocks, that you can configure to return specific values for its calls, and that allows you to inspect what it was called with? Sounds like a useful thing to be able to use in our unit tests!

Using unittest.patch

And as if that weren’t enough, the mock module also provides a helper function called patch, which we can use to do the monkeypatching we did by hand earlier.

I’ll explain how it all works shortly, but let’s see it in action first:

Example 8. src/accounts/tests/test_views.py (ch19l007)
from unittest import mock

from django.test import TestCase
[...]

    @mock.patch("accounts.views.send_mail")
    def test_sends_mail_to_address_from_post(self, mock_send_mail):
        self.client.post(
            "/accounts/send_login_email", data={"email": "edith@example.com"}
        )

        self.assertEqual(mock_send_mail.called, True)
        (subject, body, from_email, to_list), kwargs = mock_send_mail.call_args
        self.assertEqual(subject, "Your login link for Superlists")
        self.assertEqual(from_email, "noreply@superlists")
        self.assertEqual(to_list, ["edith@example.com"])

If you rerun the tests, you’ll see they still pass. And since we’re always suspicious of any test that still passes after a big change, let’s deliberately break it just to see:

Example 9. src/accounts/tests/test_views.py (ch17l008)
        self.assertEqual(to_list, ["schmedith@example.com"])

And let’s add a little debug print to our view:

Example 10. src/accounts/views.py (ch17l009)
def send_login_email(request):
    email = request.POST["email"]
    print(type(send_mail))
    send_mail(
        [...]

And run the tests again:

$ python src/manage.py test accounts
[...]
<class 'function'>
<class 'unittest.mock.MagicMock'>
[...]
AssertionError: Lists differ: ['edith@example.com'] !=
['schmedith@example.com']
[...]

Ran 5 tests in 0.024s

FAILED (failures=1)

Sure enough, the tests fail. And we can see just before the failure message that when we print the type of the send_mail function, in the first unit test it’s a normal function, but in the second unit test we’re seeing a mock object.

Let’s remove the deliberate mistake and dive into exactly what’s going on:

Example 11. src/accounts/tests/test_views.py (ch17l011)
@mock.patch("accounts.views.send_mail")  # (1)
def test_sends_mail_to_address_from_post(self, mock_send_mail):  # (2)
    self.client.post(  # (3)
        "/accounts/send_login_email", data={"email": "edith@example.com"}
    )

    self.assertEqual(mock_send_mail.called, True)  # (4)
    (subject, body, from_email, to_list), kwargs = mock_send_mail.call_args  # (5)
    self.assertEqual(subject, "Your login link for Superlists")
    self.assertEqual(from_email, "noreply@superlists")
    self.assertEqual(to_list, ["edith@example.com"])
  1. The mock.patch() decorator takes a dot-notation name of an object to monkeypatch. That’s the equivalent of manually replacing the send_mail in accounts.views. The advantage of the decorator is that, firstly, it automatically replaces the target with a mock. And secondly, it automatically puts the original object back at the end! (Otherwise, the object stays monkeypatched for the rest of the test run, which might cause problems in other tests.)

  2. patch then injects the mocked object into the test as an argument to the test method. We can choose whatever name we want for it, but I usually use a convention of mock_ plus the original name of the object.

  3. We call our view under test as usual, but everything inside this test method has our mock applied to it, so the view won’t call the real send_mail object; it’ll be seeing mock_send_mail instead.

  4. And we can now make assertions about what happened to that mock object during the test. We can see it was called…​

  5. …​and we can also unpack its various positional and keyword call arguments, and examine what it was called with. (We’ll discuss call_args in a bit more detail later.)

All crystal-clear? No? Don’t worry, we’ll do a couple more tests with mocks, to see if they start to make more sense as we use them more.

Getting the FT a Little Further Along

First let’s get back to our FT and see where it’s failing:

$ python src/manage.py test functional_tests.test_login
[...]
AssertionError: 'Check your email' not found in 'Superlists\nEnter your email
to log in\nStart a new To-Do list'

Submitting the email address currently has no effect, because the form isn’t sending the data anywhere. Let’s wire it up in base.html:

Example 12. src/lists/templates/base.html (ch19l012)
<form method="POST" action="{% url 'send_login_email' %}">

Does that help? Nope, same error. Why? Because we’re not actually displaying a success message after we send the user an email. Let’s add a test for that.

Testing the Django Messages Framework

We’ll use Django’s "messages framework", which is often used to display ephemeral "success" or "warning" messages to show the results of an action. Have a look at the django messages docs if you haven’t come across it already.

Testing Django messages is a bit contorted—​we have to pass follow=True to the test client to tell it to get the page after the 302-redirect, and examine its context for a list of messages (which we have to listify before it’ll play nicely). Here’s what it looks like:

Example 13. src/accounts/tests/test_views.py (ch19l013)
    def test_adds_success_message(self):
        response = self.client.post(
            "/accounts/send_login_email",
            data={"email": "edith@example.com"},
            follow=True,
        )

        message = list(response.context["messages"])[0]
        self.assertEqual(
            message.message,
            "Check your email, we've sent you a link you can use to log in.",
        )
        self.assertEqual(message.tags, "success")

That gives:

$ python src/manage.py test accounts
[...]
    message = list(response.context["messages"])[0]
IndexError: list index out of range

And we can get it passing with:

Example 14. src/accounts/views.py (ch19l014)
from django.contrib import messages
[...]

def send_login_email(request):
    [...]
    messages.success(
        request,
        "Check your email, we've sent you a link you can use to log in.",
    )
    return redirect("/")
Mocks Can Leave You Tightly Coupled to the Implementation
Tip
This sidebar is an intermediate-level testing tip. If it goes over your head the first time around, come back and take another look when you’ve finished this chapter and [chapter_purist_unit_tests].

I said testing messages is a bit contorted; it took me several goes to get it right. In fact, at a previous employer, we gave up on testing them like this and decided to just use mocks. Let’s see what that would look like in this case:

Example 15. src/accounts/tests/test_views.py (ch19l014-2)
    @mock.patch("accounts.views.messages")
    def test_adds_success_message_with_mocks(self, mock_messages):
        response = self.client.post(
            "/accounts/send_login_email", data={"email": "edith@example.com"}
        )

        expected = "Check your email, we've sent you a link you can use to log in."
        self.assertEqual(
            mock_messages.success.call_args,
            mock.call(response.wsgi_request, expected),
        )

We mock out the messages module, and check that messages.success was called with the right args: the original request, and the message we want.

And you could get it passing by using the exact same code as earlier. Here’s the problem though: the messages framework gives you more than one way to achieve the same result. I could write the code like this:

Example 16. src/accounts/views.py (ch17l014-3)
    messages.add_message(
        request,
        messages.SUCCESS,
        "Check your email, we've sent you a link you can use to log in.",
    )

And the original, nonmocky test would still pass. But our mocky test will fail, because we’re no longer calling messages.success, we’re calling messages.add_message. Even though the end result is the same and our code is "correct," the test is broken.

This is what people mean when they say that using mocks can leave you "tightly coupled with the implementation". We usually say it’s better to test behaviour, not implementation details; test what happens, not how you do it. Mocks often end up erring too much on the side of the "how" rather than the "what".

There’s more detailed discussion of the pros and cons of mocks in later chapters.

Adding Messages to Our HTML

What happens next in the functional test? Ah. Still nothing. We need to actually add the messages to the page. Something like this:

Example 17. src/lists/templates/base.html (ch19l015)
      [...]
      </nav>

      {% if messages %}
        <div class="row">
          <div class="col-md-8">
            {% for message in messages %}
              {% if message.level_tag == 'success' %}
                <div class="alert alert-success">{{ message }}</div>
              {% else %}
                <div class="alert alert-warning">{{ message }}</div>
              {% endif %}
            {% endfor %}
          </div>
        </div>
      {% endif %}

Now do we get a little further? Yes!

$ python src/manage.py test accounts
[...]
Ran 6 tests in 0.023s

OK

$ python src/manage.py test functional_tests.test_login
[...]
AssertionError: 'Use this link to log in' not found in 'body text tbc'

We need to fill out the body text of the email, with a link that the user can use to log in.

Let’s just cheat for now though, by changing the value in the view:

Example 18. src/accounts/views.py (ch19l016)
    send_mail(
        "Your login link for Superlists",
        "Use this link to log in",
        "noreply@superlists",
        [email],
    )

That gets the FT a little further:

$ python src/manage.py test functional_tests.test_login
[...]
AssertionError: Could not find url in email body:
Use this link to log in

Starting on the Login URL

We’re going to have to build some kind of URL! Let’s build one that, again, just cheats:

Example 19. src/accounts/tests/test_views.py (ch19l017)
class LoginViewTest(TestCase):
    def test_redirects_to_home_page(self):
        response = self.client.get("/accounts/login?token=abcd123")
        self.assertRedirects(response, "/")

We’re imagining we’ll pass the token in as a GET parameter, after the ?. It doesn’t need to do anything for now.

I’m sure you can find your way through to getting the boilerplate in for a basic URL and view, via errors like these:

  • No URL:

    AssertionError: 404 != 302 : Response didn't redirect as expected: Response
    code was 404 (expected 302)
  • No view:

    AttributeError: module 'accounts.views' has no attribute 'login'
  • Broken view:

    ValueError: The view accounts.views.login didn't return an HttpResponse object.
    It returned None instead.
  • OK!

    $ python src/manage.py test accounts
    [...]
    
    Ran 7 tests in 0.029s
    OK

And now we can give them a link to use. It still won’t do much though, because we still don’t have a token to give to the user.

Back in our send_login_email view, we’ve tested the email subject, from, and to fields. The body is the part that will have to include a token or URL they can use to log in. Let’s spec out two tests for that:

Example 20. src/accounts/tests/test_views.py (ch19l021)
from accounts.models import Token
[...]

    def test_creates_token_associated_with_email(self):
        self.client.post(
            "/accounts/send_login_email", data={"email": "edith@example.com"}
        )
        token = Token.objects.get()
        self.assertEqual(token.email, "edith@example.com")

    @mock.patch("accounts.views.send_mail")
    def test_sends_link_to_login_using_token_uid(self, mock_send_mail):
        self.client.post(
            "/accounts/send_login_email", data={"email": "edith@example.com"}
        )

        token = Token.objects.get()
        expected_url = f"http://testserver/accounts/login?token={token.uid}"
        (subject, body, from_email, to_list), kwargs = mock_send_mail.call_args
        self.assertIn(expected_url, body)

The first test is fairly straightforward; it checks that the token we create in the database is associated with the email address from the post request.

The second one is our second test using mocks. We mock out the send_mail function again using the patch decorator, but this time we’re interested in the body argument from the call arguments.

Running them now will fail because we’re not creating any kind of token:

$ python src/manage.py test accounts
[...]
accounts.models.Token.DoesNotExist: Token matching query does not exist.
[...]
accounts.models.Token.DoesNotExist: Token matching query does not exist.

We can get the first one to pass by creating a token:

Example 21. src/accounts/views.py (ch17l022)
from accounts.models import Token
[...]

def send_login_email(request):
    email = request.POST["email"]
    token = Token.objects.create(email=email)
    send_mail(
        [...]

And now the second test prompts us to actually use the token in the body of our email:

[...]
AssertionError:
'http://testserver/accounts/login?token=[...]
not found in 'Use this link to log in'

FAILED (failures=1)

So we can insert the token into our email like this:

Example 22. src/accounts/views.py (ch19l023)
from django.urls import reverse
[...]

def send_login_email(request):
    email = request.POST["email"]
    token = Token.objects.create(email=email)
    url = request.build_absolute_uri(  # (1)
        reverse("login") + "?token=" + str(token.uid),
    )
    message_body = f"Use this link to log in:\n\n{url}"
    send_mail(
        "Your login link for Superlists",
        message_body,
        "noreply@superlists",
        [email],
    )
    [...]
  1. request.build_absolute_uri deserves a mention—it’s one way to build a "full" URL, including the domain name and the http(s) part, in Django. There are other ways, but they usually involve getting into the "sites" framework, and that gets overcomplicated pretty quickly. You can find lots more discussion on this if you’re curious by doing a bit of googling.

Two more pieces in the puzzle. We need an authentication backend, whose job it will be to examine tokens for validity and then return the corresponding users; then we need to get our login view to actually log users in, if they can authenticate.

De-spiking Our Custom Authentication Backend

Our custom authentication backend is next. Here’s how it looked in the spike:

class PasswordlessAuthenticationBackend(BaseBackend):
    def authenticate(self, request, uid):
        print("uid", uid, file=sys.stderr)
        if not Token.objects.filter(uid=uid).exists():
            print("no token found", file=sys.stderr)
            return None
        token = Token.objects.get(uid=uid)
        print("got token", file=sys.stderr)
        try:
            user = ListUser.objects.get(email=token.email)
            print("got user", file=sys.stderr)
            return user
        except ListUser.DoesNotExist:
            print("new user", file=sys.stderr)
            return ListUser.objects.create(email=token.email)

    def get_user(self, email):
        return ListUser.objects.get(email=email)

Decoding this:

  • We take a UID and check if it exists in the database.

  • We return None if it doesn’t.

  • If it does exist, we extract an email address, and either find an existing user with that address, or create a new one.

1 if = 1 More Test

A rule of thumb for these sorts of tests: any if means an extra test, and any try/except means an extra test, so this should be about three tests. How about something like this?

Example 23. src/accounts/tests/test_authentication.py (ch19l024)
from django.contrib.auth import get_user_model
from django.http import HttpRequest
from django.test import TestCase

from accounts.authentication import PasswordlessAuthenticationBackend
from accounts.models import Token

User = get_user_model()


class AuthenticateTest(TestCase):
    def test_returns_None_if_no_such_token(self):
        result = PasswordlessAuthenticationBackend().authenticate(
            HttpRequest(), "no-such-token"
        )
        self.assertIsNone(result)

    def test_returns_new_user_with_correct_email_if_token_exists(self):
        email = "edith@example.com"
        token = Token.objects.create(email=email)
        user = PasswordlessAuthenticationBackend().authenticate(
            HttpRequest(), token.uid
        )
        new_user = User.objects.get(email=email)
        self.assertEqual(user, new_user)

    def test_returns_existing_user_with_correct_email_if_token_exists(self):
        email = "edith@example.com"
        existing_user = User.objects.create(email=email)
        token = Token.objects.create(email=email)
        user = PasswordlessAuthenticationBackend().authenticate(
            HttpRequest(), token.uid
        )
        self.assertEqual(user, existing_user)

In authenticate.py we’ll just have a little placeholder:

Example 24. src/accounts/authentication.py (ch19l025)
class PasswordlessAuthenticationBackend:
    def authenticate(self, request, uid):
        pass

How do we get on?

$ python src/manage.py test accounts

.FE.........
======================================================================
ERROR: test_returns_new_user_with_correct_email_if_token_exists (accounts.tests
.test_authentication.AuthenticateTest.test_returns_new_user_with_correct_email_
if_token_exists)
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "...goat-book/src/accounts/tests/test_authentication.py", line 24, in
test_returns_new_user_with_correct_email_if_token_exists
    new_user = User.objects.get(email=email)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
[...]
accounts.models.User.DoesNotExist: User matching query does not exist.


======================================================================
FAIL: test_returns_existing_user_with_correct_email_if_token_exists (accounts.t
ests.test_authentication.AuthenticateTest.test_returns_existing_user_with_corre
ct_email_if_token_exists)
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "...goat-book/src/accounts/tests/test_authentication.py", line 34, in
test_returns_existing_user_with_correct_email_if_token_exists
    self.assertEqual(user, existing_user)
AssertionError: None != <User: User object (edith@example.com)>

 ---------------------------------------------------------------------
Ran 12 tests in 0.038s

FAILED (failures=1, errors=1)

Here’s a first cut:

Example 25. src/accounts/authentication.py (ch19l026)
from accounts.models import Token, User


class PasswordlessAuthenticationBackend:
    def authenticate(self, request, uid):
        token = Token.objects.get(uid=uid)
        return User.objects.get(email=token.email)

That gets one test passing but breaks another one:

$ python src/manage.py test accounts

ERROR: test_returns_None_if_no_such_token (accounts.tests.test_authentication.A
uthenticateTest.test_returns_None_if_no_such_token)
[...]
accounts.models.Token.DoesNotExist: Token matching query does not exist.

ERROR: test_returns_new_user_with_correct_email_if_token_exists (accounts.tests
.test_authentication.AuthenticateTest.test_returns_new_user_with_correct_email_
if_token_exists)
[...]
accounts.models.User.DoesNotExist: User matching query does not exist.

Let’s fix each of those in turn:

Example 26. src/accounts/authentication.py (ch19l027)
    def authenticate(self, request, uid):
        try:
            token = Token.objects.get(uid=uid)
            return User.objects.get(email=token.email)
        except Token.DoesNotExist:
            return None

That gets us down to one failure:

ERROR: test_returns_new_user_with_correct_email_if_token_exists (accounts.tests
.test_authentication.AuthenticateTest.test_returns_new_user_with_correct_email_
if_token_exists)
[...]
accounts.models.User.DoesNotExist: User matching query does not exist.

FAILED (errors=1)

And we can handle the final case like this:

Example 27. src/accounts/authentication.py (ch17l028)
    def authenticate(self, request, uid):
        try:
            token = Token.objects.get(uid=uid)
            return User.objects.get(email=token.email)
        except User.DoesNotExist:
            return User.objects.create(email=token.email)
        except Token.DoesNotExist:
            return None

That’s turned out neater than our spike!

The get_user Method

We’ve handled the authenticate function which Django will use to log new users in.q The second part of the protocol we have to implement is the get_user method, whose job is to retrieve a user based on their unique identifier (the email address), or to return None if it can’t find one (have another look at the spiked code if you need a reminder).

Here are a couple of tests for those two requirements:

Example 28. src/accounts/tests/test_authentication.py (ch17l030)
class GetUserTest(TestCase):
    def test_gets_user_by_email(self):
        User.objects.create(email="another@example.com")
        desired_user = User.objects.create(email="edith@example.com")
        found_user = PasswordlessAuthenticationBackend().get_user("edith@example.com")
        self.assertEqual(found_user, desired_user)

    def test_returns_None_if_no_user_with_that_email(self):
        self.assertIsNone(
            PasswordlessAuthenticationBackend().get_user("edith@example.com")
        )

And our first failure:

AttributeError: 'PasswordlessAuthenticationBackend' object has no attribute
'get_user'

Let’s create a placeholder one then:

Example 29. src/accounts/authentication.py (ch17l031)
class PasswordlessAuthenticationBackend:
    def authenticate(self, request, uid):
        [...]

    def get_user(self, email):
        pass

Now we get:

    self.assertEqual(found_user, desired_user)
AssertionError: None != <User: User object (edith@example.com)>

And (step by step, just to see if our test fails the way we think it will):

Example 30. src/accounts/authentication.py (ch17l033)
    def get_user(self, email):
        return User.objects.first()

That gets us past the first assertion, and onto:

    self.assertEqual(found_user, desired_user)
AssertionError: <User: User object (another@example.com)> != <User: User object
(edith@example.com)>

And so we call get with the email as an argument:

Example 31. src/accounts/authentication.py (ch17l034)
    def get_user(self, email):
        return User.objects.get(email=email)

Now our test for the None case fails:

ERROR: test_returns_None_if_no_user_with_that_email (accounts.tests.test_authen
tication.GetUserTest.test_returns_None_if_no_user_with_that_email)
[...]
accounts.models.User.DoesNotExist: User matching query does not exist.

Which prompts us to finish the method like this:

Example 32. src/accounts/authentication.py (ch17l035)
    def get_user(self, email):
        try:
            return User.objects.get(email=email)
        except User.DoesNotExist:
            return None  # (1)
  1. You could just use pass here, and the function would return None by default. However, because we specifically need the function to return None, the "explicit is better than implicit" rule applies here.

That gets us to passing tests:

OK

And we have a working authentication backend!

Using Our Auth Backend in the Login View

The final step is to use the backend in our login view. First we add it to 'settings.py':

Example 33. src/superlists/settings.py (ch17l036)
AUTH_USER_MODEL = "accounts.User"
AUTHENTICATION_BACKENDS = [
    "accounts.authentication.PasswordlessAuthenticationBackend",
]

[...]

Next let’s write some tests for what should happen in our view. Looking back at the spike again:

Example 34. src/accounts/views.py
def login(request):
    print("login view", file=sys.stderr)
    uid = request.GET.get("uid")
    user = auth.authenticate(uid=uid)
    if user is not None:
        auth.login(request, user)
    return redirect("/")

We need the view to call django.contrib.auth.authenticate, and then, if it returns a user, we call django.contrib.auth.login.

Tip
This is a good time to check out the Django docs on authentication for a little more context.

An Alternative Reason to Use Mocks: Reducing Duplication

So far we’ve used mocks to test external dependencies, like Django’s mail-sending function. The main reason to use a mock was to isolate ourselves from external side effects, in this case, to avoid sending out actual emails during our tests.

In this section we’ll look at a different kind of use of mocks. Here we don’t have any side effects we’re worried about, but there are still some reasons you might want to use a mock here.

The nonmocky way of testing this login view would be to see whether it does actually log the user in, by checking whether the user gets assigned an authenticated session cookie in the right circumstances.

But our authentication backend does have a few different code paths: it returns None for invalid tokens, existing users if they already exist, and creates new users for valid tokens if they don’t exist yet. So, to fully test this view, I’d have to write tests for all three of those cases.

Tip
One possible justification for using mocks is when they will reduce duplication between tests. It’s one way of avoiding combinatorial explosion.

On top of that, the fact that we’re using the Django auth.authenticate function rather than calling our own code directly is relevant: it allows us the option to add further backends in future.

So in this case (in contrast to the example in Mocks Can Leave You Tightly Coupled to the Implementation) the implementation does matter, and using a mock will save us from having duplication in our tests. Let’s see how it looks:

Example 35. src/accounts/tests/test_views.py (ch19l037)
    @mock.patch("accounts.views.auth")  # (1)
    def test_calls_authenticate_with_uid_from_get_request(self, mock_auth):  # (2)
        self.client.get("/accounts/login?token=abcd123")
        self.assertEqual(
            mock_auth.authenticate.call_args,  # (3)
            mock.call(uid="abcd123"),  # (4)
        )
  1. We expect to be using the django.contrib.auth module in 'views.py', and we mock it out here. Note that this time, we’re not mocking out a function, we’re mocking out a whole module, and thus implicitly mocking out all the functions (and any other objects) that module contains.

  2. As usual, the mocked object is injected into our test method.

  3. This time, we’ve mocked out a module rather than a function. So we examine the call_args not of the mock_auth module, but of the mock_auth.authenticate function. Because all the attributes of a mock are more mocks, that’s a mock too. You can start to see why Mock objects are so convenient, compared to trying to build your own.

  4. Now, instead of "unpacking" the call args, we use the call function for a neater way of saying what it should have been called with—​that is, the token from the GET request. (See the following sidebar.)

On Mock call_args

The call_args property on a mock represents the positional and keyword arguments that the mock was called with. It’s a special "call" object type, which is essentially a tuple of (positional_args, keyword_args). positional_args is itself a tuple, consisting of the set of positional arguments. keyword_args is a dictionary.

>>> from unittest.mock import Mock, call
>>> m = Mock()
>>> m(42, 43, 'positional arg 3', key='val', thing=666)
<Mock name='mock()' id='139909729163528'>

>>> m.call_args
call(42, 43, 'positional arg 3', key='val', thing=666)

>>> m.call_args == ((42, 43, 'positional arg 3'), {'key': 'val', 'thing': 666})
True
>>> m.call_args == call(42, 43, 'positional arg 3', key='val', thing=666)
True

So in our test, we could have done this instead:

Example 36. src/accounts/tests/test_views.py
    self.assertEqual(
        mock_auth.authenticate.call_args,
        ((,), {'uid': 'abcd123'})
    )
    # or this
    args, kwargs = mock_auth.authenticate.call_args
    self.assertEqual(args, (,))
    self.assertEqual(kwargs, {'uid': 'abcd123'})

But you can see how using the call helper is nicer.

What happens when we run the test? The first error is this:

$ python src/manage.py test accounts
[...]
AttributeError: <module 'accounts.views' from
'...goat-book/src/accounts/views.py'> does not have the attribute 'auth'
Tip
module foo does not have the attribute bar is a common first failure in a test that uses mocks. It’s telling you that you’re trying to mock out something that doesn’t yet exist (or isn’t yet imported) in the target module.

Once we import django.contrib.auth, the error changes:

Example 37. src/accounts/views.py (ch17l038)
from django.contrib import auth, messages
[...]

Now we get:

AssertionError: None != call(uid='abcd123')

Now it’s telling us that the view doesn’t call the auth.authenticate function at all. Let’s fix that, but get it deliberately wrong, just to see:

Example 38. src/accounts/views.py (ch17l039)
def login(request):
    auth.authenticate("bang!")
    return redirect("/")

Bang indeed!

$ python src/manage.py test accounts
[...]
AssertionError: call('bang!') != call(uid='abcd123')
[...]
FAILED (failures=1)

Let’s give authenticate the arguments it expects then:

Example 39. src/accounts/views.py (ch19l040)
def login(request):
    auth.authenticate(uid=request.GET.get("token"))
    return redirect("/")

That gets us to passing tests:

$ python src/manage.py test accounts
[...]
Ran 15 tests in 0.041s

OK

Using mock.return_value

Next we want to check that if the authenticate function returns a user, we pass that into auth.login. Let’s see how that test looks:

Example 40. src/accounts/tests/test_views.py (ch19l041)
@mock.patch("accounts.views.auth")  # (1)
def test_calls_auth_login_with_user_if_there_is_one(self, mock_auth):
    response = self.client.get("/accounts/login?token=abcd123")
    self.assertEqual(
        mock_auth.login.call_args,  # (2)
        mock.call(response.wsgi_request, mock_auth.authenticate.return_value),  # (3)
    )
  1. We mock the contrib.auth module again.

  2. This time we examine the call args for the auth.login function.

  3. We check that it’s called with the request object that the view sees, and the "user" object that the authenticate function returns. Because authenticate is also mocked out, we can use its special "return_value" attribute.

When you call a mock, you get another mock. But you can also get a copy of that returned mock from the original mock that you called. Boy, it sure is hard to explain this stuff without saying "mock" a lot! Another little console illustration might help here:

>>> m = Mock()
>>> thing = m()
>>> thing
<Mock name='mock()' id='140652722034952'>
>>> m.return_value
<Mock name='mock()' id='140652722034952'>
>>> thing == m.return_value
True

In any case, what do we get from running the test?

$ python src/manage.py test accounts
[...]
AssertionError: None != call(<WSGIRequest: GET '/accounts/login?t[...]

Sure enough, it’s telling us that we’re not calling auth.login at all yet. Let’s try doing that. Deliberately wrong as usual first!

Example 41. src/accounts/views.py (ch19l042)
def login(request):
    auth.authenticate(uid=request.GET.get("token"))
    auth.login("ack!")
    return redirect("/")

Ack indeed!

TypeError: login() missing 1 required positional argument: 'user'
[...]
AssertionError: call('ack!') != call(<WSGIRequest: GET
'/accounts/login?token=[...]

Let’s fix that:

Example 42. src/accounts/views.py (ch19l043)
def login(request):
    user = auth.authenticate(uid=request.GET.get("token"))
    auth.login(request, user)
    return redirect("/")

Now we get this unexpected complaint:

ERROR: test_redirects_to_home_page
(accounts.tests.test_views.LoginViewTest.test_redirects_to_home_page)
[...]
AttributeError: 'AnonymousUser' object has no attribute '_meta'

It’s because we’re still calling auth.login indiscriminately on any kind of user, and that’s causing problems back in our original test for the redirect, which isn’t currently mocking out auth.login. We need to add an if (and therefore another test), and while we’re at it we’ll learn about patching at the class level.

Patching at the Class Level

We want to add another test, with another @patch('accounts.views.auth'), and that’s starting to get repetitive. We use the "three strikes" rule, and we can move the patch decorator to the class level. This will have the effect of mocking out accounts.views.auth in every single test method in that class. That also means our original redirect test will now also have the mock_auth variable injected:

Example 43. src/accounts/tests/test_views.py (ch19l044)
@mock.patch("accounts.views.auth")  # (1)
class LoginViewTest(TestCase):
    def test_redirects_to_home_page(self, mock_auth):  # (2)
        [...]

    def test_calls_authenticate_with_uid_from_get_request(self, mock_auth):  # (3)
        [...]

    def test_calls_auth_login_with_user_if_there_is_one(self, mock_auth):  # (3)
        [...]

    def test_does_not_login_if_user_is_not_authenticated(self, mock_auth):
        mock_auth.authenticate.return_value = None  # (4)
        self.client.get("/accounts/login?token=abcd123")
        self.assertEqual(mock_auth.login.called, False)  # (5)
  1. We move the patch to the class level…​

  2. which means we get an extra argument injected into our first test method…​

  3. And we can remove the decorators from all the other tests.

  4. In our new test, we explicitly set the return_value on the auth.authenticate mock, 'before' we call the self.client.get.

  5. We assert that, if authenticate returns None, we should not call auth.login at all.

That cleans up the spurious failure, and gives us a specific, expected failure to work on:

    self.assertEqual(mock_auth.login.called, False)
AssertionError: True != False

And we get it passing like this:

Example 44. src/accounts/views.py (ch19l045)
def login(request):
    user = auth.authenticate(uid=request.GET.get("token"))
    if user:
        auth.login(request, user)
    return redirect("/")

The unit tests pass…​

OK

So are we there yet?

Avoid Mock’s Magic assert_called…​ Methods?

If you’ve used unittest.mock before, you may have come across its special assert_called…​ methods, and you may be wondering why I didn’t use them. For example, instead of doing:

self.assertEqual(a_mock.call_args, call(foo, bar))

You can just do:

a_mock.assert_called_with(foo, bar)

And the mock library will raise an AssertionError for you if there is a mismatch.

Why not use that? For me, the problem with these magic methods is that it’s too easy to make a silly typo and end up with a test that always passes:

a_mock.asssert_called_with(foo, bar)  # will always pass

Unless you get the magic method name exactly right, then you will just get a "normal" mock method, which just silently return another mock, and you may not realise that you’ve written a test that tests nothing at all.

That’s why I prefer to always have an explicit unittest method in there.

The Moment of Truth: Will the FT Pass?

I think we’re just about ready to try our functional test!

Let’s just make sure our base template shows a different nav bar for logged-in and non–logged-in users (which our FT relies on):

Example 45. src/lists/templates/base.html (ch19l046)
<nav class="navbar">
  <div class="container-fluid">
    <a class="navbar-brand" href="/">Superlists</a>
    {% if user.email %}
      <ul>
        <span class="navbar-text">Logged in as {{ user.email }}</span>
        <a href="#TODO">Log out</a>
      </ul>
    {% else %}
      <form method="POST" action="{% url 'send_login_email' %}">
        <div class="input-group">
          <label class="navbar-text me-2" for="id_email_input">
            Enter your email to log in
          </label>
          <input
            id="id_email_input"
            name="email"
            class="form-control"
            placeholder="your@email.com"
          />
          {% csrf_token %}
        </div>
      </form>
    {% endif %}
  </div>
</nav>

TODO resume updates to chapter from here

How does our FT look now?

$ python src/manage.py test functional_tests.test_login
[...]
.
 ---------------------------------------------------------------------
Ran 1 test in 3.282s

OK

It Works in Theory! Does It Work in Practice?

Wow! Can you believe it? I scarcely can! Time for a manual look around with runserver:

$ python src/manage.py runserver
[...]
Internal Server Error: /accounts/send_login_email
Traceback (most recent call last):
  File "...goat-book/accounts/views.py", line 20, in send_login_email

ConnectionRefusedError: [Errno 111] Connection refused

Using Our New Environment Variable, and Saving It to .env

You’ll probably get an error, like I did, when you try to run things manually. It’s because of two things:

  • Firstly, we need to re-add the email configuration to settings.py.

Example 46. src/superlists/settings.py (ch19l047)
EMAIL_HOST = "smtp.gmail.com"
EMAIL_HOST_USER = "obeythetestinggoat@gmail.com"
EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_PASSWORD")
EMAIL_PORT = 587
EMAIL_USE_TLS = True
  • Secondly, we (probably) need to re-set the EMAIL_PASSWORD in our shell.

$ export EMAIL_PASSWORD="yoursekritpasswordhere"
Using a Local .env File for Development

Until now we’ve only used the .env file on the server, because all the other settings have sensible defaults for dev, but there’s just no way to get a working login system without this one.

Just as we do on the server, you can also use a .env file to save project-specific environment variables:

$ echo .env >> .gitignore  # we don't want to commit our secrets into git!
$ echo EMAIL_PASSWORD="yoursekritpasswordhere" >> .env
$ set -a; source .env; set +a;

It does mean you have to remember to do that weird set -a; source…​ dance, every time you start working on the project, as well as remembering to activate your virtualenv.

If you search or ask around, you’ll find there are some tools and shell plugins that load virtualenvs and .env files automatically, and/or django plugins that do this stuff too.

And now…​

$ python src/manage.py runserver

…​you should see something like Check your email…​..

de-spiked site with success message
Figure 1. Check your email…​.

Woohoo!

I’ve been waiting to do a commit up until this moment, just to make sure everything works. At this point, you could make a series of separate commits—​one for the login view, one for the auth backend, one for the user model, one for wiring up the template. Or you could decide that, since they’re all interrelated, and none will work without the others, you may as well just have one big commit:

$ git status
$ git add .
$ git diff --staged
$ git commit -m "Custom passwordless auth backend + custom user model"

Finishing Off Our FT, Testing Logout

The last thing we need to do before we call it a day is to test the logout link (you may remember the URL just says #TODO at the moment.) We extend the FT with a couple more steps:

Example 47. src/functional_tests/test_login.py (ch19l048)
        [...]
        # she is logged in!
        self.wait_for(lambda: self.browser.find_element(By.LINK_TEXT, "Log out"))
        navbar = self.browser.find_element(By.CSS_SELECTOR, ".navbar")
        self.assertIn(TEST_EMAIL, navbar.text)

        # Now she logs out
        self.browser.find_element(By.LINK_TEXT, "Log out").click()

        # She is logged out
        self.wait_for(
            lambda: self.browser.find_element(By.CSS_SELECTOR, "input[name=email]")
        )
        navbar = self.browser.find_element(By.CSS_SELECTOR, ".navbar")
        self.assertNotIn(TEST_EMAIL, navbar.text)

With that, we can see that the test is failing because the logout button doesn’t actually do anything:

$ python src/manage.py test functional_tests.test_login
[...]
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: input[name=email]; [...]

So let’s tell the base template that we want a new url named "logout":

Example 48. src/lists/templates/base.html (ch19l049)
          {% if user.email %}
            <ul>
              <span class="navbar-text">Logged in as {{ user.email }}</span>
              <a href="{% url 'logout' %}">Log out</a>
            </ul>
          {% else %}

If you try the FTs at this point, you’ll see an error saying that URL doesn’t exist yet:

$ python src/manage.py test functional_tests.test_login
Internal Server Error: /
[...]
django.urls.exceptions.NoReverseMatch: Reverse for 'logout' not found. 'logout'
is not a valid view function or pattern name.

======================================================================
ERROR: test_can_get_email_link_to_log_in
(functional_tests.test_login.LoginTest.test_can_get_email_link_to_log_in)
[...]

selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: Log out; [...]

Implementing a logout URL is actually very simple: we can use Django’s built-in logout view, which clears down the user’s session and redirects them to a page of our choice:

Example 49. src/accounts/urls.py (ch19l050)
from django.contrib.auth import views as auth_views
from django.urls import path

from . import views

urlpatterns = [
    path("send_login_email", views.send_login_email, name="send_login_email"),
    path("login", views.login, name="login"),
    path("logout", auth_views.LogoutView.as_view(next_page="/"), name="logout"),
]

And that gets us a fully passing FT—​indeed, a fully passing test suite:

$ python src/manage.py test functional_tests.test_login
[...]
OK
$ cd src && python manage.py test
[...]

Ran 56 tests in 78.124s

OK
Warning
We’re nowhere near a truly secure or acceptable login system here. Since this is just an example app for a book, we’ll leave it at that, but in "real life" you’d want to explore a lot more security and usability issues before calling the job done. We’re dangerously close to "rolling our own crypto" here, and relying on a more established login system would be much safer.

In the next chapter, we’ll start trying to put our login system to good use. In the meantime, do a commit and enjoy this recap:

On Mocking in Python
Mocking and external dependencies

We use mocking in unit tests when we have an external dependency that we don’t want to actually use in our tests. A mock is used to simulate the third-party API. Whilst it is possible to "roll your own" mocks in Python, a mocking framework like the unittest.mock module provides a lot of helpful shortcuts which will make it easier to write (and more importantly, read) your tests.

Monkeypatching

Replacing an object in a namespace at runtime. We use it in our unit tests to replace a real function which has undesirable side effects with a mock object, using the mock.patch decorator.

The Mock library

Michael Foord (who used to work for the company that spawned PythonAnywhere, just before I joined) wrote the excellent "Mock" library that’s now been integrated into the standard library of Python 3. It contains most everything you might need for mocking in Python.

The mock.patch decorator

unittest.mock provides a function called patch, which can be used to "mock out" (monkeypatch) any object from the module you’re testing. It’s commonly used as a decorator on a test method. Importantly, it "undoes" the mocking at the end of the test for you, to avoid contamination between tests.

Mocks can leave you tightly coupled to the implementation

As we saw in Mocks Can Leave You Tightly Coupled to the Implementation, mocks can leave you tightly coupled to your implementation. For that reason, you shouldn’t use them unless you have a good reason.

Mocks can save you from duplication in your tests

With that said, there is an argument for using mocks to remove duplication; used extensively, this approach leads to "London-style" TDD, a variation on the style I mostly follow and show in this book.

There’s lots more discussion of the pros and cons of mocks coming up soon. Read on!


1. I’m using the generic term "mock", but testing enthusiasts like to distinguish other types of a general class of test tools called "Test Doubles", including spies, fakes, and stubs. The differences don’t really matter for this book, but if you want to get into the nitty-gritty, check out this amazing wiki by Justin Searls. Warning: absolutely chock full of great testing content.
2. Yes, I know Django already mocks out emails using mail.outbox for us, but, again, let’s pretend it doesn’t. What if you were using Flask? Or what if this was an API call, not an email?
3. In Python 2, you can install it with pip install mock.