もた日記

くだらないことを真面目にやる

Djangoメモ(20) : ユーザー認証を実装する〜サインアップのテストとフォームの改良

Python 3.6.4 Django 2.0.2

A Complete Beginner's Guide to Djangoのチュートリアルを参考に認証関連のテストを作成してみる。


サインアップのテスト

前回サインアップ機能を作成したのでサインアップのテストを作成する。
accounts/tests.pyを以下のように修正。

from django.contrib.auth.forms import UserCreationForm
from django.urls import reverse, resolve
from django.test import TestCase
from .views import signup

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

    def test_signup_status_code(self):
        self.assertEquals(self.response.status_code, 200)

    def test_signup_url_resolves_signup_view(self):
        view = resolve('/signup/')
        self.assertEquals(view.func, signup)

    def test_csrf(self):
        self.assertContains(self.response, 'csrfmiddlewaretoken')

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

上記は基本的なテストでステータスコードの確認、ビューやフォームが適切かどうかの確認、CSRF対策のトークンが含まれているかを確認している。


サインアップ成功時のテスト

次にサインアップ成功時のテストをaccounts/tests.pyに追加する。

from django.contrib.auth.models import User
from django.contrib.auth.forms import UserCreationForm
from django.urls import reverse, resolve
from django.test import TestCase
from .views import signup

class SignUpTests(TestCase):
    # code suppressed...

class SuccessfulSignUpTests(TestCase):
    def setUp(self):
        url = reverse('signup')
        data = {
            'username': 'john',
            'password1': 'abcdef123456',
            'password2': 'abcdef123456'
        }
        self.response = self.client.post(url, data)
        self.home_url = reverse('home')

    def test_redirection(self):
        '''
        A valid form submission should redirect the user to the home page
        '''
        self.assertRedirects(self.response, self.home_url)

    def test_user_creation(self):
        self.assertTrue(User.objects.exists())

    def test_user_authentication(self):
        '''
        Create a new request to an arbitrary page.
        The resulting response should now have a `user` to its context,
        after a successful sign up.
        '''
        response = self.client.get(self.home_url)
        user = response.context.get('user')
        self.assertTrue(user.is_authenticated)

各テストの意味は以下の通り。

  • setUp() : 適切なusername, passwordをPOST送信
  • test_redirection() : assertRedirects()homeページにリダイレクトされることを確認
  • test_user_creation() : Userオブジェクトが作成されていることを確認
  • test_user_authentication() : assertTrue(user.is_authenticated)でユーザーが認証済みであることを確認


サインアップ失敗時のテスト

無効なデータによりサインアップが失敗するケースのテストを追加する。

from django.contrib.auth.models import User
from django.contrib.auth.forms import UserCreationForm
from django.urls import reverse, resolve
from django.test import TestCase
from .views import signup

class SignUpTests(TestCase):
    # code suppressed...

class SuccessfulSignUpTests(TestCase):
    # code suppressed...

class InvalidSignUpTests(TestCase):
    def setUp(self):
        url = reverse('signup')
        self.response = self.client.post(url, {})  # submit an empty dictionary

    def test_signup_status_code(self):
        '''
        An invalid form submission should return to the same page
        '''
        self.assertEquals(self.response.status_code, 200)

    def test_form_errors(self):
        form = self.response.context.get('form')
        self.assertTrue(form.errors)

    def test_dont_create_user(self):
        self.assertFalse(User.objects.exists())

各テストの意味は以下の通り。

  • setUp() : 失敗するように空の辞書{}をPOST送信
  • test_signup_status_code() : 失敗時には再度フォームが表示されるのでステータスコードが200であることを確認
  • test_form_errors() : エラーメッセージがあることを確認
  • test_dont_create_user() : Userオブジェクトが作成されていないことを確認


サインアップフォームにEメールフィールド追加

テストは一通り実装したのでサインアップフォームを改良する。
ビルトインのUserCreationFormでサインアップフォームを作成しているが、このフォームにはusername, password1, password2の3つのフィールドはあるがemailフィールドがないのでサインアップフォームに追加する。
accounts/forms.pyを以下の内容で新規作成。

from django import forms
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth.models import User

class SignUpForm(UserCreationForm):
    email = forms.CharField(max_length=254, required=True, widget=forms.EmailInput())
    class Meta:
        model = User
        fields = ('username', 'email', 'password1', 'password2')

UserCreationFormを継承したSignUpFormクラスを作成しemailフィールドを追加している。
SignUpFormを使用するようにaccounts/views.pyを編集する。

from django.contrib.auth import login
from django.shortcuts import render, redirect

from .forms import SignUpForm

def signup(request):
    if request.method == 'POST':
        form = SignUpForm(request.POST)
        if form.is_valid():
            user = form.save()
            login(request, user)
            return redirect('home')
    else:
        form = SignUpForm()
    return render(request, 'signup.html', {'form': form})

これでEメールアドレス入力用のフィールドが追加され、ユーザー登録時にEメールアドレスがデータベースに登録されるようになる。

f:id:wonder-wall:20180319210525p:plain

テストもSignUpFormを使うように変更する。
また、フォームがcsrf, username, email, password1, password2の5つのinputタグだけを含むことを確認するテストを追加する。

from .forms import SignUpForm

class SignUpTests(TestCase):
    # ...

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

    def test_form_inputs(self):
        '''
        The view must contain five inputs: csrf, username, email,
        password1, password2
        '''
        self.assertContains(self.response, '<input', 5)
        self.assertContains(self.response, 'type="text"', 1)
        self.assertContains(self.response, 'type="email"', 1)
        self.assertContains(self.response, 'type="password"', 2)

class SuccessfulSignUpTests(TestCase):
    def setUp(self):
        url = reverse('signup')
        data = {
            'username': 'john',
            'email': 'john@doe.com',
            'password1': 'abcdef123456',
            'password2': 'abcdef123456'
        }
        self.response = self.client.post(url, data)
        self.home_url = reverse('home')

    # ...

テストを実行して通ることを確認する。

$ python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...........................
----------------------------------------------------------------------
Ran 27 tests in 1.958s

OK
Destroying test database for alias 'default'...


テストの構成を変更

次にSignUpForm自体のテストを追加するが、ここでテストの構成を変更する。
accounts/tests/というディレクトリを作成し、その中に__init__.pyファイルを作成する。そして、tests.pyも同じディレクトリに移動しtest_view_signup.pyにリネームする。
結果、ディレクトリ構成は以下のようになる。

accounts/
├── __init__.py
├── admin.py
├── apps.py
├── forms.py
├── migrations/
│  └── __init__.py
├── models.py
├── tests/
│  ├── __init__.py
│  └── test_view_signup.py
└── views.py

ディレクトリ構成が変わったのでaccounts/tests/test_view_signup.pyのimport文を変更する。

from ..forms import SignUpForm
from ..views import signup

SignUpForm自体のテストをaccounts/tests/test_form_signup.pyというファイルに新規作成。

from django.test import TestCase
from ..forms import SignUpForm

class SignUpFormTest(TestCase):
    def test_form_has_fields(self):
        form = SignUpForm()
        expected = ['username', 'email', 'password1', 'password2',]
        actual = list(form.fields)
        self.assertSequenceEqual(expected, actual)

テストを実行して問題ないことを確認する。

$ python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
............................
----------------------------------------------------------------------
Ran 28 tests in 1.964s

OK
Destroying test database for alias 'default'...


サインアップフォームのテンプレート変更

Bootstrap 4のCardsコンポーネントと背景パターンを使用してサインアップフォームの見た目を良くする。
背景パターンについては下記サイトで適当なパターンをダウンロード(今回はVintage Leavesを選択)する。

www.toptal.com

static/img/ディレクトリを作成し、ダウンロードした画像ファイルを置く。
そして、static/css/ディレクトリにaccounts.cssファイルを作成する。

├── static/
│  ├── css/
│  │  ├── accounts.css
│  │  ├── app.css
│  │  └── bootstrap.min.css
│  └── img/
│     └── vintage-leaves.png
└── templates/

static/css/accounts.cssを以下のように編集。

body {
  background-image: url(../img/vintage-leaves.png);
}

.logo {
  font-family: 'Pacifico', cursive;
}

.logo a {
  color: rgba(0,0,0,.9);
}

.logo a:hover,
.logo a:active {
  text-decoration: none;
}

css/accounts.cssを読み込み、Cardsコンポーネントを使用するようにtemplates/signup.htmlを編集する。

{% extends 'base.html' %}

{% load static %}

{% block stylesheet %}
  <link rel="stylesheet" href="{% static 'css/accounts.css' %}">
{% endblock %}

{% block body %}
  <div class="container">
    <h1 class="text-center logo my-4">
      <a href="{% url 'home' %}">Django Boards</a>
    </h1>
    <div class="row justify-content-center">
      <div class="col-lg-8 col-md-10 col-sm-12">
        <div class="card">
          <div class="card-body">
            <h3 class="card-title">Sign up</h3>
            <form method="post" novalidate>
              {% csrf_token %}
              {% include 'includes/form.html' %}
              <button type="submit" class="btn btn-primary btn-block">Create an account</button>
            </form>
          </div>
          <div class="card-footer text-muted text-center">
            Already have an account? <a href="#">Log in</a>
          </div>
        </div>
      </div>
    </div>
  </div>
{% endblock %}

これで下図のような見た目になる。

f:id:wonder-wall:20180319214031p:plain


まとめ

  • assertRedirects()でリダイレクトのテスト
  • user.is_authenticatedで認証済みであるかを確認
  • UserCreationFormを継承したクラスでEメールフィールドを追加
  • Bootstrap 4のCardsコンポーネントと背景パターンで見た目を変更