外部APIをモック化してテストする

モックを使った自動テストを作成しよう色々なテストコードを作成してみよう

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

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

今回はまだ開発されていない外部APIを含むテストの書き方を学んでいきます。

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

ウサギ
ウサギ

お前の日記アプリにAPIを追加してテストする方法を学んだな

わいへい
わいへい

めちゃくちゃ長かったね。。。

ウサギ
ウサギ

前回は、自分で作るAPIのテストだった

ウサギ
ウサギ

今回は外部APIとのテストを考えてみるぞ

ウサギ
ウサギ

開発の現場でよくあるのだが。開発途中の外部APIがあるとして

ウサギ
ウサギ

その外部APIを叩くような機能開発をしなければならない場合

ウサギ
ウサギ

お前ならどうする??

わいへい
わいへい

まだAPIできてないんでしょ?? APIが完成してから開発すればいいんじゃない??

ウサギ
ウサギ

NOだ

わいへい
わいへい

否定はやっ

ウサギ
ウサギ

そういう時はモックを使う

ウサギ
ウサギ

今回はモックを使ったテストコードの書き方を学んでいくぞ

テスト対象の準備

リポジトリのクローン

ウサギ
ウサギ

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

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

今回実装する機能

こんな状況を考えてくれ。

時は2006年。

この日記アプリの一覧に、アメリカで話題のTwitterを表示したい。

投稿者のタイムラインをサイドバーに表示するイメージだ。

APIリファレンスは下記で公開されている。

GET /2/users/:id/tweets

ただ、まだTwitter APIは日本から利用不可のようだ。なんせ2006年だからな。

近いうちに公開されるのは間違いないが。。。

ウサギ
ウサギ

さあどうする!?

わいへい
わいへい

Twitter APIの公開を待とう!

ウサギ
ウサギ

答えはNO! 圧倒的なNOだ!

機能追加する

APIのエンドポイントがまだ実装されていない。

こういうのは開発の現場では良くあることだ。

そういう時はモックの仕組みを使うと良い。

まずはAPIリファレンスを確認するぞ。

GET /2/users/:id/tweets

エンドポイントはGET /2/users/:id/tweets

レスポンスのサンプルは下記。

{
  "data": [
    {
      "id": "1338971066773905408",
      "text": "💡 Using Twitter data for academic research? Join our next livestream this Friday @ 9am PT on https://t.co/GrtBOXh5Y1!n n@SuhemParack will show how to get started with recent search & filtered stream endpoints on the #TwitterAPI v2, the new Tweet payload, annotations, & more. https://t.co/IraD2Z7wEg"
    },
    {
      "id": "1338923691497959425",
      "text": "📈 Live now with @jessicagarson and @i_am_daniele! https://t.co/Y1AFzsTTxb"
    },
    ...
    {
      "id": "1334564488884862976",
      "text": "Before we release new #TwitterAPI endpoints, we let developers test drive a prototype of our intended design. @i_am_daniele takes you behind the scenes of an endpoint in the making. https://t.co/NNTDnciwNq"
    }
  ],
  "meta": {
    "oldest_id": "1334564488884862976",
    "newest_id": "1338971066773905408",
    "result_count": 10,
    "next_token": "7140dibdnow9c7btw3w29grvxfcgvpb9n9coehpk7xz5i"
  }
}

今回は、Twitter APIが↑のような固定のレスポンスを返すと想定し、

Twitterのタイムラインを取得するサービスクラスを作り。

作ったサービスクラスをモックを使ってテストできるようにする。

まずはTwitterのエンドポイントにアクセスするサービスクラスを書く。

diary/services.pyを作り、下記を書いてみよう。

import requests


class TweetGetTimelineService:
  def __init__(self, user_id) -> None:
    self.__user_id = user_id

  def get(self):
    params = {
      # 何かリクエストに必要なやつがここに入るが今は関係ない
    }
    response = requests.get(
      f'https://api.twitter.com/2/users/{str(self.__user_id)}/tweets',
      params=params
    )
    return response.json().get('data', [])

requests.get()で、エンドポイントを叩くだけだ。

何度も言うが https://api.twitter.com/2/users/:id/tweetsまだ公開されていない想定だ。

叩いてもホストが見つかんないよ的なエラーが返ってくる。2006年だからな。

つまり、このサービスクラスはどうやってもタイムラインを返してこない!

実際のTwitter APIはこの前にトークン取得したりとか色々やることがあるはずだが
そこは本筋とは関係ないので無視するぞ。

テストコードを書く

requests.getの返却値をモック化する

そこで登場するのがモックだ。

ここでやりたいのは、とにかくrequests.get()がタイムラインっぽいレスポンスを返してくれれば良い。

実はrequests.get()の返却値をモック化することができる。

下記のようにdiary/tests.pyを書く。

from django.test import TestCase, Client
from unittest.mock import patch
from diary.services import TweetGetTimelineService
from django.core.exceptions import PermissionDenied
from django.urls import reverse


class TweetGetTimelineMockResponse:
  def __init__(self):
    self.status_code = 200

  def json(self):
    '''
    APIレファレンス TimeLines GET /2/users/:id/tweets
    https://developer.twitter.com/en/docs/twitter-api/tweets/timelines/api-reference/get-users-id-tweets
    '''
    return {
      "data": [
        {
          "id": "3",
          "text": "つぶやき3"
        },
        {
          "id": "2",
          "text": "つぶやき2"
        },
        {
          "id": "1",
          "text": "つぶやき1"
        },
      ],
      "meta": {
        "oldest_id": "1",
        "newest_id": "3",
        "result_count": 3,
        "next_token": "7140dibdnow9c7btw3w29grvxfcgvpb9n9coehpk7xz5i"
      }
    }


class TweetGetTimelineServiceTest(TestCase):
  @patch("requests.get", return_value=TweetGetTimelineMockResponse())
  def test_1(self, mocked):
    '''
    ユーザーのタイムラインを正常に取得できること
    '''
    service = TweetGetTimelineService(user_id=1)
    timelines = service.get()
    self.assertEqual(True, len(timelines) > 0)
    self.assertEqual(
      '3',
      timelines[0].get('id')
    )
    self.assertEqual(
      'つぶやき3',
      timelines[0].get('text')
    )

下記部分に注目してほしい。

  @patch("requests.get", return_value=TweetGetTimelineMockResponse())
  def test_1(self, mocked):

このデコレーターの意味は

  • test_1関数の中では
  • requests.getの
  • 返り値が(return_value)がTweetGetTimelineMockResponse()オブジェクトになる

と言う意味だ。つまりサービスクラス中の下記部分

class TweetGetTimelineService:
    ...
    ...
    # responseはTweetGetTimelineMockResponse()オブジェクトになる
    response = requests.get(
      f'https://api.twitter.com/2/users/{str(self.__user_id)}/tweets',
      params=params
    )
    # response.json()はTweetGetTimelineMockResponse()のjson()関数の値(固定値)が返ってくる
    return response.json().get('data', [])

response.json()はTweetGetTimelineMockResponse()のjson()関数で定義した固定値が返るようになる。

↓の部分が返ってくるってことだ。

  def json(self):
    '''
    APIレファレンス TimeLines GET /2/users/:id/tweets
    https://developer.twitter.com/en/docs/twitter-api/tweets/timelines/api-reference/get-users-id-tweets
    '''
    return {
      "data": [
        {
          "id": "3",
          "text": "つぶやき3"
        },
        {
          "id": "2",
          "text": "つぶやき2"
        },
        {
          "id": "1",
          "text": "つぶやき1"
        },
      ],
      "meta": {
        "oldest_id": "1",
        "newest_id": "3",
        "result_count": 3,
        "next_token": "7140dibdnow9c7btw3w29grvxfcgvpb9n9coehpk7xz5i"
      }
    }

オレたちが待ち望んでいたタイムラインのレスポンスだ!!

これであら不思議、Twitterのエンドポイントが存在しない状態でも、想定レスポンスで機能の作成ができると言うわけ。

実際にテストを通してみる。

$ python manage.py test -v 2
System check identified 2 issues (0 silenced).
test_1 (diary.tests.TweetGetTimelineServiceTest)
ユーザーのタイムラインを正常に取得できること ... ok

----------------------------------------------------------------------
Ran 1 test in 0.001s

OK

モックを維持したまま画面表示のテストをする

これでTweetGetTimelineService(user_id=1).get()とやれば、仮のタイムラインレスポンスが取れるようになった。

もう一度やりたいことを確認しておくと

こんな感じにタイムラインの文言をテンプレート側で表示するのがゴールだ。

つまりテンプレートにタイムラインのリストを渡せられればOKそうだ。

と、言うことで画面表示のテストを書いておく。

diary/views.pyに下記を記載する。

from .services import TweetGetTimelineService

# Create your views here.
def index(request):
  timelines = TweetGetTimelineService(user_id=1).get()  # user_idは仮
  return render(request, 'index.html', {
    'posts': Post.objects.all(),
    'timelines': timelines
  })

この画面表示のテストは下記だ。

from django.urls import reverse
...
...
class IndexViewTest(TestCase):
  @patch("requests.get", return_value=TweetGetTimelineMockResponse())
  def test_1(self, mocked):
    '''
    ユーザのタイムラインがトップページに表示されていること
    '''
    response = Client().get(reverse('diary:index'))
    timelines = response.context['timelines']
    self.assertEqual(True, len(timelines) > 0)
    self.assertEqual(
      '3',
      timelines[0].get('id')
    )
    self.assertEqual(
      'つぶやき3',
      timelines[0].get('text')
    )

ではテストを実行するぞ。

$ python manage.py test -v 2

System check identified 2 issues (0 silenced).
test_1 (diary.tests.IndexViewTest)
ユーザのタイムラインがトップページに表示されていること ... ok
test_1 (diary.tests.TweetGetTimelineServiceTest)
ユーザーのタイムラインを正常に取得できること ... ok

----------------------------------------------------------------------
Ran 2 tests in 0.030s

OK

エラーレスポンスが返ってきたときのテストをする

ただ、正常系を作って終わりではない。

開発には必ず準正常/異常系の考慮が必要になってくる。

例えば、TwitterのAPIの認証が通っていない時。

APIのステータスコードは401となり、タイムラインは取得できないだろう。

こう言うときのテストも、それ用のモックを作っておくことで対応可能だ。

下記のようなテストコードになる。

まずは401を返すようなモッククラスを作り。。。

class TweetGetTimelineMockResponse401:
  def __init__(self):
    self.status_code = 401

  def json(self):
    return {
      'title': 'Unauthorized',
      'detail': 'Unauthorized',
      'type': 'about:blank',
      'status': 401
    }

次に、@patchで401のモック(TweetGetTimelineMockResponse401)を指定したテストコードを書く。

class TweetGetTimelineServiceTest(TestCase):
  ...

  @patch("requests.get", return_value=TweetGetTimelineMockResponse401())
  def test_2(self, mocked):
    '''
    ユーザーのタイムラインが401で取得できなかったときPermissionDeniedが発生すること
    '''
    service = TweetGetTimelineService(user_id=1)
    with self.assertRaises(PermissionDenied):
      service.get()

これでステータスコードが401になるようなテストが書けた。

このように、プロダクションコードを全く変えずに、APIレスポンスだけをモックで変えることができる。

実際のエンドポイントが未開通だったとしても、API仕様書さえあれば、様々な単体テストが可能だ。

テストを通すようなコードを書いてみよう

さて、ここでやってみようのコーナーだ。

現状下記テストを実行すると、エラーが出る。

$ python manage.py test diary.tests.TweetGetTimelineServiceTest.test_2

System check identified 2 issues (0 silenced).
F
======================================================================
FAIL: test_2 (diary.tests.TweetGetTimelineServiceTest)
ユーザーのタイムラインが401で取得できなかったときPermissionDeniedが発生すること
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/usr/local/opt/python@3.8/Frameworks/Python.framework/Versions/3.8/lib/python3.8/unittest/mock.py", line 1325, in patched
    return func(*newargs, **newkeywargs)
  File "/Users/yhei/github/base_diary/diary/tests.py", line 78, in test_2
    service.get()
AssertionError: PermissionDenied not raised

----------------------------------------------------------------------
Ran 1 test in 0.005s

FAILED (failures=1)

このエラーを解消するためのプロダクションコードを書いてみよう。

また、ストレッチ目標として、401を返すようなときの画面表示のテスト、プロダクションコードも書いてみよう。

わいへい
わいへい

TDD(テスト駆動開発)っぽくてチャラいな。。。

答えは例のごとくブランチに書いてあるので、どうしても分からなかったら確認してくれ。

base_diary/diary at feature/#18 · yheihei/base_diary
ウサでも分かる中級Django講座の教材. Contribute to yheihei/base_diary development by creating an account on GitHub.

外部APIをモック化する方法まとめ

ウサギ
ウサギ

今回はモックを使った自動テスト方法を学んだな

モックは下記のようにモックとして返したいクラスを作り。。。

class TweetGetTimelineMockResponse401:
  def __init__(self):
    self.status_code = 401

  def json(self):
    return {
      'title': 'Unauthorized',
      'detail': 'Unauthorized',
      'type': 'about:blank',
      'status': 401
    }

テストコードの関数に@patchのデコレーターをつけてやれば良い。

  @patch("requests.get", return_value=TweetGetTimelineMockResponse401())
  def test_2(self, mocked):
    '''
    ユーザーのタイムラインが401で取得できなかったときPermissionDeniedが発生すること
    '''
わいへい
わいへい

今回requests.getの返却値を変えたけど、他のものでも変えれたりするのか?

ウサギ
ウサギ

もちろん。例えば自作のサービスクラスをテスト時だけこう言うレスポンスにしておく〜的なことができる

ウサギ
ウサギ

大規模開発をやっていると、対向のI/Fが完成していないことが良くある

ウサギ
ウサギ

そんな時はモックを使って先行開発できるように心がけてみてくれ

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

わいへい
わいへい

これでテストコードの書き方はだいたい終わったのかな?

ウサギ
ウサギ

そうだな。今までの知識でほとんどのテストコードが書けるはずだ。

サーバーサイドにおいてはな

わいへい
わいへい

え? まだあるの?

ウサギ
ウサギ

次はフロントエンドのテスト方法を学んでいくぞ

わいへい
わいへい

フロントエンド?

ウサギ
ウサギ

こいつフロントエンドって何? って思ってるな

次の講座

Djangoの基礎を学びたい方は

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

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

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

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

コメント

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