UI操作の自動テストをする(E2Eテスト)

UI操作の自動テスト-Django色々なテストコードを作成してみよう

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

色々なテストコードを作成してみよう の講座の5レッスン目。

今回はUI操作の自動テスト(E2Eテスト)を勉強します。

前回のレッスンはこちら↓

ウサギ
ウサギ

前回まででサーバーサイドのテストはほぼできるようになった

ウサギ
ウサギ

次はUIのテストだ。いわゆるフロントエンドのテスト方法を学んでいくぞ

わいへい
わいへい

前回も言ってたけど、フロントエンドって・・・??

ウサギ
ウサギ

百聞は一見にしかず。まずは下記を見てくれ

いいね!ボタンの挙動

これは、今回作る「いいねするボタン」の動作だ。

「いいねする」が押されると、その投稿にいいねがつく。

逆に「いいね済」が押されると、その投稿のいいねが削除される。

画面リロードはされず、

JavaScriptで、いいね!APIを叩いて、動的にDB更新を行なっている。

また、APIのレスポンス結果をもとにボタンの表示も変更している。

ウサギ
ウサギ

要するに、ブラウザ上のJavaScriptだけで機能を作っちゃうやつ。

こういうのをフロントエンドと言う

ウサギ
ウサギ

フロントエンド、つまりJavaScriptが絡んでくるようなテストは、

今までのテスト方法では実施できない

ウサギ
ウサギ

そこで、Seleniumを使ってテストを実施していくぞ

テスト対象の準備

ではいつも通り、準備をしていくぞ。

リポジトリのクローン

ウサギ
ウサギ

ベースのアプリをcloneする

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

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

ウサギ
ウサギ

機能とテストコードを書いたブランチを用意しておいた。

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

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

テスト対象の解説

ウサギ
ウサギ

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

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

クソ日記アプリ

仕様は下記。

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

今回実装する機能

ではいいね機能を作っていく。

完成イメージは下記だ。

いいね!ボタンを押すと、
いいねがDBに保存されている

ログイン状態で日記一覧に行くと、「いいねする」ボタンが表示される。

「いいねする」ボタンを押すと、いいねしたユーザーと、その投稿が、いいねデータベースに保存される。

「いいねする」ボタンは、非同期通信を行い、いいねの追加、削除APIを使ってブラウザ上でデータベースを更新する。

methodエンドポイント名前どんな動作か
POST/api/stars/いいね追加いいねしたユーザーIDと投稿IDを受け取って、いいねを保存する
DELETE/api/stars/:id/いいね削除いいねのIDを受け取って、そのいいねを削除する
いいねに関するAPI仕様
わいへい
わいへい

うへえ〜、APIも作るのか

ウサギ
ウサギ

前回作っただろ

まあこの辺はコピペでできるようにしてやる。

重要なのはテストのやり方だからな。

機能追加する

いいねModelを作成する

まずはいいねのModelを作成する。

diary/models.pyに下記を追加する。

...
...
class Star(models.Model):
  '''いいねモデル'''
  class Meta:
    constraints = [
      models.UniqueConstraint(
        fields=["post", "user"],
        name="post_user_unique",
      ),
    ]
  post = models.ForeignKey(Post, on_delete=CASCADE)
  user = models.ForeignKey(User, on_delete=CASCADE)
  created_at = models.DateTimeField(auto_now_add=True)
  updated_at = models.DateTimeField(auto_now=True)

投稿(Post)とUser(投稿者)だけあれば十分だ。

ただし、1人の投稿者が1つの投稿に何回もいいねできたら微妙なので、models.UniqueConstraintを設定して、投稿(Post)とUser(投稿者)で複合ユニークにしておく。

ついでにadminに表示するためにdiary/admin.pyに下記を追加しておく。

from django.contrib import admin
from .models import User, Post, Category, Star  # 追加

# Register your models here.
admin.site.register(User)
admin.site.register(Post)
admin.site.register(Category)
admin.site.register(Star)  # 追加

テーブル定義が追加されたので、一度マイグレーションを行なっておく。下記コマンドだ。

$ python manage.py makemigrations
$ python manage.py migrate

いいねのAPIを用意する

フロントエンドからいいね追加/削除できるように、APIを作る。

ここではAPIを自動テストするのレッスンでも使った、django-rest-frameworkを使うぞ。

まずはdjango-rest-frameworkをインストールする。

$ pip install djangorestframework

次にsettings.pyでdjango-rest-frameworkのアプリを読み込むようにする。

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'diary',
    'rest_framework',  # 追加
]

最後に/api/stars/のエンドポイントを実装する。

diary/views.pyに下記を追加。

from rest_framework import viewsets, serializers

from diary.models import Star

...
...

class StarSerializer(serializers.ModelSerializer):
  class Meta:
    model = Star
    fields = ('id', 'post', 'user', 'created_at', 'updated_at',)


class StarViewSet(viewsets.ModelViewSet):
  queryset = Star.objects.all()
  serializer_class = StarSerializer

diary/urls.pyを下記のようにする。

from django.urls import path
from django.conf.urls import url, include
from . import views
from rest_framework import routers
from rest_framework.authtoken import views as auth_views

app_name = "diary"

router = routers.DefaultRouter()
router.register(r'stars', views.StarViewSet)

urlpatterns = [
  path("", views.index, name="index"),
  url(r'^api/', include(router.urls)),
]
ウサギ
ウサギ

これでいいねに関するAPIのCRUDが全て作成された

ウサギ
ウサギ

前のレッスンでも言ったが、django-rest-frameworkの詳しい説明は省く。

テスト方法のレッスンだから

この状態でhttp://127.0.0.1:8000/api/stars/にアクセスすると

いいねの一覧取得ができるようになった。

改めて確認はしないが、下記のAPIもすでに作られている。

methodエンドポイント名前どんな動作か
POST/api/stars/いいね追加いいねしたユーザーIDと投稿IDを受け取って、いいねを保存する
DELETE/api/stars/:id/いいね削除いいねのIDを受け取って、そのいいねを削除する
いいねに関するAPI仕様

フロントエンドの実装を行う

ウサギ
ウサギ

次はいよいよ、いいねボタンを押したら、いいねを追加する実装をする

わいへい
わいへい

でもさ、僕JavaScriptのことなんもわからんのだけど。。。

ウサギ
ウサギ

そういうと思った。

ウサギ
ウサギ

例によって、このレッスンはテストのレッスンなので

ウサギ
ウサギ

フロントエンド部分は走り抜けるぞ。(コピペで)

JavaScriptやCSSを格納するフォルダを用意する

まずはJavaScriptを格納するためのフォルダを用意する。

basediary/settings.pyに下記を追加してくれ。

...
...
STATICFILES_DIRS = (
    os.path.join(BASE_DIR, "static"),
)

これで、staticフォルダ配下に何かを置くと、テンプレート側から参照できるようになる。

JavaScript、CSSを作成する

ウサギ
ウサギ

ここからはJavaScriptやCSSを知らなければ意味不明だ

ウサギ
ウサギ

が、特に解説はしない。コピペで走り抜けてくれ

ウサギ
ウサギ

解説するのに3レッスンぐらいかかるような気がするから

まずは、static/css/style.cssを作って、下記のように記述する。

.star--disabled {
  pointer-events: none;
}
.star--enabled {
  pointer-events: initial;
}

次に、static/js/app.jsを作って、下記のように記述する。

class starButton {
  constructor(csrftoken, postId, userId, starId=0) {
    this.csrftoken = csrftoken;
    this.postId = postId;
    this.userId = userId;
    this.starId = starId;
    this.element = document.querySelector(`.star.star--post-id-${postId}`);

    /**
     * 初期化
     */
    this.init = () => {
      if (this.starId) {
        // いいね済の場合
        this.element.textContent = 'いいね済';
        this.element.removeEventListener('click', this.addStar)
        this.element.addEventListener('click', this.removeStar)
      } else {
        // まだいいね!していない
        this.element.textContent = 'いいねする';
        this.element.removeEventListener('click', this.removeStar)
        this.element.addEventListener('click', this.addStar)
      }
    }

    /**
     * 通信中にいいね!ボタン押下不可にする
     */
    this.disabled = () => {
      this.element.classList.remove('star--enabled');
      this.element.classList.add('star--disabled');
    }

    /**
     * いいね!ボタン押下可能にする
     */
    this.enabled = () => {
      this.element.classList.remove('star--disabled');
      this.element.classList.add('star--enabled');
    }

    /**
     * 非同期通信でいいね!する
     */
    this.addStar = async () => {
      this.disabled();

      let response = await fetch('/api/stars/', {
        method: 'POST',
        headers: {
          'X-CSRFToken': this.csrftoken,
          'Content-Type': 'application/json'
        },
        body:JSON.stringify({
            post: this.postId,
            user: this.userId
        })
      });
      if (!response.ok) {
        throw new Error('Network response was not ok');
      }
      if (response.status !== 201) {
        throw new Error(response.status);
      }
      const data = await response.json();
      this.starId = data['id']
      this.init();

      this.enabled();
    }

    /**
     * 非同期通信でいいね!を取り消す
     */
    this.removeStar = async () => {
      this.disabled();

      let response = await fetch(`/api/stars/${this.starId}`, {
        method: 'DELETE',
        headers: {
          'X-CSRFToken': this.csrftoken,
          'Content-Type': 'application/json'
        }
      })
      if (!response.ok) {
        throw new Error('Network response was not ok');
      }
      if (response.status !== 204) {
        throw new Error(response.status);
      }
      this.starId = 0;
      this.init();

      this.enabled();
    }
  }
}
わいへい
わいへい

全然分からないけど!?!?

だから分からないって言っただろ。

ひとまず、使い方だけ軽く教えておく。

このJavaScriptはテンプレート側で下記のように記述して使う。

<!-- いいねする場合の実装 -->

<!-- JavaScriptとCSSを読み込む -->
<script src="/static/js/app.js"></script>
<link rel="stylesheet" href="/static/css/style.css" %}">

...

<!-- いいねするボタンを用意 -->
<a href="#" class="star star--post-id-投稿のID">いいねする</a>
<!-- JavaScript側で定義したstarButtonクラスのインスタンスを作り、initメソッドを呼ぶ -->
<script>
  document.addEventListener('DOMContentLoaded', function() {
    new starButton(CSRFトークン, 投稿のID, ユーザーのID).init();
  });
</script>

「いいねする」ボタンを押すと、いいね追加のAPIが叩かれる。投稿のIDとユーザーのIDで誰がどの記事にいいねしたかを指定している。

JavaScript的にはnew starButton()の引数に、「CSRFトークン」と「いいねしたい投稿のID」、
「いいねしたユーザーのID」を指定するだけだ。

これだけでstarButtonクラスが勝手に「いいねする」ボタンを押した際の処理をやってくれる。

いいね追加のAPIのレスポンスが正常の場合、ボタンの名称が「いいねする」から「いいね済」に変わる。これもstarButtonクラスが勝手にやってくれる。

一方で、いいねを解除する場合は下記のようになる。

<!-- いいねを解除する場合の実装 -->

<!-- JavaScriptとCSSを読み込む -->
<script src="/static/js/app.js"></script>
<link rel="stylesheet" href="/static/css/style.css" %}">

...

<!-- いいね済ボタンを用意 -->
<a href="#" class="star star--post-id-投稿のID">いいね済</a>
<!-- JavaScript側で定義したstarButtonクラスのインスタンスを作り、解除したいいいねのIDを指定する -->
<script>
  document.addEventListener('DOMContentLoaded', function() {
    new starButton(CSRFトークン, 投稿のID, ユーザーのID, 解除したいいいねのID).init();
  });
</script>

new starButton()の第三引数に「解除したいいいねのID」を指定すればOK。

views.pyの実装

ではいよいよstarButtonクラスを使って、いいねボタンを実装してみる。

まずはdiary/views.pyindex関数を下記のように修正する。

from django.db.models import Prefetch
from diary.models import Star
...
...
def index(request):
  posts = Post.objects.all()
  if request.user.is_authenticated:
    posts = posts.prefetch_related(
      Prefetch(
        'star_set',
        queryset=Star.objects.filter(user=request.user),
        to_attr='stars'
      )
    )
  return render(request, 'index.html', {
    'posts': list(posts),
  })

prefetch_relatedを使って、PostからStarを逆参照で取って来ている。

Starの中でも

queryset=Star.objects.filter(user=request.user)

をすることで、ログインユーザーのStarだけを取って来れる。

例えば以下のような感じで、そのユーザーがそのpostにいいねしているかどうか判定できる。

for post in posts:
  if len(post.stars) > 0
    print('postに紐つくstarがある。つまりユーザーはこのpostにいいねしている')
    # starの中身を取りたい時は例えばこう
    print(post.stars[0].id)
  else:
    print('いいねしてない')

これでどの投稿でログインユーザーがいいねしているか、わかるようになった。

index.htmlの実装

index.html側ではpostsのループの中で↑のpost.starsの情報をもとに、JavaScriptの処理を変える。

こんな感じだ。

<html>
  <head>
    <title>とあるエンジニアの日記帳</title>
    {% load static %}
    <script src="{% static 'js/app.js' %}"></script>
    <link rel="stylesheet" href="{% static 'css/style.css' %}">
  </head>
  <body>
    <h1>とあるエンジニアの日記帳</h1>
    {% csrf_token %}
    {% 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>
        {% if user.is_authenticated %}
          <a href="#" class="star star--post-id-{{ post.id }}">
            {% if post.stars|length > 0 %}
              いいね済
            {% else %}
              いいねする
            {% endif %}
          </a>
          <script>
            document.addEventListener('DOMContentLoaded', function() {
              const csrftoken = document.querySelector('[name=csrfmiddlewaretoken]').value;
              {% if post.stars|length > 0 %}
                new starButton(csrftoken, {{ post.id }}, {{ user.id }}, {{ post.stars.0.id }}).init();
              {% else %}
                new starButton(csrftoken, {{ post.id }}, {{ user.id }}).init();
              {% endif %}
            });
          </script>
        {% endif %}
      </div>
    {% empty %}
      <p>No Diaries.</p>
    {% endfor %}
  </body>
</html>

ポイントは、

{% for post in posts %}のループの中で、JavaScriptのstarButtonクラスを下記のように使い分けていることだ。

<script>
  document.addEventListener('DOMContentLoaded', function() {
    const csrftoken = document.querySelector('[name=csrfmiddlewaretoken]').value;
    {% if post.stars|length > 0 %}
      new starButton(csrftoken, {{ post.id }}, {{ user.id }}, {{ post.stars.0.id }}).init();
    {% else %}
      new starButton(csrftoken, {{ post.id }}, {{ user.id }}).init();
    {% endif %}
  });
</script>

{% if post.stars|length > 0 %}の部分で、その投稿がいいね済みかどうかを判定している。

いいね済であれば↓でいいね解除の処理を。

new starButton(csrftoken, {{ post.id }}, {{ user.id }}, {{ post.stars.0.id }}).init();

まだいいねしていなければ、↓でいいね追加の処理をしている。

new starButton(csrftoken, {{ post.id }}, {{ user.id }}).init();

完成品の確認

この状態で、ログインし、http://127.0.0.1:8000/にアクセスしてみよう。

わいへい
わいへい

いいねするボタンが出てきた〜〜〜!! 長かった〜〜〜!!

テストコードを書く

ウサギ
ウサギ

いよいよUIのテストコードを書いてみる

実現したいテストは下記だ。

  • ログインして
  • トップページを開き
  • 「いいねする」ボタンを押したら
    • 「いいねする」ボタンが「いいね済」ボタンに変わること
    • いいねがDBに保存されること
わいへい
わいへい

え〜っと、テストコードでUI操作って全然イメージがつかないんだけど。。。

ウサギ
ウサギ

簡単だ。いつも通りpython manage.py testと叩くと、

勝手にブラウザが立ち上がってテストが行われる

わいへい
わいへい

え!?

テストの完成形を確認する

ウサギ
ウサギ

見た方が早い。実際に、テスト実行するとこんな感じになる

Django+SeleniumでUIの自動テスト
わいへい
わいへい

すげ〜〜〜〜!!!

DjangoがSeleniumを使ってブラウザを起動、そのブラウザを操作して、テストしている。

それじゃあ、早速やってみるぞ。

Seleniumをインストールする

まずは必要なパッケージをインストールしていくぞ。

$ pip install selenium webdriver-manager

seleniumはブラウザを起動して操作するモジュール。

webdriver-managerはChromeブラウザを動かすために必要なモジュールだ。

テストデータを入れる

次は恒例のテストデータ入れだ。

テストデータのfixturesをdiary/fixtures/test/test_index_view.jsonに入れる。

[
  {
    "model": "diary.user",
    "pk": 1,
    "fields": {
      "password": "pbkdf2_sha256$180000$v5YxxVKCObi7$SrCWUES4Qedh+fsHJIx2az1cMgS6wC4O7JapYTbj1/4=",
      "last_login": "2021-06-07T13:09:31.834Z",
      "is_superuser": true,
      "username": "yhei",
      "first_name": "",
      "last_name": "",
      "email": "yheihei0126@gmail.com",
      "is_staff": true,
      "is_active": true,
      "date_joined": "2021-06-07T13:05:56.903Z",
      "groups": [],
      "user_permissions": []
    }
  },
  {
    "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"
    }
  }
]

これを読み込めば、「yhei」という投稿者が作成され、日記2件が読み込まれる。

ブラウザを起動する

テストコードを書いていくぞ。流れは↓だ。

  • seleniumを使うためのモジュールをimport
  • selenium用のクラスStaticLiveServerTestCaseを継承してテストクラスを作る
  • setUpClass内でブラウザを起動
  • tearDownClass内でブラウザを落とす
# 1. seleniumを使うためのモジュールをimport
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from webdriver_manager.chrome import ChromeDriverManager

# 2. selenium用のクラスStaticLiveServerTestCaseを継承してテストクラスを作る
from django.contrib.staticfiles.testing import StaticLiveServerTestCase
class UiTest(StaticLiveServerTestCase):
  fixtures = ['diary/fixtures/test/test_index_view.json']

  @classmethod
  def setUpClass(cls):
    super().setUpClass()
    options = Options()
    # 3. setUpClass内でブラウザを起動
    # options.add_argument('--no-sandbox')  # UIのないOSの場合首開けすること
    # options.add_argument('--headless')    # UIのないOSの場合首開けすること
    cls.selenium = webdriver.Chrome(ChromeDriverManager().install(), options=options)

  @classmethod
  def tearDownClass(cls):
    # 4. tearDownClass内でブラウザを終了させる
    cls.selenium.quit()
    super().tearDownClass()
  
  def test_1(self):
    pass

この状態でテストを実行すると。

$ python manage.py test
Django+Selenium ブラウザの起動と終了
わいへい
わいへい

おお! ブラウザが起動した!

地味に重要な点として

StaticLiveServerTestCaseを継承して作るのがポイントだ。

LiveServerTestCaseを継承して作る方法がよくWeb上に出回っているが、

LiveServerTestCaseの場合、static配下のjsとかcssにアクセスできない。

staticフォルダ配下のリソースをもろに使う今回のテストケースでは、StaticLiveServerTestCaseを使うようにしてくれ。

2. StaticLiveServerTestCase

Selenium demands the test class to be either a LiveServerTestCase or a StaticLiveServerTestCase, that’s because it needs the server running to test the site.
Both classes are similar, the difference is that the latter will load the static content (custom css and javascript files for instace) while the former won’t.
I prefer StaticLiveServerTestCase because one of the reasons for using selenium is to test the javascript functions, so, static content is necessary.

https://elennion.wordpress.com/2018/11/23/django-automated-testing-with-selenium/

ログインする

わいへい
わいへい

OK! ブラウザも起動するようになったし、いいねを押すコードを…

待て。ログインしないといいねボタンが出てこない。

まずはログイン操作をseleniumにやらせてみるぞ。

テスト関数の前に必ず実行されるsetUp関数内でログイン操作をやってみる。

  def setUp(self) -> None:
    # ログイン
    self.selenium.get(self.live_server_url + '/admin/login/')
    username_input = self.selenium.find_element_by_name('username')
    username_input.send_keys('yhei')
    password_input = self.selenium.find_element_by_name('password')
    password_input.send_keys('password')
    self.selenium.find_element_by_css_selector('[type=submit]').click()

一個ずつ解説する。

まずは下記でログイン画面に遷移する。

self.selenium.get(self.live_server_url + '/admin/login/')

self.selenium.getで指定したURLに遷移する。self.live_server_urlでURLのホスト部分が取得できる。localhostのport番号がテスト実行する度に変化するので、self.live_server_urlを使うのが必須だ。

次に、ユーザー入力の要素をfind_element_by_nameを使ってname指定で取得する。

username_input = self.selenium.find_element_by_name('username')

取得した要素に下記でテキストを入力する。

username_input.send_keys('yhei')

これでユーザー名のフォームにテキストが入る。

同じようにPasswordの部分にもテキスト入力する。

password_input = self.selenium.find_element_by_name('password')
password_input.send_keys('password')

さらにセレクター指定で、Log inボタンの要素を取得してそのままclick()でクリックする。

self.selenium.find_element_by_css_selector('[type=submit]').click()

いいねボタンを押す

ログインできたらトップに遷移し、いいねするボタンを押してみる。

...
from diary.models import Star
from django.urls import reverse_lazy
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
...
...
  def test_1(self):
    self.selenium.get('%s%s' % (self.live_server_url, str(reverse_lazy('diary:index'))))
    star_button_element = self.selenium.find_element_by_class_name('star--post-id-1')
    star_button_element.click()

find_element_by_class_nameでいいねするボタンのCSSのclass名を指定して取得、その後click()でクリックする。

さっきとほぼ同じだな。

いいね追加APIの通信が終わったらassertを書く

わいへい
わいへい

あとはいいねがDBに保存されたか。

ボタンのテキストがちゃんと変わったかを確認する〜っと。

  def test_1(self):
    '''
    いいねするボタンを押したら、ボタンの名称が「いいね済」に変わること、いいねがDBに保存されること
    '''
    self.selenium.get('%s%s' % (self.live_server_url, str(reverse_lazy('diary:index'))))
    star_button_element = self.selenium.find_element_by_class_name('star--post-id-1')
    star_button_element.click()

    self.assertEqual('いいね済', star_button_element.text)
    self.assertEqual(
      True,
      Star.objects.filter(post__id=1, user__id=1).exists()
    )
わいへい
わいへい

できた!!!

ウサギ
ウサギ

(ニヤニヤ)

わいへい
わいへい

え? なんかダメだった??

一見できたように見えるだろ? 実はこのテストを走らせるとエラーになるはずだ。

なぜなら「いいねする」ボタンを押した後、すぐにDBを確認しにいっている。

すると、早すぎてAPIの処理が終わる前にassertEqualをしてしまう。

======================================================================
FAIL: test_1 (diary.tests.UiTest)
いいねするボタンを押したら、ボタンの名称が「いいね済」に変わること、いいねがDBに保存されること
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/yhei/github/base_diary/diary/tests.py", line 49, in test_1
    self.assertEqual('いいね済', star_button_element.text)
AssertionError: 'いいね済' != 'いいねする'
- いいね済
+ いいねする
わいへい
わいへい

ほんとだ。「いいねする」ボタンのままになってる!

つまり、APIの通信がちゃんと終わったかどうかを待つ必要がある。

ということで、下記★を追加してみる。

  def test_1(self):
    '''
    いいねするボタンを押したら、ボタンの名称が「いいね済」に変わること、いいねがDBに保存されること
    '''
    self.selenium.get('%s%s' % (self.live_server_url, str(reverse_lazy('diary:index'))))
    star_button_element = self.selenium.find_element_by_class_name('star--post-id-1')
    star_button_element.click()

    # ★★★ 「APIの通信が完了する」=「ボタンにstar--enabledのクラスがつく」まで待つ
    WebDriverWait(self.selenium, 2).until(
      EC.presence_of_element_located((By.CSS_SELECTOR, '.star--post-id-1.star--enabled'))
    )

    self.assertEqual('いいね済', star_button_element.text)
    self.assertEqual(
      True,
      Star.objects.filter(post__id=1, user__id=1).exists()
    )

WebDriverWaitを使うと、until内の条件が満たされるまで処理をストップすることができる。

EC.presence_of_element_located((By.CSS_SELECTOR, '.star--post-id-1.star--enabled'))

これは、.star--post-id-1.star--enabledのセレクターを持つ要素が出てくるまで待つ、という記述だ。

ここで「いいねする」ボタンのCSSクラスがどのように変化するかを解説しておく。

「いいねする」ボタンはAPIの通信状態によって下記のようにCSSクラスが変化する。

状態CSSクラス説明
初期状態.star--post-id-1
API通信中.star--post-id-1.star--disabled.star–disabledが付いている間はクリック不可
(連打対策)
API通信完了.star--post-id-1.star--enabledAPIのレスポンスが返ってきたら、
.star--disabledを外し、
.star--enabledを付与している

つまり、.star--post-id-1.star--enabledになっていれば、API通信が完了したことがわかる。

ではもう一度テストをやってみるぞ。

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

OK
Destroying test database for alias 'default'...
わいへい
わいへい

おお〜。テスト通った!!

UIのテストでは「〇〇がXXするまで待つ」ってのがよくある。

実装側の内部設計を理解しつつテストを組み立てることになる。注意してくれ。

また、「待つ」というのを単純に下記のようにやることもできる。

import time
...
# 3秒待機
time.sleep(3)

秒数指定で待つ方法だ。

ただ、この方法は全くオススメしない。

APIの通信が完了するのは3秒後とは限らない。

今後いいね追加の処理が重くなり、

レスポンスを返すのに3秒以上かかってテストが失敗したら。

エラー内容だけでは、レスポンスが遅くてエラーになったのか、実装をミスってエラーになったのかが判別つかないはずだ。

time.sleepを使うのは最終手段だと認識してくれ。

いいね済ボタンのテストコードを書いてみよう

ウサギ
ウサギ

では、最後にやってみようのコーナーだ

下記のテストを実装してみてくれ。

  • 「いいね済」ボタンを押したら
    • 「いいね済」ボタンの表示が「いいねする」に変わることを確認する
    • いいねがDBから削除されることを確認する

答えは下記に書いてあるので、どうしてもわからなければ見てやっても良い。

https://github.com/yheihei/base_diary/blob/feature/%236/diary/tests.py

UI操作の自動テストする方法まとめ

ウサギ
ウサギ

今回は、UI操作を自動テスト化する方法を学んだな

ウサギ
ウサギ

Seleniumをインストールした後、ブラウザを起動するコードを書く

# 1. seleniumを使うためのモジュールをimport
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from webdriver_manager.chrome import ChromeDriverManager

# 2. selenium用のクラスStaticLiveServerTestCaseを継承してテストクラスを作る
from django.contrib.staticfiles.testing import StaticLiveServerTestCase
class UiTest(StaticLiveServerTestCase):
  fixtures = ['diary/fixtures/test/test_index_view.json']

  @classmethod
  def setUpClass(cls):
    super().setUpClass()
    options = Options()
    # options.add_argument('--no-sandbox')  # UIのないOSの場合首開けすること
    # options.add_argument('--headless')    # UIのないOSの場合首開けすること
    cls.selenium = webdriver.Chrome(ChromeDriverManager().install(), options=options)

  @classmethod
  def tearDownClass(cls):
    # 4. tearDownClass内でブラウザを終了させる
    cls.selenium.quit()
    super().tearDownClass()
  
  def test_1(self):
    pass
わいへい
わいへい

ログインするときは要素を取得してから、send_keys。

クリックするときはclick()を使う〜っと。

  def setUp(self) -> None:
    # ログイン
    self.selenium.get(self.live_server_url + '/admin/login/')
    username_input = self.selenium.find_element_by_name('username')
    username_input.send_keys('yhei')
    password_input = self.selenium.find_element_by_name('password')
    password_input.send_keys('password')
    self.selenium.find_element_by_css_selector('[type=submit]').click()
ウサギ
ウサギ

処理待ちはtime.sleep()ではなく、WebDriverWaitを使って、処理待ちを行う

    # ★★★ APIの通信が完了する=ボタンにstar--enabledのクラスがつくまで待つ
    WebDriverWait(self.selenium, 2).until(
      EC.presence_of_element_located((By.CSS_SELECTOR, '.star--post-id-1.star--enabled'))
    )
ウサギ
ウサギ

これで大体のテストができるはずだ

ウサギ
ウサギ

今回は以上!

次が最後だ

わいへい
わいへい

おお、ついにテストの講座が終わるんだね

ウサギ
ウサギ

うむ。

最後はこの自動テストたちをCI/CDに組み込む方法を伝授するぞ

ウサギ
ウサギ

最後まで読んで、即現場で活かしてくれ

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

次の講座

Djangoの基礎を学びたい方は

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

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

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

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

コメント

  1. […] […]

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