Where can I find good resources for creating password reset tests?

Hi,

I am following a rather old (but for the most part good) Django tutorial that I am trying to update to 5.1, and when I debugged some of my test code for password resets, I saw that the code that I initially followed and used results in my forms returning as None. I think that part of the problem is that I am importing (and using):

from django.contrib.auth.forms import PasswordResetForm, SetPasswordForm

But when I searched for django.contrib.auth.forms, I only found django.contrib.auth. Am I right in thinking that django.contrib.auth.forms is deprecated? I could not find any Django documentation regarding default password resets (which is what I am using) tests. Have been looking everywhere. Is it even worth testing when I am using the default templates (which work perfectly)? Or am I just missing something in not being able to find such test documentation? I do have a public Github repository of this project if anyone wants to take a look at it there: https://github.com/interglobalmedia/django-boards The password reset related code is within the accounts app and the templates are inside the root templates directory. Thanks in advance!

Searching where? What specifically are you searching through?

The PasswordResetForm and SetPasswordForm still exist in django.contrib.auth.forms.

If you’re having issues with specific forms or views not working, it would be most helpful if you posted the code that is trying to use them.

Django documentation via Google.

This is the only place that I came up with django.contrib.auth.forms for example. Well, I guess I will keep on looking…

I’m not following what the specific information is that you’re trying to find, or what the distinction is that you’re searching for.

I specifically want to find documentation on how to create tests for the default Django password reset. Or should I not even bother to create those tests? The tests I have right now only partially pass due to the use of

from django.contrib.auth.forms import PasswordResetForm, SetPasswordForm

which when used returns “is None”. I can’t get clearer than that.

You really shouldn’t. They’re covered by Django’s own test suite.

If you want to see how they’re tested, you can look at Django itself and how it implements those tests.

This is what I’m not following.

When used how? How are you testing these forms?

This import statement itself doesn’t do anything testable. You’re not doing anything with the form to test at this line, you’re just making it available to your code.

I’m using what I am importing. I did make SOME changes to the tests, decreasing the number of failures. But not completely. Here is the code where I have the failing tests and the form returning “None” (I have comments explaining):

from django.contrib.auth import views as auth_views
from django.contrib.auth import authenticate
from django.contrib.auth.forms import PasswordResetForm, SetPasswordForm
from django.contrib.auth.models import User
from django.contrib.auth.tokens import default_token_generator
from django.core import mail
from django.test import TestCase
from django.urls import resolve, reverse
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode

import bs4
import soupsieve as sv


class PasswordResetTests(TestCase):
    def setUp(self):
        user = User.objects.create_user(username='john', email='john@doe.com', password='123')
        user.save()
        url = reverse('password_reset')
        self.response = self.client.get(url)
        # prints out "./password-reset/ the url"
        print(url, 'the url')
        # prints out "<TemplateResponse status_code=200, "text/html; charset=utf-8"> get the url"
        print(self.response, 'get the url')
        auth_user = authenticate(user)
        print(user, 'the authenticated user')

    def test_status_code(self):
        self.assertEqual(self.response.status_code, 200)
        # Prints out "None reset status code"
        print(self.assertEqual(self.response.status_code, 200), 'reset status code')

    def test_view_function(self):
        view = resolve('/password-reset/')
        self.assertEqual(view.func.view_class, auth_views.PasswordResetView)
        # Prints out "None is anything being returned here?"
        print(self.assertEqual(view.func.view_class, auth_views.PasswordResetView), 'is anything being returned here?')

    def test_csrf(self):
        csrf_token = 'csrfmiddlewaretoken'
        self.assertContains(self.response, csrf_token)
        # returns "None the token"
        print(self.assertContains(self.response, csrf_token), 'the token')

    def test_contains_form(self):
        form = self.response.context.get('form')
        self.assertIsInstance(form, PasswordResetForm)

    def test_form_inputs(self):
        '''
        The view must contain two inputs: csrf and email
        '''
        self.assertContains(self.response, '<input', 2)
        self.assertContains(self.response, 'type="email"', 1)

class SuccessfulPasswordResetTests(TestCase):
    def setUp(self):
        email = 'john@doe.com'
        User.objects.create_user(username='john', email=email, password='123abcdef')
        url = reverse('password_reset')
        self.response = self.client.post(url, {'email': email})

    def test_redirection(self):
        '''
        A valid form submission should redirect the user to `password_reset_done` view
        '''
        url = reverse('password_reset_done')
        self.assertRedirects(self.response, url)

    def test_send_password_reset_email(self):
        self.assertEqual(1, len(mail.outbox))


class InvalidPasswordResetTests(TestCase):
    def setUp(self):
        url = reverse('password_reset')
        self.response = self.client.post(url, {'email': 'donotexist@email.com'})

    def test_redirection(self):
        '''
        Even invalid emails in the database should
        redirect the user to `password_reset_done` view
        '''
        url = reverse('password_reset_done')
        self.assertRedirects(self.response, url)

    def test_no_reset_email_sent(self):
        self.assertEqual(0, len(mail.outbox))


class PasswordResetDoneTests(TestCase):
    def setUp(self):
        url = reverse('password_reset_done')
        self.response = self.client.get(url)

    def test_status_code(self):
        self.assertEqual(self.response.status_code, 200)

    def test_view_function(self):
        view = resolve('/password-reset/done/')
        self.assertEqual(view.func.view_class, auth_views.PasswordResetDoneView)


class PasswordResetConfirmTests(TestCase):
    def setUp(self):
        user = User.objects.create_user(username='john', email='john@doe.com', password='123abcdef')

        '''
        create a valid password reset token
        based on how django creates the token internally:
        https://github.com/django/django/blob/1.11.5/django/contrib/auth/forms.py#L280
        '''
        self.uid = urlsafe_base64_encode(force_bytes(user.id)).encode()
        self.token = default_token_generator.make_token(user)

        url = reverse('password_reset_confirm', kwargs={'uidb64': self.uid, 'token': self.token})
        self.response = self.client.get(url, follow=True)

    def test_status_code(self):
        self.assertEqual(self.response.status_code, 200)

    def test_view_function(self):
        view = resolve('/password-reset-confirm/{uidb64}/{token}/'.format(uidb64=self.uid, token=self.token))
        self.assertEqual(view.func.view_class, auth_views.PasswordResetConfirmView)

    def test_csrf(self):
        # currently does not work. The test returns that "You clicked on invalid link. Try again".
        uidb64 = self.uid
        token = self.token
        self.assertContains(self.response, token)

    def test_contains_form(self):
        # add condition to test whether form is "None" or not. Add condition because there is no form.
        form = None
        if form is not None:
            # form is None
            form = self.response.context.get('form')
            self.assertIsInstance(form, SetPasswordForm)

    def test_form_inputs(self):
        '''
        The view must contain two inputs: csrf and two password fields
        '''
        self.assertContains(self.response, '<input', 3)
        self.assertContains(self.response, 'type="password"', 2)

        self.response = self.client.get(reverse("password_reset_confirm"))

        text = """
            <form method="post" novalidate="" class="password-reset-confirm">
              <input type="hidden" name="csrfmiddlewaretoken" value="hSV5mb7Ex4GqiuGcmmQEdsmDw7JtOavc4CpBqyd3fj2rppQQNDTbEfijYSyH5beF">

                <div class="form-group">
                    <label for="id_new_password1">New password:</label>
                    <input type="password" name="new_password1" autocomplete="new-password" class="form-control " aria-describedby="id_new_password1_helptext" id="id_new_password1" data-np-intersection-state="visible">
                    
                    <small class="form-text text-muted">
                        <ul><li>Your password can’t be too similar to your other personal information.</li><li>Your password must contain at least 8 characters.</li><li>Your password can’t be a commonly used password.</li><li>Your password can’t be entirely numeric.</li></ul>
                    </small>
                    
                </div>

                <div class="form-group">
                    <label for="id_new_password2">New password confirmation:</label>
                    <input type="password" name="new_password2" autocomplete="new-password" class="form-control " aria-describedby="id_new_password2_helptext" id="id_new_password2" data-np-intersection-state="visible">
                    
                    
                    <small class="form-text text-muted">
                        Enter the same password as before, for verification.
                    </small>
                    
                </div>

              <button type="submit" class="btn btn-success btn-block">Change password</button>
            </form>
        """

        soup = bs4.BeautifulSoup(text, "html5lib")
        sv.select(
                "form:is(.password-reset-confirm)",
                soup,
        )
        print(
            sv.select(
                "form:is(.password-reset-confirm",
                soup,
            )
        )
        for tag in soup.find_all('input'):
            print(tag)

class InvalidPasswordResetConfirmTests(TestCase):
    def setUp(self):
        user = User.objects.create_user(username='john', email='john@doe.com', password='123abcdef')
        uid = urlsafe_base64_encode(force_bytes(user.id)).encode()
        token = default_token_generator.make_token(user)

        '''
        invalidate the token by changing the password
        '''
        user.set_password('abcdef123')
        user.save()

        url = reverse('password_reset_confirm', kwargs={'uidb64': uid, 'token': token})
        self.response = self.client.get(url)

    def test_status_code(self):
        self.assertEqual(self.response.status_code, 200)

    def test_html(self):
        password_reset_url = reverse('password_reset')
        self.assertContains(self.response, 'invalid password reset link')
        self.assertContains(self.response, 'href="{0}"'.format(password_reset_url))

class PasswordResetCompleteTests(TestCase):
    def setUp(self):
        url = reverse('password_reset_complete')
        self.response = self.client.get(url)

    def test_status_code(self):
        self.assertEqual(self.response.status_code, 200)

    def test_view_function(self):
        view = resolve('/password-reset/complete/')
        self.assertEqual(view.func.view_class, auth_views.PasswordResetCompleteView)

And running those tests return the following:

python3 manage.py test accounts
Found 46 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.......None is this change form rendering?
...............FF..../password-reset/ the url
<TemplateResponse status_code=200, "text/html; charset=utf-8"> get the url
john the authenticated user
./password-reset/ the url
<TemplateResponse status_code=200, "text/html; charset=utf-8"> get the url
john the authenticated user
None the token
./password-reset/ the url
<TemplateResponse status_code=200, "text/html; charset=utf-8"> get the url
john the authenticated user
./password-reset/ the url
<TemplateResponse status_code=200, "text/html; charset=utf-8"> get the url
john the authenticated user
None reset status code
./password-reset/ the url
<TemplateResponse status_code=200, "text/html; charset=utf-8"> get the url
john the authenticated user
None is anything being returned here?
........[<form class="signup-form" method="post" novalidate="">
            <input name="csrfmiddlewaretoken" type="hidden" value="5bzfyc9iidGoyInd3IYNlTrBGVLNVo09hNqsSjydsbrvupjtRELqgD8siJf94pup"/>

            <div class="form-group">
                <label for="id_username">Username:</label>
                <input aria-describedby="id_username_helptext" autofocus="" class="form-control" data-np-intersection-state="visible" id="id_username" maxlength="150" name="username" required="" type="text"/>
                
                <small class="form-text text-muted">
                    Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.
                </small>
            </div>

            <div class="form-group">
                <label for="id_email">Email:</label>
                <input class="form-control" data-np-intersection-state="visible" id="id_email" maxlength="254" name="email" required="" type="email"/>
            </div>

            <div class="form-group">
                <label for="id_password1">Password:</label>
                <input aria-describedby="id_password1_helptext" autocomplete="new-password" class="form-control" data-np-intersection-state="visible" id="id_password1" name="password1" type="password"/>
                
                
                <small class="form-text text-muted">
                    <ul><li>Your password can’t be too similar to your other personal information.</li><li>Your password must contain at least 8 characters.</li><li>Your password can’t be a commonly used password.</li><li>Your password can’t be entirely numeric.</li></ul>
                </small>
                
            </div>

            <div class="form-group">
                <label for="id_password2">Password confirmation:</label>
                <input aria-describedby="id_password2_helptext" autocomplete="new-password" class="form-control" data-np-intersection-state="visible" id="id_password2" name="password2" type="password"/>
                
                
                <small class="form-text text-muted">
                    Enter the same password as before, for verification.
                </small>
                
            </div>

            <div class="form-group">
                <label>Password-based authentication:</label>
                <div class="form-control" id="id_usable_password"><div>
                <label for="id_usable_password_0"><input checked="" class="form-control" id="id_usable_password_0" name="usable_password" type="radio" value="true"/>
            Enabled</label>

            </div>
            <div>
                <label for="id_usable_password_1"><input class="form-control" id="id_usable_password_1" name="usable_password" type="radio" value="false"/>
            Disabled</label>

            </div>
            <div>
                <small class="form-text text-muted">
                    Whether the user will be able to authenticate using a password or not. If disabled, they may still be able to authenticate using other backends, such as Single Sign-On or LDAP.
                </small>
            </div>

            <button class="btn btn-primary btn-block" type="submit">Create an account</button>
        
        </div></div></form>]
<input name="csrfmiddlewaretoken" type="hidden" value="5bzfyc9iidGoyInd3IYNlTrBGVLNVo09hNqsSjydsbrvupjtRELqgD8siJf94pup"/>
<input aria-describedby="id_username_helptext" autofocus="" class="form-control" data-np-intersection-state="visible" id="id_username" maxlength="150" name="username" required="" type="text"/>
<input class="form-control" data-np-intersection-state="visible" id="id_email" maxlength="254" name="email" required="" type="email"/>
<input aria-describedby="id_password1_helptext" autocomplete="new-password" class="form-control" data-np-intersection-state="visible" id="id_password1" name="password1" type="password"/>
<input aria-describedby="id_password2_helptext" autocomplete="new-password" class="form-control" data-np-intersection-state="visible" id="id_password2" name="password2" type="password"/>
<input checked="" class="form-control" id="id_usable_password_0" name="usable_password" type="radio" value="true"/>
<input class="form-control" id="id_usable_password_1" name="usable_password" type="radio" value="false"/>
......
======================================================================
FAIL: test_csrf (accounts.tests.test_view_password_reset_tests.PasswordResetConfirmTests.test_csrf)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/mariacam/Python-Development/django-boards/django_boards/accounts/tests/test_view_password_reset_tests.py", line 131, in test_csrf
    self.assertContains(self.response, token)
  File "/Users/mariacam/.pyenv/versions/3.12.5/lib/python3.12/site-packages/django/test/testcases.py", line 623, in assertContains
    self.assertTrue(
AssertionError: False is not true : Couldn't find 'ce4ggb-850c03878c3f612ef5878b7cd2109d82' in the following response
b'\n<!DOCTYPE html>\n<html lang="en">\n  <head>\n    <meta charset="utf-8">\n    <meta name="description" content="A forum dedicated to all things Django" />\n    <meta name="keywords" content="django, python3" />\n    <title>\n      \n  \n    Reset your password\n  \n\n    </title>\n    <link rel="stylesheet" href="/static/css/bootstrap.min.css">\n    <link rel="stylesheet" href="/static/css/app.css">\n    \n  <link rel="stylesheet" href="/static/css/accounts.css">\n\n  </head>\n  <body>\n    \n  <div class="container">\n    <h1 class="text-center logo my-4">\n      <a href="/">Django Boards</a>\n    </h1>\n    \n  <div class="row justify-content-center">\n    <div class="col-lg-6 col-md-8 col-sm-10">\n      <div class="card">\n        <div class="card-body">\n          \n            <h3 class="card-title">Reset your password</h3>\n            <div class="alert alert-danger" role="alert">\n              It looks like you clicked on an invalid password reset link. Please try again.\n            </div>\n            <a href="/password-reset/" class="btn btn-secondary btn-block">Request a new password reset link</a>\n          \n        </div>\n      </div>\n    </div>\n  </div>\n\n  </div>\n\n    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/2.9.2/umd/popper.min.js"\n            integrity="sha512-2rNj2KJ+D8s1ceNasTIex6z4HWyOnEYLVC3FigGOmyQCZc2eBXKgOxQmo3oKLHyfcj53uz4QMsRCWNbLd32Q1g=="\n            crossorigin="anonymous"\n            referrerpolicy="no-referrer"></script>\n    <script src="https://code.jquery.com/jquery-3.7.1.min.js"\n            integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo="\n            crossorigin="anonymous"></script>\n    <script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/js/bootstrap.min.js"\n            integrity="512-ykZ1QQr0Jy/4ZkvKuqWn4iF3lqPZyij9iRv6sGqLRdTPkY69YX6+7wvVGmsdBbiIfN/8OdsI7HABjvEok6ZopQ=="\n            crossorigin="anonymous"\n            referrerpolicy="no-referrer"></script>\n  </body>\n</html>\n'

======================================================================
FAIL: test_form_inputs (accounts.tests.test_view_password_reset_tests.PasswordResetConfirmTests.test_form_inputs)
The view must contain two inputs: csrf and two password fields
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/mariacam/Python-Development/django-boards/django_boards/accounts/tests/test_view_password_reset_tests.py", line 145, in test_form_inputs
    self.assertContains(self.response, '<input', 3)
  File "/Users/mariacam/.pyenv/versions/3.12.5/lib/python3.12/site-packages/django/test/testcases.py", line 614, in assertContains
    self.assertEqual(
AssertionError: 0 != 3 : Found 0 instances of '<input' (expected 3) in the following response
b'\n<!DOCTYPE html>\n<html lang="en">\n  <head>\n    <meta charset="utf-8">\n    <meta name="description" content="A forum dedicated to all things Django" />\n    <meta name="keywords" content="django, python3" />\n    <title>\n      \n  \n    Reset your password\n  \n\n    </title>\n    <link rel="stylesheet" href="/static/css/bootstrap.min.css">\n    <link rel="stylesheet" href="/static/css/app.css">\n    \n  <link rel="stylesheet" href="/static/css/accounts.css">\n\n  </head>\n  <body>\n    \n  <div class="container">\n    <h1 class="text-center logo my-4">\n      <a href="/">Django Boards</a>\n    </h1>\n    \n  <div class="row justify-content-center">\n    <div class="col-lg-6 col-md-8 col-sm-10">\n      <div class="card">\n        <div class="card-body">\n          \n            <h3 class="card-title">Reset your password</h3>\n            <div class="alert alert-danger" role="alert">\n              It looks like you clicked on an invalid password reset link. Please try again.\n            </div>\n            <a href="/password-reset/" class="btn btn-secondary btn-block">Request a new password reset link</a>\n          \n        </div>\n      </div>\n    </div>\n  </div>\n\n  </div>\n\n    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/2.9.2/umd/popper.min.js"\n            integrity="sha512-2rNj2KJ+D8s1ceNasTIex6z4HWyOnEYLVC3FigGOmyQCZc2eBXKgOxQmo3oKLHyfcj53uz4QMsRCWNbLd32Q1g=="\n            crossorigin="anonymous"\n            referrerpolicy="no-referrer"></script>\n    <script src="https://code.jquery.com/jquery-3.7.1.min.js"\n            integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo="\n            crossorigin="anonymous"></script>\n    <script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/js/bootstrap.min.js"\n            integrity="512-ykZ1QQr0Jy/4ZkvKuqWn4iF3lqPZyij9iRv6sGqLRdTPkY69YX6+7wvVGmsdBbiIfN/8OdsI7HABjvEok6ZopQ=="\n            crossorigin="anonymous"\n            referrerpolicy="no-referrer"></script>\n  </body>\n</html>\n'

----------------------------------------------------------------------
Ran 46 tests in 11.133s

FAILED (failures=2)
Destroying test database for alias 'default'...

I just wanted to understand what was going on with these tests included in the tutorial (why did he include them in the first place I wonder since it was the default password resets) and if there was a way to make the tests that failed pass.

Are you sure you want to use resolve here? (Do you have a url identified as ‘password-reset’ mounted at root in your url tree? The current url as defined in those docs is password_reset, not password-reset.)

Typically, these urls are defined in a way as described in the docs for Using the views:

path("accounts/", include("django.contrib.auth.urls")),

which means you would need to resolve /accounts/password_reset/.

Or, more appropriately, you would use reverse to find the URL by name, and then use resolve to get the view function from the identified url.

But all this is based on you using the standard URLs for these purposes. If you’re defining custom urls then what you have may be correct.

Now, as far as this:

This is correct, because the assertEqual method isn’t going to return anything if the test passes. (The assert functions don’t return results on passing values.)

The other thing I’ve noticed:

You should not be using the encode method here. If you look at the url being generated as a result of this at your line:

You’ll see that you’re generating an incorrect url.

Thanks @KenWhitesell! I am new to doing all of this testing in Django, and I did mention that I was following an old tutorial and trying to update it to “current” Django. I will look over your notes and yes, I did see in the current docs that the thing to do is to use “accounts” which was not used in the tutorial I am following. I definitely then should change to that! I do have a password-reset in urls.py. But I guess I should try and fix everything to match what is in the docs. I did not know I was using custom urls. We were told that they were the standard ones. Maybe they were way back then! As for encode(), I had decode, but an error was thrown. I will have to revisit that.
Thanks so much for the invaluable feedback. I see I have much to do! But the whole point was to find out what is current and implement. I will get there eventually!

To clarify this one point - you don’t use either one. If you look at the source for the save method in PasswordResetForm, the function call is urlsafe_base64_encode(force_bytes(user.pk))

Hi @KenWhitesell I did it! They all pass. I have to do something else, but I will share the passing code in a little bit. Thanks so much for pointing me in the right direction. So appreciate it! Learned a lot.