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

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
わいへい
わいへい

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

わいへい
わいへい

これは、記事が1件も入ってないってことだよね?

わいへい
わいへい

ねんのため、ローカルのDBをSQLで見ると、

$ sqlite3 db.sqlite3
sqlite> .header on
sqlite> select * from diary_post;

id|title|body|created_at|updated_at|user_id
1|初めての日記です|<p>Djangoで日記アプリを作りました!
<p>adminで日記投稿できるし、ユーザーも追加できる、カテゴリーも作れるし最高!|2021-06-07 13:11:23.615464|2021-06-15 04:03:54.439426|1
2|個人開発、忙しいです|<h3>カテゴリーをつけてみた</h3>
<p>楽しみにしているみなさん、申し訳ございません</p>
<p>個人開発が忙しく、手が回らない状態です
<p>画像表示機能もつけないといけないし、やることはたくさんあります
<p>充実? ってやつですね。
<p>#朝活 #ブログ書け #副業月収3桁|2021-06-07 13:12:19.457595|2021-06-15 04:11:55.771296|1
わいへい
わいへい

ちゃんと日記が入ってそうなんだけど。。。

ちゃんとテストできてるの?

テストってちょっと怪しくない??

ウサギ
ウサギ

お前みたいなやつがSQLを書けたことに驚いているが、解説しよう

わいへい
わいへい

さらりと悪口言われた??

実はDjangoの自動テストはテスト用の空っぽのDBを作ってテスト実行してるんだ。

簡単なフロー図を作ってみた。

django自動テストのフロー
Django自動テストのフロー

./manage.py test が実行された後のテストの流れはこの通りだ。

1. テスト用DB作成

最初に、お前がいじっているローカルDBとは別のテスト用DBが新規で作成される。

3. テストデータの読み込み(fixtures)

その後、fixturesを使って、DBの初期データを投入できる。

これはTestCaseクラス内だけで有効な初期データだ。

4-2. テスト実行 test_xxx()

4-2で実際にtest_xxx()のテストが行われる。

4.4 DBロールバック(setUp~tearDown内の変更分)

test_xxx()内で投入したデータはtest_xxx()が終わったら、4.4でロールバックされる。

test_xxx()が終わってtest_yyyに()に移ったら、test_xxx内で投入されたデータは破棄されているってわけ。

5. DBロールバック(fixtures分)

test_xxx()が全て終わったら、「3. テストデータの読み込み(fixtures)」で入れたテストデータを破棄して、次のTestCaseクラスに行く。

テストデータを入れてみる

わいへい
わいへい

うーん。ひとまずテスト用DBが作られることはわかったけど、

全然わからない

まあそうだな。実際やってみないとわからん。

試しに、日記コンテンツの「3. テストデータの読み込み(fixtures)」をやってみよう。

diary/fixtures/test/test_index_view.json にDBのデータをjsonで記載する。

[
{
  "model": "diary.user",
  "pk": 1,
  "fields": {
    "username": "yhei",
    "email": "yheihei0126@gmail.com"
  }
},
{
  "model": "diary.post",
  "pk": 1,
  "fields": {
    "user": 1,
    "title": "タイトル1",
    "body": "<p>一つ目の日記本文",
    "created_at": "2021-06-07T13:11:23.615Z",
    "updated_at": "2021-06-07T13:11:23.615Z",
    "categories": [
      1
    ]
  }
},
{
  "model": "diary.post",
  "pk": 2,
  "fields": {
    "user": 1,
    "title": "2つめの記事",
    "body": "<p>二つ目の日記本文",
    "created_at": "2021-06-07T13:12:19.457Z",
    "updated_at": "2021-06-07T13:13:12.822Z",
    "categories": [
      1
    ]
  }
},
{
  "model": "diary.category",
  "pk": 1,
  "fields": {
    "name": "日記カテゴリ",
    "slug": "diary",
    "created_at": "2021-06-07T13:11:40.564Z",
    "updated_at": "2021-06-07T13:11:40.564Z"
  }
}
]

DBのデータをjsonで書き直しただけだ。

わいへい
わいへい

ええ。。。なんかめんどくさくない?

そういうと思った。

そういうボンクラ向けに、既存DBからjsonをDumpする方法もある。

下記コマンドだ。

$ python manage.py dumpdata アプリ名 --indent=4 > 出力先のjsonファイル名

# 例
python manage.py dumpdata diary --indent=4 > diary/fixtures/test/test_index_view.json

アプリ名を指定してdumpdataコマンドを実行するだけだ。

まずはこれでテストすると良いかもしれないな。

次はテストデータを読み込んでみるぞ。

下記を追加してくれ。

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

  # テスト用データを投入する
  fixtures = ['diary/fixtures/test/test_index_view.json']

先ほど作ったjsonをfixturesという配列で指定するだけだ。

配列に複数のファイルパスを入れれば、複数のファイルからデータを入れることもできる。

  fixtures = [
    'diary/fixtures/test/user.json',
    'diary/fixtures/test/post.json',
  ]

モデルごとにjsonを分けたりしたいやつは使うといい。

わいへい
わいへい

確かに一つのJSONにいろんなモデルのデータが入り乱れてたら大変かも

じゃあテストをやってみるぞ。

今のテストコードは下記だ。

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


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

  # テスト用データを投入する
  fixtures = ['diary/fixtures/test/test_index_view.json']
  
  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).
.
----------------------------------------------------------------------
Ran 1 test in 0.045s

OK
わいへい
わいへい

通った〜〜〜〜〜!!

このまま日記の内容を確認するテストを追加してみるぞ。

    # 記事にタイトル、本文、カテゴリが存在する
    post = posts[0]
    self.assertEqual('タイトル1', post.title)
    self.assertEqual('<p>一つ目の日記本文', post.body)
    self.assertEqual('diary', post.categories.first().slug)
ウサギ
ウサギ

テスト実行!

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

OK
わいへい
わいへい

タイトル、本文、カテゴリーの内容が正しいかが確認できた!!!

表示項目をテストする方法まとめ

ウサギ
ウサギ

今回の自動テスト方法をまとめるぞ

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


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

  # テスト用データを投入する
  fixtures = ['diary/fixtures/test/test_index_view.json']
  
  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)
ウサギ
ウサギ

Clientクラスを使って、表示したいURLにアクセスする

    c = Client()
    response = c.get(reverse('diary:index'))
ウサギ
ウサギ

self.assertEqualで期待のresponseが取れているか確認する

    self.assertEqual(200, response.status_code)
ウサギ
ウサギ

views.pyから渡されているものはresponse.context['hoge']で取得できる

    posts = response.context['posts']
    # 記事が1件以上取れていること
    self.assertEqual(True, len(posts) > 0)
ウサギ
ウサギ

以上だ。これを覚えるだけで、死ぬほど開発が捗るようになる。

ウサギ
ウサギ

機能がデグレったときは、

このテストがエラーを吐いてくれるはずだ

ウサギ
ウサギ

手動テストしてる暇があったら、こういう簡単なものは

即テスト化するように。

わかったな?

わいへい
わいへい

これで自動テストを極めたと言っても過言ではないな!

ウサギ
ウサギ

アホか

ウサギ
ウサギ

まだめちゃくちゃ語ることあるからな!

次は関数とかクラスのテスト方法を解説するから。

ちゃんと復習しとけよ!!!?

わいへい
わいへい

こいつウサギのくせにめちゃくちゃ意識たけえな

\ 講座で学んだことを即アウトプットしよう /

次の講座

Djangoの基礎を学びたい方は

Djangoの基礎を固めたい方はこちら(セール時に買うのがおすすめ)

『Djangoパーフェクトマスター』〜インスタ映えを支えるPython超高速開発Webフレームワークを徹底解説!

動画講座で手を動かしながら、ほとんどのことが学べます。

ウサギもここから始めました。

コメント

  1. […] 画面の表示項目を自動テストするDjango自動テスト講座第一弾! Clientクラス… […]

タイトルとURLをコピーしました