概要
Django REST Frameworkでテスト中、from rest_framework.test import APIClient
を使用したPatchメソッドでJSON ParseError
が出てしまった場合の解決方法を紹介!
目的
掲題の問題の解決と解説
使用技術
# pyproject.tomlの一部
python = "^3.11.2"
Django = "^4.2.1"
djangorestframework = "^3.14.0"
pytest = "^7.3.1"
pytest-django = "^4.5.2"
APIClient().patch()がうまくいかない...
ユーザー情報を変更するAPIに対するテストで起こった出来事です
import pytest
from rest_framework.test import APIClient
@pytest.fixture
def client(scope="session"):
"""APIクライアントを作成"""
return APIClient()
@pytest.fixture
def partial_update_url():
"""ユーザー情報変更APIのURL"""
def _method(id):
return f"/api/users/{id}"
return _method
@pytest.fixture
def payload():
return {"name": "sotaheavymetal21", "user_role": "偉い人"}
@pytest.mark.django_db
def test_user_partial_update(
admin_user, general_user, partial_update_url, client, payload
):
# conftest.pyにフィクスチャを複数用意しています(説明は省略)
"""ユーザーの情報を変更できるか"""
response = client.patch(
partial_update_url(general_user.id),data=payload, format="json"
)
assert response.json() == {"detail": "ユーザー情報を変更しました"}
上記のテストを実行してみると、以下のエラーが返却されました
{'detail': 'リクエストのメディアタイプ "application/octet-stream" はサポートされていません。'}
?!
私は驚きました
以前までたくさんテストを実装してきて、今回も同様にやったつもりでした
違うのはHTTPメソッドが違う点のみです(以前はGETやPOSTでのテストが全て)
じゃあ、"application/json"を指定してあげよう
response = client.patch(
partial_update_url(general_user.user.id),
data=payload,
headers={"Content-Type": "application/json"},
)
以下のエラーが出ました
{'detail': 'JSON parse error - Expecting property name enclosed in double quotes: line 1 column 2 (char 1)'}
?!
パースエラー...
なぜ...
エラーが起きた原因はこれ
上記記事より引用↓
Pytest uses the django test client and client.post default content_type is multipart/form-data, while put, patch and delete use application/octet-stream.
That's why this is tricky sometimes. Even with post requests, if you plan to support JSON payload, you must tell the content type in the test request. Anyway, with recent Django versions, you can just pass the data object to the client request and it will be serialized for you, as declared in the docs:
If you provide content_type as application/json, the data is serialized using json.dumps() if it’s a dict, list, or tuple. Serialization is performed with DjangoJSONEncoder by default, and can be overridden by providing a json_encoder argument to Client. This serialization also happens for put(), patch(), and delete() requests.
重要な点は以下
-
PytestがDjangoのテストクライアントを使用し、
client.post
のデフォルトのcontent_typeはmultipart/form-data
である一方、putやpatch、deleteはapplication/octet-stream
を使用している -
実際、最新のDjangoのバージョンでは、データオブジェクトを単にクライアントリクエストに渡すことで、データが自動的にシリアライズされ、ドキュメントで宣言されている通りに動作することが可能
-
content_typeを
application/json
として提供する場合、データは辞書、リスト、またはタプルの場合にjson.dumps()
を使用してシリアル化される -
デフォルトでは
DjangoJSONEncoder
によってシリアル化が実行される -
このシリアル化は、put()、patch()、および delete() リクエストに対しても同様に行われる
APICientのコード
class APIClient(APIRequestFactory, DjangoClient):
def __init__(self, enforce_csrf_checks=False, **defaults):
super().__init__(**defaults)
self.handler = ForceAuthClientHandler(enforce_csrf_checks)
self._credentials = {}
# ~~この間省略~~
def post(self, path, data=None, format=None, content_type=None,
follow=False, **extra):
response = super().post(
path, data=data, format=format, content_type=content_type, **extra)
if follow:
response = self._handle_redirects(response, data=data, format=format, content_type=content_type, **extra)
return response
def patch(self, path, data=None, format=None, content_type=None,
follow=False, **extra):
response = super().patch(
path, data=data, format=format, content_type=content_type, **extra)
if follow:
response = self._handle_redirects(response, data=data, format=format, content_type=content_type, **extra)
return response
結論、こう改善しよう!
response = client.patch(
partial_update_url(general_user.id),
data=payload,
content_type="application/json",
)
参考
- https://stackoverflow.com/questions/39906956/patch-and-put-dont-work-as-expected-when-pytest-is-interacting-with-rest-framew
- https://www.django-rest-framework.org/api-guide/testing/#making-requests
- https://developer.mozilla.org/ja/docs/Web/HTTP/Basics_of_HTTP/MIME_types
- https://docs.djangoproject.com/en/3.0/topics/testing/tools/#django.test.Client.post