画面の表示項目を自動テストする

Djangoの表示項目を自動テストする色々なテストコードを作成してみよう

↓↓↓はじめて講座を受ける方はこちら↓↓↓
開発環境を作ろう

わいへい
わいへい

やったーーーー!!

Djangoのアプリができたぞ〜〜〜〜!!!!

ウサギ
ウサギ

お、セコセコなんかやってると思ったら、個人開発か。

わいへい
わいへい

ああ、今度のアイデアは最高だ。見ろ!

日記アプリのトップページ
ウサギ
ウサギ

チュートリアルアプリかな?

わいへい
わいへい

どうだ! いいだろ!

この日記アプリさえあれば、いちいちQiitaだのZennだのに書かなくても大丈夫。

わいへい
わいへい

どんどん機能追加していって、

ゆくゆくはQiitaを超えるプラットフォーマーになる!

わいへい
わいへい

そんで会社を売り払って早期Exit、早期リタイアって寸法よw

まだエンジニアで消耗してるのwww

ウサギ
ウサギ

根拠なき自信っていいよな。。。

テストコード書いた?

ウサギ
ウサギ

ところでこのアプリ、テストコードは書いたのか?

わいへい
わいへい

テストコード? うちみたいなベンチャー(1人)には必要ないさ。

わいへい
わいへい

市場に速くアプリを出さないといけないんだ。

わいへい
わいへい

ベンチャーはスピードが命。

わいへい
わいへい

テストコードなんて書いてる暇ないのさ!

ウサギ
ウサギ

コ○スゾ

わいへい
わいへい

え?

<strong>TDD原理主義ウサギ</strong>
TDD原理主義ウサギ

いや、そういうことを言うと

キレる輩もいるよなあって思っただけ。

ウサギ
ウサギ

テストコードは品質を担保する上で欠かせない。

やり方さえ覚えればすぐだから。

ウサギ
ウサギ

まあやってみようぜ。

わいへい
わいへい

今、めっちゃ怖かったよね?

自動テストのイメージ

ウサギ
ウサギ

まずはテストコードの完成形を見る。

お前のイケイケアプリのテストコードを実行してみよう。

こんなイメージだ。

$ python manage.py test -v 2
...
test_1 (diary.tests.TestIndexView)
一覧に日記コンテンツが表示されている ... ok

----------------------------------------------------------------------
Ran 1 test in 0.028s

OK
わいへい
わいへい

え? どう言うこと?

コマンド打ったら仕様に基づいたテストをしてくれてんの?

ウサギ
ウサギ

そうだ。

ウサギ
ウサギ

そして、なんらかのバグを埋め込んでテストが失敗した場合は…

FAIL: test_1 (diary.tests.TestIndexView) (message='2つ以上記事がある')
一覧に日記コンテンツが表示されている
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/yhei/github/base_diary/diary/tests.py", line 22, in test_1
    self.assertGreater(len(posts), 1)
AssertionError: 1 not greater than 1
ウサギ
ウサギ

こんな感じになる

わいへい
わいへい

おお。なんかエラーっぽいのが出てる

ウサギ
ウサギ

2つ以上記事があるってテストでこけてるな。

記事が2つ以上取れていなけりゃならないのに、1つしか記事がない。

ウサギ
ウサギ

記事の取得条件を誰かがミスった可能性がある

わいへい
わいへい

なるほど、仕様に基づいてテストコードを書けば、デグレがすぐにわかるってわけか

ウサギ
ウサギ

そんじゃあ実際に書いてみるぜ

テスト対象の準備

リポジトリのクローン

ウサギ
ウサギ

まずは日記アプリのリポジトリをクローンする

GitHub - yheihei/base_diary: ウサでも分かる中級Django講座の教材
ウサでも分かる中級Django講座の教材. Contribute to yheihei/base_diary development by creating an account on GitHub.
$ git clone https://github.com/yheihei/base_diary.git
$ cd base_diary

サンプルデータを入れる

サンプルデータも用意しておいた。

下記コマンドでDBにデータを入れることができる。

$ python manage.py migrate
$ python manage.py loaddata initial.json

テストコードだけ見る場合

ウサギ
ウサギ

ちなみにテストコードを書いたブランチも用意しておいた。

答えだけ先に見たいやつは下記コマンドでブランチをチェックアウトしてくれ

GitHub - yheihei/base_diary at feature/#1
ウサでも分かる中級Django講座の教材. Contribute to yheihei/base_diary development by creating an account on GitHub.
# テストコードがすでに書いてあるブランチを見たい場合はこちら
$ git fetch && git checkout feature/#1

テスト対象の解説

ウサギ
ウサギ

もう一度この日記アプリの画面表示を見てみよう。

トップページにアクセスすると、こんな画面が出ている

クソ日記アプリ

仕様をまとめるとこんな感じだ。

  • トップページにアクセスすると、日記一覧が表示される
  • 各日記には下記が表示される
    • タイトル
    • 投稿者
    • 本文
    • 投稿日
    • カテゴリー

モデル定義

ウサギ
ウサギ

モデルはこんな感じだな。

from django.db import models
from django.db.models.deletion import CASCADE

from django.contrib.auth.models import AbstractUser


# Create your models here.
class User(AbstractUser):
  pass

class Post(models.Model):
  id = models.AutoField(primary_key=True)
  user = models.ForeignKey(User, on_delete=CASCADE)
  title = models.CharField(max_length=2048)
  body = models.TextField()
  created_at = models.DateTimeField(auto_now_add=True)
  updated_at = models.DateTimeField(auto_now=True)
  categories = models.ManyToManyField(
    'Category',
    blank=True,
    related_name="posts",
  )

class Category(models.Model):
  name = models.CharField(max_length=1024)
  slug = models.CharField(max_length=1024)
  created_at = models.DateTimeField(auto_now_add=True)
  updated_at = models.DateTimeField(auto_now=True)

  def __str__(self) -> str:
    return f'{self.name}'
ウサギ
ウサギ

Postモデル(投稿)と、Userモデル(投稿者)が1対1で紐つく。

ウサギ
ウサギ

さらに、PostモデルとCategoryモデルが多対多で紐ついてる。

Viewとテンプレートの定義

ウサギ
ウサギ

views.pyはこんな感じ

from django.shortcuts import render

# Create your views here.
from .models import Post

# Create your views here.
def index(request):
  return render(request, 'index.html', {
    'posts': Post.objects.all(),
  })
ウサギ
ウサギ

取得する記事をPost.objects.all()で取得して、

index.htmlのテンプレート側に渡し

<html>
  <head>
    <title>とあるエンジニアの日記帳</title>
  </head>
  <body>
    {% load static %}
    <h1>とあるエンジニアの日記帳</h1>
    {% for post in posts %}
      <div>
        <h2>{{ post.title }}</h2>
        <div>投稿 : {{ post.user }}</div>
        <div>{{ post.body |safe }}</div>
        <div>{{ post.updated_at }}</div>
        <div>カテゴリー : 
        {% for category in post.categories.all %}
          <span>{{ category }},</span>
        {% endfor %}
        </div>
      </div>
    {% empty %}
      <p>No Diaries.</p>
    {% endfor %}
  </body>
</html>
ウサギ
ウサギ

トップページのテンプレートで表示するってだけの単純な構造だ

テストコードを書く

テストの実行コマンド

ウサギ
ウサギ

テストを実行するのはメチャクチャ簡単だ。

manage.pyがあるディレクトリで下記コマンドを打つだけ。

# 全テストをする場合
$ python manage.py test

# アプリのディレクトリ単位でテストをする場合
$ python manage.py test ディレクトリ名

# ファイル単位でテストをする場合
$ python manage.py test ディレクトリ名.ファイル名

# テストクラス単位でテストをする場合
$ python manage.py test ディレクトリ名.ファイル名.テストクラス名

# テストクラスの関数単位でテストをする場合
$ python manage.py test ディレクトリ名.ファイル名.テストクラス名.test_xxx

トップページにアクセスするテストを書いてみる

ウサギ
ウサギ

テストの流れは下記だ

  • トップページにアクセスし、そのレスポンスを取得する
  • レスポンスの中を見て、記事が取れているか確認する

まずはトップページにアクセスして、そのレスポンスを取得してみるぞ。

テストコードは全て、diary/tests.py に書いていく。こんな感じだ。

from django.test import TestCase, Client
from django.urls import reverse


# Create your tests here.
class TestIndexView(TestCase):
  '''#1 画面表示を自動テストする'''

  def setUp(self):
    pass
  
  def test_1(self):
    '''一覧に日記コンテンツが表示されている'''
    c = Client()
    response = c.get(reverse('diary:index'))
    print(response.status_code)
    print(response.content)

c = Client() を使うと、仮想のブラウザ(みたいなもの)が起動される。

それを使って

response = c.get('任意のURL')

で任意のURLにgetリクエストした際のレスポンスを取得できる。

テスト実行すると、トップページにアクセスした際の

responseのステータスコード(response.status_code)と

htmlの中身(response.content)がprintされるはずだ。

やってみるぞ。

$ python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
200
b'<html>\n  <head>\n    <title>\xe3\x81\xa8\xe3\x81\x82\xe3\x82\x8b\xe3\x82\xa8\xe3\x83\xb3\xe3\x82\xb8\xe3\x83\x8b\xe3\x82\xa2\xe3\x81\xae\xe6\x97\xa5\xe8\xa8\x98\xe5\xb8\xb3</title>\n  </head>\n
<body>\n    \n    
<h1>\xe3\x81\xa8\xe3\x81\x82\xe3\x82\x8b\xe3\x82\xa8\xe3\x83\xb3\xe3\x82\xb8\xe3\x83\x8b\xe3\x82\xa2\xe3\x81\xae\xe6\x97\xa5\xe8\xa8\x98\xe5\xb8\xb3</h1>\n    \n      
<p>No Diaries.</p>\n    \n  </body>\n</html>'
.
----------------------------------------------------------------------
Ran 1 test in 0.024s

OK
わいへい
わいへい

おお! ステータスコード200とhtmlの中身が返ってきてる!

この状態で最も単純なテストコードを書くとしたら。

  def test_1(self):
    '''一覧に日記コンテンツが表示されている'''
    c = Client()
    response = c.get(reverse('diary:index'))
    self.assertEqual(200, response.status_code)

self.assertEqual(200, response.status_code)

とやることで、ステータスコード(response.status_code)が200と等しいかのテストをしている。

では実際にテストを走らせてみる。

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

OK

この通り、テストが通ればOKと表示されてテストが終了する。

試しにわざとテストエラーとなるコードを書いてみよう。

  def test_1(self):
    '''一覧に日記コンテンツが表示されている'''
    c = Client()
    response = c.get('/存在しないパス')
    self.assertEqual(200, response.status_code)

c.get('/存在しないパス')で存在しないパスを指定してみた。

レスポンスのステータスコードは404になるはずだ。

テストを実行してみる。

$ python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F
======================================================================
FAIL: test_1 (diary.tests.TestIndexView)
一覧に日記コンテンツが表示されている
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/yhei/github/base_diary/diary/tests.py", line 19, in test_1
    self.assertEqual(200, response.status_code)
AssertionError: 200 != 404

----------------------------------------------------------------------
Ran 1 test in 0.013s

FAILED (failures=1)
わいへい
わいへい

ちゃんと404になって テストがエラーになってるね

トップページの表示項目をテストする

ここからが本題だ。

もう一度トップページの画面を見てみるぞ。

日記アプリ(笑)

我々はステータスコードのテストをしたいんじゃない。

日記がちゃんと表示されているかをテストしたいはずだ。

そこで、Client().get()のレスポンスを見る。

Client().get()のレスポンスではrender()で渡したcontextを確認することができる。

contextとはview側のrender関数で渡している部分のやつだ。

def index(request):
  return render(request, 'index.html', {
    'posts': Post.objects.all(),  # ★これがcontext部分
  })

実際にcontext の中のpostsをどのように取得できるか

↓のようにやると、postsを取得できるようになる。

    response = c.get(reverse('diary:index'))
    posts = response.context['posts']
わいへい
わいへい

おお。response.context[‘取りたいcontextのkey’] で取れるんだね

これで日記コンテンツがちゃんと出ているかのテストができる。

例えば「記事が1件以上取れていること」とかだったら

  def test_1(self):
    '''一覧に日記コンテンツが表示されている'''
    c = Client()
    response = c.get(reverse('diary:index'))
    self.assertEqual(200, response.status_code)
    posts = response.context['posts']
    # 記事が1件以上取れていること
    self.assertEqual(True, len(posts) > 0)

こう書いてやるわけだ。

わいへい
わいへい

すげーー! テストできる気がしてきた!!!

自動テストのフロー

わいへい
わいへい

でもさ。実際このテストコードを走らせてみるとさ

$ python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F
======================================================================
FAIL: test_1 (diary.tests.TestIndexView)
一覧に日記コンテンツが表示されている
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/yhei/github/base_diary/diary/tests.py", line 22, in test_1
    self.assertEqual(True, len(posts) > 0)
AssertionError: True != False

----------------------------------------------------------------------
Ran 1 test in 0.012s
わいへい
わいへい

エラー出るんだよね。。。

わいへい