色々なテストコードを作成してみよう の講座。
今回はまだ開発されていない外部APIを含むテストの書き方を学んでいきます。
前回のレッスンはこちら↓
お前の日記アプリにAPIを追加してテストする方法を学んだな
めちゃくちゃ長かったね。。。
前回は、自分で作るAPIのテストだった
今回は外部APIとのテストを考えてみるぞ
開発の現場でよくあるのだが。開発途中の外部APIがあるとして
その外部APIを叩くような機能開発をしなければならない場合
お前ならどうする??
まだAPIできてないんでしょ?? APIが完成してから開発すればいいんじゃない??
NOだ
否定はやっ
そういう時はモックを使う
今回はモックを使ったテストコードの書き方を学んでいくぞ
テスト対象の準備
リポジトリのクローン
まずはベースとなる日記アプリのリポジトリをクローンする
$ git clone https://github.com/yheihei/base_diary.git
$ cd base_diary
テストコードだけ見る場合
今回もテストコードを書いたブランチを予め用意しておいた。
答えだけ先に見たいやつは下記コマンドでブランチをチェックアウトしてくれ
# テストコードが書いてあるブランチを見たい場合はこちら
$ git fetch && git checkout feature/#18
今回実装する機能
こんな状況を考えてくれ。
時は2006年。
この日記アプリの一覧に、アメリカで話題のTwitterを表示したい。
投稿者のタイムラインをサイドバーに表示するイメージだ。
APIリファレンスは下記で公開されている。
ただ、まだTwitter APIは日本から利用不可のようだ。なんせ2006年だからな。
近いうちに公開されるのは間違いないが。。。
さあどうする!?
Twitter APIの公開を待とう!
答えはNO! 圧倒的なNOだ!
機能追加する
APIのエンドポイントがまだ実装されていない。
こういうのは開発の現場では良くあることだ。
そういう時はモックの仕組みを使うと良い。
まずはAPIリファレンスを確認するぞ。
エンドポイントは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年だからな。
つまり、このサービスクラスはどうやってもタイムラインを返してこない!
テストコードを書く
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(テスト駆動開発)っぽくてチャラいな。。。
答えは例のごとくブランチに書いてあるので、どうしても分からなかったら確認してくれ。
外部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フレームワークを徹底解説!
動画講座で手を動かしながら、ほとんどのことが学べます。
ウサギもここから始めました。
コメント