requests-mock で multipart/form-data をテストする

2020/05/03 8:11pm

Python の requests-mock でテストを書いているとき、multipart/form-data 形式のリクエストをテストしたくなるときがある。

multipart/form-data

multipart/form-data 形式のリクエストは requests で以下のようにして送ることができる。

url = 'http://httpbin.org/post'
files = {'file': ('settings.json', open('settings.json', 'rb'), 'application/json')}

r = requests.post(url, files=files)

このとき、期待するリクエストが送られていることをテストするために、requests-mock の Request History で上記のリクエストの body を調べてみる。

history = requests_mock.request_history

print(history[0].body)

送られているのは、以下のような MIME データ・ストリームだ(読みやすいように適宜改行を入れてある)。

--c93780ea776047cd945c189bad1d081c
Content-Disposition: form-data; name="settings"; filename="settings.json"
Content-Type: application/json

{"foo": 1, "bar": 2}
--c93780ea776047cd945c189bad1d081c
Content-Disposition: form-data; name="archive"; filename="archive.zip"
Content-Type: application/zip

...
--c93780ea776047cd945c189bad1d081c--

なんとなく、body は、

ことが分かると思う。もちろん、これをそのままテストするのは難しいので、もうすこし扱いやすいデータ構造に変換したいところだ。

cgi.parse_multipart()

multipart/form-data の解析は cgi モジュールに parse_multipart() という、そのまんまの関数が用意されている。この関数を使って、リクエストで送られたデータをテストするコードは以下のようになる。

import cgi
import json
from io import BytesIO
...

history = requests_mock.request_history

# Extract content type and its parameters.
c_type, c_data = cgi.parse_header(history[0].headers['Content-Type'])
assert c_type == 'multipart/form-data'

# Prior to Python 3.7, cgi.parse_multipart() works on bytes.
form_data = cgi.parse_multipart(BytesIO(history[0].body), {'boundary': c_data['boundary'].encode()})
settings = json.loads(form_data['settings'][0].decode('utf-8'))

assert form_data['archive'][0] == zip_content
assert settings['foo'] == 1
assert settings['bar'] == 2

注意: Python 3.7 からは str を受けつけるようになっているので、encode()/decode() による変換は不要なはず)

ただ、cgi.parse_multipart() では、残念なことに、

という欠点がある。

cgi.FieldStorage

cgi.parse_multipart() には上記の欠点があるので、より細かい制御が可能な cgi.FieldStorage を使うことをおすすめする。cgi.FieldStoragePython 3.7 以降の parse_multipart() の内部でも使われている

import cgi
import json
from io import BytesIO
...
history = requests_mock.request_history

fs = cgi.FieldStorage(
        fp=BytesIO(history[0].body),
        headers=history[0].headers,
        # FieldStorage works only for POST request!
        environ={'REQUEST_METHOD': 'POST'})

item = fs['settings']
settings = json.loads(item.value.decode('utf-8'))

assert item.filename == 'settings.json'
assert item.headers['Content-Type'] == 'application/json'
assert settings['foo'] == 1
assert settings['bar'] == 2

item = fs['archive']
assert item.filename == 'archive.zip'
assert item.headers['Content-Type'] == 'application/zip'
assert item.value == zip_content

environ={'REQUEST_METHOD': 'POST'} を渡さないといけないのが多少トリッキーだが、使い方はなんとなく分かるだろう。