読者です 読者をやめる 読者になる 読者になる

Pythonで、WebTestを使って、WSGIサーバを起動せずにWSGIアプリのテストをする

Python WSGI テスト

Pythonで、「WSGIサーバを起動せずにWSGIアプリをテストする」方法を探してみたところ、ライブラリWebTestがありました。
Pylons/webtest: Wraps any WSGI application and makes it easy to send test requests to that application, without starting up an HTTP server.

そこで、以下を参考にして、WebTestを使ったテストコードを書いてみました。

 
目次

 

環境

  • Mac OS X 10.11.6
  • Python 3.6.0
  • WebTest 2.0.27
    • 依存パッケージ
      • BeautifulSoup4 4.5.3
      • WebOb 1.7.2
  • pytest 3.0.7
    • テストランナー

 

Hello world的なWSGIアプリのテスト

Hello worldを表示するWSGIアプリを作成しました。

def application(environ, start_response):
    start_response('200 OK', [('Content-Type', 'text/plain')])
    return [b"Hello, world."]

 
次にテストコードを書きます。

WebTestではTestAppを使うことで、擬似的なHTTPリクエスト・レスポンスをテストできます。

GETのテストコードを書いてみたところ、テストをパスしました。

from webtest import TestApp
import simple_wsgi_app

class Test_simple_wsgi_app(object):
    def test_get(self):
        # TestAppにテスト対象のアプリケーションを渡す
        sut = TestApp(simple_wsgi_app.application)
        # getリクエストを送信
        actual = sut.get('/')

        # ステータスコード・content-type、ボディのテスト
        assert actual.status_code == 200
        assert actual.content_type == 'text/plain'
        assert actual.body == b'Hello, world.'

 

GETやPOSTでjinja2テンプレートを返すWSGIアプリのテスト

続いて、以前作成したWSGIアプリを使って、GETやPOSTのテストを書いてみます。bbs_wsgi_app.pyが今回使うアプリです。
wsgi_application-sample/bbs_wsgi_app.py

 
このアプリは、

  • WSGIフレームワークは使っていない
  • GETでjinja2テンプレートを返す
  • POSTでリダイレクトし、jinja2テンプレートに値を埋めて返す

というモノです。

import datetime
import cgi
import io
from jinja2 import Environment, FileSystemLoader

class Message(object):
    def __init__(self, title, handle, message):
        self.title = title
        self.handle = handle
        self.message = message
        self.created_at = datetime.datetime.now()


class MyWSGIApplication(object):
    def __init__(self):
        self.messages = []

    def __call__(self, environ, start_response):
        if environ['REQUEST_METHOD'].upper() == "POST":
            decoded = environ['wsgi.input'].read().decode('utf-8')
            header_body_list = decoded.split('\r\n')
            body = header_body_list[-1]
            encoded_body = body.encode('utf-8')
            with io.BytesIO(encoded_body) as bytes_body:
                fs = cgi.FieldStorage(
                    fp=bytes_body,
                    environ=environ,
                    keep_blank_values=True,
                )
            self.messages.append(Message(
                title=fs['title'].value,
                handle=fs['handle'].value,
                message=fs['message'].value,
            ))
            location = "{scheme}://{name}:{port}/".format(
                scheme = environ['wsgi.url_scheme'],
                name = environ['SERVER_NAME'],
                port = environ['SERVER_PORT'],
            )
            start_response(
                '301 Moved Permanently',
                [('Location', location), ('Content-Type', 'text/plain')])
            # 適当な値を返しておく
            return [b'1']

        else:
            jinja2_env = Environment(loader=FileSystemLoader('./templates', encoding='utf8'))
            template = jinja2_env.get_template('bbs.html')
            html = template.render({'messages': self.messages})
            start_response('200 OK', [('Content-Type', 'text/html')])
            return [html.encode('utf-8')]


app = MyWSGIApplication()

 

GETのテスト

TestAppを使ってGETのテストを書いてみます。

レスポンスボディの取得方法は2つあったため、それぞれ試してみます。

  • bodyで、バイト文字列のレスポンスボディを取得
  • textで、ユニコード文字列のレスポンスボディを取得

 

class Test_simple_wsgi_app(object):
    def test_get(self):
        """GETのテスト"""
        sut = TestApp(get_post_app.app)
        actual = sut.get('/')

        assert actual.status_code == 200
        assert actual.content_type == 'text/html'
        # bodyは、レスポンスボディをバイト文字列で取得
        assert 'テスト掲示板'.encode('utf-8') in actual.body
        # textは、レスポンスボディをユニコード文字列で取得
        assert 'テスト掲示板' in actual.text

 

直接POSTするテスト

続いて、直接POSTリクエストを送信するテストを書いてみます。

なお、このアプリではPOSTの後にリダイレクトしています。

リダイレクトに追随するには、follow()を使ってリダイレクト先のレスポンスを取得します。
follow(**kw) | webtest API — WebTest 2.0.28.dev0 documentation

def test_post(self):
    """直接POSTのテスト"""
    sut = TestApp(get_post_app.app)
    actual = sut.post(
        '/',
        {'title': 'ハム', 'handle': 'スパム', 'message': 'メッセージ'})

    assert actual.status_code == 301
    assert actual.content_type == 'text/plain'
    assert actual.location == 'http://localhost:80/'
    assert actual.body == b'1'

    # redirectの検証には、follow()を使う
    redirect_response = actual.follow()
    assert 'ハム' in redirect_response.text
    assert 'スパム さん' in redirect_response.text
    assert 'メッセージ' in redirect_response.text

 

フォームのsubmitを使ってPOSTするテスト

WebTestではフォームのsubmitボタンを押すこともできるため、

  • GETでフォームを取得
  • フォームに入力し、submitボタンを押してPOSTする

というテストも行えます。

def test_form_post(self):
    """GETして、formに入力し、submitボタンを押すテスト"""
    sut = TestApp(get_post_app.app)
    # 属性formを使って、フォームの中身をセット
    form = sut.get('/').form
    form['title'] = 'ハム'
    form['handle'] = 'スパム'
    form['message'] = 'メッセージ'
    # submit()を使って、フォームデータをPOST
    actual = form.submit()

    assert actual.status_code == 301
    assert actual.content_type == 'text/plain'
    assert actual.location == 'http://localhost:80/'
    assert actual.body == b'1'

    redirect_response = actual.follow()
    assert 'ハム' in redirect_response.text
    assert 'スパム さん' in redirect_response.text
    assert 'メッセージ' in redirect_response.text

 

BeautifulSoupを使ったPOSTの検証

WebTestではBeautifulSoupを使った値取得もできます。

 
BeautifulSoupを使ったテストコードを書いてみます。

def test_post_with_beautifulsoup(self):
    """BeautifulSoupを使って検証する"""
    sut = TestApp(get_post_app.app)
    response = sut.post(
        '/',
        {'title': 'ハム', 'handle': 'スパム', 'message': 'メッセージ'})
    redirect_respose = response.follow()

    # response.htmlで、BeautifulSoupオブジェクトを取得できる
    actual = redirect_respose.html

    title = actual.find('span', class_='title')
    # BeautifulSoupのget_text()で出力してみると、文字化けしていた
    print(title.get_text())  #=> ������������

    assert '「ハム」' == actual.find('span', class_='title').get_text()

get_text()の結果が文字化けしたことにより、テストは失敗しました。

 
試しにレスポンスの内容を出力してみたところ、

def test_print_respose_object(self):
    """レスポンスオブジェクトを表示してみる"""
    sut = TestApp(get_post_app.app)
    actual = sut.get('/')
    print(actual)
    assert False
    """
    Response: 200 OK
    Content-Type: text/html
    <html>
        <head>
            <meta charset="UTF-8">
            <title>���������������������</title>
        </head>
        <body>
            <h1>������������������</h1>
            <form action="/" method="POST">
                ��������������� <input type="text" name="title" size="60"><br>
                ������������������ <input type="text" name="handle"><br>
                <textarea name="message" rows="4" cols="60"></textarea>
                <input type="submit">
            </form>
            <hr>
        </body>
    </html>
    """

日本語を表示する部分が文字化けしていました。

これが原因のようですが、今回は深く追求しません。

 

Bottleアプリのテスト

今まではWebフレームワークを使わないWSGIアプリをテストしました。

今度は、WebフレームワークであるBottleを使ってWSGIアプリを作成し、WebTestを使ってテストコードを書いてみます。

Bottleの公式ページにも、WebTestを使ってテストを書いている例がありました。
FUNCTIONAL TESTING BOTTLE APPLICATIONS | Recipes — Bottle 0.13-dev documentation

from bottle import Bottle, get, post, run, request, HTTPResponse
from bottle import TEMPLATE_PATH, jinja2_template
import datetime
import json


class Message(object):
    """Bottleのjinja2テンプレートへ値を引き渡すためのクラス"""
    def __init__(self, title, handle, message):
        self.title = title
        self.handle = handle
        self.message = message
        self.created_at = datetime.datetime.now()

# テストコードで扱えるよう、変数appにインスタンスをセット
app = Bottle()

@app.get('/')
def get_top():
    return jinja2_template('bbs', message=None)

@app.post('/')
def post_top():
    print(request.forms.get('handle'))
    message = Message(
        title=request.forms.get('title'),
        handle=request.forms.get('handle'),
        message=request.forms.get('message'),
    )
    return jinja2_template('bbs', message=message)

@app.post('/json')
def post_json():
    json_body = request.json
    print(json_body)

    body = json.dumps({
        'title': json_body.get('title'),
        'message': json_body.get('message'),
        'remarks': '備考'})
    r = HTTPResponse(status=200, body=body)
    r.set_header('Content-Type', 'application/json')
    return r


if __name__ == "__main__":
    run(app, host="localhost", port=8080, debug=True, reloader=True)

 
また、HTMLテンプレートとしてjinja2を使っています。

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>テストタイトル</title>
    </head>
    <body>
        <h1>テスト掲示板</h1>
        <form action="/" method="POST">
            タイトル: <input type="text" name="title" size="60"><br>
            ハンドル名: <input type="text" name="handle"><br>
            <textarea name="message" rows="4" cols="60"></textarea><br>
            <input type="submit">
        </form>
        <hr>

        {% if message %}
            <p>
                <span class="title">「{{ message.title }}」</span>&nbsp;&nbsp;
                <span class="handle">{{ message.handle }} さん</span>&nbsp;&nbsp
                <span class="created_at">{{ message.created_at }}</span>
            </p>
            <p class="message">{{ message.message }}</p>
            <hr>
        {% endif %}
    </body>
</html>

 

フォームからPOSTするテスト(だが、日本語はNG)

GETでフォームを取得し、フォームにデータをセットして、submitボタンでデータをPOSTするテストを作成します。

class Test_bottle_app(object):
    @pytest.mark.xfail
    def test_form_submit(self):
        """GETして、formに入力し、submitボタンを押すテスト"""
        sut = TestApp(bottle_app.app)
        response = sut.get('/')
        form = response.form
        form['title'] = u'ハム'.encode('utf-8').decode('utf-8')
        form['handle'] = b'\xe3\x81\x82' #あ
        form['message'] = 'メッセージ'
        actual = form.submit()

        assert actual.status_code == 200
        assert actual.content_type == 'text/html'

        assert 'ハム' in actual.text
        assert 'スパム さん' in actual.text
        assert 'メッセージ' in actual.text

テストを実行してみたところ、エラーとなりました。

submit()の結果を取得してみたところ、

...
actual = form.submit()

# submitの結果を出力
print(actual)
        actual = form.submit()
        # https://github.com/Pylons/webtest/issues/150
        print(actual)

 
日本語の部分が文字化けしていました。

<p>
    <span class="title">「ãã 」</span>&nbsp;&nbsp;
    <span class="handle">ã さん</span>&nbsp;&nbsp
    <span class="created_at">2017-03-26 10:58:14.466710</span>
</p>
<p class="message">ã¡ãã»ã¼ã¸</p>

 
フォーム経由でPOSTするのが良くないのかなと考え、直接POSTするコードも書いてみましたが、結果は変わらずでした。

また、以下の方法も試してみましたが、結局文字化けは直りませんでした。
MTG-Guild 開発日誌: webtestで日本語を使うときの注意点

そんな中、GitHubのissueを見たところ、それらしいものがありました。
Form POST with unicode values is not encoded properly · Issue #150 · Pylons/webtest

2016年から進展がないこともあり、これ以上追求するのはやめました。

ただ、実用上これでは困るため、自分のコードの誤りや回避策があれば知りたいです。

 

JSONをPOSTするテスト

続いてJSONをPOSTするテストを書いてみます。

フォームのときと同じだろうかと心配しましたが、最近クローズされたissueにてJSONに関する修正が入っていました。
Decoding issue for non-ASCII characters in JSON response · Issue #177 · Pylons/webtest

そこで、どうなるのかを試してみました。

 

def test_post_json(self):
    sut = TestApp(bottle_app.app)
    actual = sut.post_json('/json', dict(title='タイトル', message='メッセージ'))

    assert actual.status_code == 200
    assert actual.content_type == 'application/json'

    assert actual.json.get('title') == 'タイトル'
    assert actual.json.get('message') == 'メッセージ'
    assert actual.json.get('remarks') == '備考'

実行したところ、テストはパスしました。

JSONの場合は、非ASCII文字列であってもうまく動作するようです。

 

ソースコード

GitHubにあげました。
thinkAmi-sandbox/wsgi_webtest-sample

Python + pytestにて、pytest.raisesを使って例外をアサーションする時の注意点

Python pytest

Python + pytestにて、「pytest.raisesを使って例外をアサーションする」テストコードを作成する機会がありました。

ただ、書き方を誤りうまくアサーションできなかっため、メモを残します。

 
目次

 

環境

  • Python 3.6.0
    • unittest.mock.patchを使用
  • pytest 3.0.7

 

状況

テスト対象のメソッドtarget_method()は、以下とします。

pytest_raises.py

from with_statement_library import Validator

class Target(object):
    def target_method(self):
        """テスト対象のメソッド"""
        validator = Validator()
        validator.run()

 
テスト対象クラスでimportしているValicationクラスは、(今回省略していますが)長い行に渡って検証処理があり、エラーがあったら最後に例外を送出しています。

pytest_raises_library.py

class Validator(object):
    def run(self):
        """長い行検証処理をしていて、エラーがあったら例外を送出するメソッド"""
        raise RuntimeError

 
今回のテストでは、run()メソッドをテストするためのデータを用意するのが難しいと仮定して、Validator.run()をモックに差し替える方法を取ります。

 
モックを使ったテストコードとして

from unittest.mock import patch, MagicMock
import pytest
from with_statement import Target

class Test_Target(object):
    def test_mock_patch(self):
        mock_run = MagicMock()
        with patch('with_statement.Validator.run', mock_run):
            sut = Target()
            actual = sut.target_method()
            # モックが呼ばれている回数は1回か
            assert mock_run.call_count == 1

としたところ、テストをパスしました。

 
続いて、例外を送出するよう、patchとpytest.raisesを使って

    def test_mistake_usage_pytest_raises(self):
        mock_run = MagicMock(side_effect=AssertionError)
        # with文にpytest.raisesを追加
        with patch('with_statement.Validator.run', mock_run), \
                pytest.raises(AssertionError):
            sut = Target()
            actual = sut.target_method()
            assert mock_run.call_count == 1

としたところ、これもテストをパスしました。

 
念のため、失敗するテストとして

    def test_mistake_usage_pytest_raises_but_test_pass(self):
        mock_run = MagicMock(side_effect=AssertionError)
        with patch('with_statement.Validator.run', mock_run), \
                pytest.raises(AssertionError):
            sut = Target()
            actual = sut.target_method()
            # 呼ばれた回数を2回としてアサーションする
            # 実際に呼ばれるのは1回なので、テストは失敗するはず
            assert mock_run.call_count == 2

と書いてみたところ、このテストがパスしてしまいました。

 

原因

pytestの公式ドキュメント(英語版)に記載がありました*1

When using pytest.raises as a context manager, it’s worthwhile to note that normal context manager rules apply and that the exception raised must be the final line in the scope of the context manager. Lines of code after that, within the scope of the context manager will not be executed.

Helpers for assertions about Exceptions/Warnings raises | Pytest API and builtin fixtures — pytest documentation 

例外が発生したメソッド以降の処理は、実行されないようでした。

 
今回の場合、

with patch('with_statement.Validator.run', mock_run), \
        pytest.raises(AssertionError):
    sut = Target()

    # ここで例外が発生
    actual = sut.target_method()
    # このassertは処理されないため、テストがパスする
    assert mock_run.call_count == 2

のため、テストがパスしたと考えられました。

 

対応

with文をネストして、モック用とpytest.raises用に分けます。

def test_correct_usage_pytest_raises_and_test_fail(self):
    mock_run = MagicMock(side_effect=AssertionError)
    # モック用のwith文
    with patch('with_statement.Validator.run', mock_run):

        # pytest.raisesのwith文は別途用意
        with pytest.raises(AssertionError):
            sut = Target()
            # この中の最後に、例外を送出するメソッドを書く
            actual = sut.target_method()

        # 検証は、インデントを一つ上げて書く
        assert mock_run.call_count == 2

 
テストを実行したところ、正しく失敗しました。

$ pytest
==== test session starts ====
platform darwin -- Python 3.6.0, pytest-3.0.6, py-1.4.32, pluggy-0.4.0
rootdir: /Users/kamijoshinya/thinkami/try/pytest_sample, inifile: pytest.ini
collected 4 items

test_pytest_raises.py ...F

==== FAILURES ====
____ Test_Target.test_correct_usage_pytest_raises_and_test_fail ____
...
>           assert mock_run.call_count == 2
E           assert 1 == 2
E            +  where 1 = <MagicMock id='4422285968'>.call_count

test_pytest_raises.py:58: AssertionError

 

ソースコード

GitHubにあげました。e.g._pytest_raisesディレクトリが今回のものです。 thinkAmi-sandbox/python_mock-sample

*1:対象バージョンが古いためか、日本語版には記載がありません

#stapy #glnagano みんなのPython勉強会 in 長野#1に参加しました

イベント

3/18にギークラボ長野で開かれた「みんなのPython勉強会 in 長野 #1」に参加しました。
みんなのPython勉強会 in 長野 #1 - connpass

資料も上記ページにまとまっています。

会場が参加者で埋まり、さらに各プレゼンでも質問のやり取りが行われるなど、熱気あふれる会場でした。

以下、感想とメモです。

 

オープニング

勉強会のオープニングとして

  • StartPythonClubとギークラボ長野の関わり(中島祐樹さん)
  • 自己紹介タイム
  • 長野で語るStapyのビジョン(阿久津剛史さん (Start Python Club))

がありました*1

プログラマでもPython勉強会を主催しようと考え、さらにコミュニティを拡大していったのは凄い行動力だと感じました。

 

もうPythonを始めるしかない!

辻真吾さん

Pythonとそのまわりの環境についてのプレゼンでした。

Pythonの標準モジュールが充実しているのはありがたいと実感しています。

最近ユニットテストまわりをさわっていますが、ユニットテストライブラリが標準モジュールとして提供されているのには驚きました。また、Python3からはユニットテスト用のモックモジュールも標準に加わっているのが良いです。

 
また、「他言語からの乗り換えなら、翻訳は良いトレーニング」については、自分もJavaの書籍をPythonに書き直して学んだため、確かにそのとおりだと感じました。

 

DjangoでさくっとWebアプリケーション開発をする話

中澤祐一さん

PyCon JP 2014のアップデート版ということで、Djangoの基本からBootstrap、Herokuへのデプロイまでがまとまっている内容でした。

PyCon JP 2014版は色々と参考になったので、今回もあとで読み返してみようと思います。

 

ディープラーニングのハンズオンを準備して学んだこと色々

さとうきよしさん(ジーワークス)

PaintsChainerやStackGANの紹介から始まり、ディープラーニングに関する話題からDocker + Jupyter・NumPy・Sympyまで、いろいろなライブラリの紹介も合わせて行われました。

特に、NumPyの実際のコード例を見ることができて、便利さが伝わってきました。

 
また、長野ディープラーニングハンズオンのイベント告知も行われ、5/20, 27の2日間で開催するとのことです。
長野ディープラーニングハンズオン - connpass

 

オープンハードカンファレンスの紹介

知野雄二さん

4/22(土)に行われる、オープンハードカンファレンスの紹介がありました。
オープンハードカンファレンス2017 Nagano – OSHWC

オープンハードカンファレンスTシャツの秘密とかの紹介もありました。

 

とあるプログラミング初心者の学習記録

sizumitaさん

文部科学省プログラミンでプログラミングを始め、Raspberry PiMinecraftPythonにさわり、今はDjangoをさわっている中学生エンジニアのプレゼンでした。

学習記録の内容は、

  • GitHubアカウントを作成
  • TeraTailで質問
  • PyCharmを使って開発
  • 見本のアプリを拡張
  • メソッドコメントも書く

と、かなり本格的なものでした。

Djangoの公式チュートリアルDjango入門の次として、自分は「Two Scoops of Django」や、Packt publishingの本*2のなどの洋書に進みましたが、確かに英語ではハードルが高いと感じました。

 
また、自分も初心者の気持ちを忘れずにいろいろとやらないといけないなと改めて思えたプレゼンでした。

 

PythonでつくるSlack Bot

Akira Nonakaさん

XoxzoのAPIを使い、Slackで入力した文字を音声化して電話をかけるというデモを中心としたプレゼンでした。

また、大多数が別の国にいて、リモートワークで働いているというところも印象に残りました*3

 

Jupyterのデモ

阿久津剛史さん(Start Python Club)

さとうさんのプレゼンで紹介のあったJupyterについて、実際にデモを見せていただきました。

デモを見ると便利さが伝わってきました。

 
その後クロージングに移り、本編が終わりました。

 

懇親会

引き続きギークラボ長野にて懇親会がありました。

自分は「みんなのPython勉強会リモート中継」や「GCPUG信州 キックオフ勉強会」で知り合った方々と話し込んでいました。

 

その他

今回、受付係をしました。

前回担当した時の反省をいかそうとしたのですが、結局はバタバタしてしまいました。参加費の受取と管理のノウハウを知りたいところです。

 
最後になりましたが、企画・運営・参加をされたみなさま、ありがとうございました。

*1:このあたりは受付などでバタバタしていたため、詳細な内容は他の方に譲ります

*2:例えば、「Django by Example」等

*3:今思えば、リモートワークのコツなどを教わっておけばよかったです…

Pythonで、モックに差し替えたメソッドが呼ばれた回数や呼ばれた時の引数を検証する

Python mock テスト

Pythonにて、「モックに差し替えたメソッドが呼ばれた回数や呼ばれた時の引数を検証する」テストコードを作成する機会があったため、メモを残します。

目次

 

環境

  • Python 3.6.0
    • モックは、unittest.mock.MagicMock
  • pytest 3.0.6
    • テストランナーとして使用

 

状況

複雑な処理はするけど戻り値を返さないテスト対象メソッドTarget.target_method()があります。

called_count.py

import called_count_library

class Target(object):
    def target_method(self):
        c = called_count_library.Complex()
        c.set_complex('ham')
        c.set_complex('spam')
        c.set_complex('egg')
        c.set_complex('egg')
        c.set_complex_dict('hoge', {'fuga': 'piyo', 'くだもの': 'りんご'})
        c.set_complex_with_keyword('foo', str_arg='bar', dict_arg={'baz': 'qux', 'quux': 'foobar'} )

 
importして使っているcalled_count_library.pyの各メソッドも、複雑な処理をしている上に戻り値を返さないものでした。

なお、called_count_library.pyは十分にテストされているものとします。

called_count_library.py

class Complex(object):
    def set_complex(self, key):
        # 複雑な処理だけど、戻り値を返さない
        pass

    def set_complex_dict(self, key, dict):
        # 複雑な処理だけど、戻り値を返さない
        pass

    def set_complex_with_keyword(self, no_keyword, str_arg, dict_arg):
        # 複雑な処理だけど、戻り値を返さない
        pass

    def uncall_method(self):
        # 呼ばれないメソッド
        pass

 

対応

called_count.Target.target_method()のテストを書きます。

importしているcalled_count_library.pyは十分にテストされているため、今回は

  • called_count_library.Complexをモックに差し替え
  • モックを使って、メソッド(set_complexなど)が何回呼ばれたかを検証
  • モックを使って、メソッドを呼び出した時の引数がどんなものだったかを検証
  • モックを使って、呼んでいないメソッドを検証

としました。

 
検証方法については、unittest.mock.Mockunittest.mock.MagicMockには検証用のメソッドや属性があるため、それを利用してテストします。

テスト対象のメソッド呼び出しまではこんな感じです。unittest.mock.patch()を使ってComplexクラスを差し替えます。  

from unittest.mock import patch, MagicMock, call
from called_count import Target

class Test_Target(object):
    def test_valid(self):
        mock_lib = MagicMock()
        with patch('called_count_library.Complex', return_value=mock_lib):
            sut = Target()
            sut.target_method()

 

メソッドが呼ばれた回数を検証

<モックオブジェクト>.<検証対象のメソッド>.<検証メソッド>で、対象クラスのメソッドを検証します。

検証メソッド・属性として

  • called
    • 呼ばれたか
  • assert_called()
    • 呼ばれたか
  • assert_called_once()
    • 1回だけ呼ばれたか
  • call_count
    • 呼ばれた回数

があります。

# 複数回呼んでるメソッドの確認
## set_complex()が呼ばれたか
assert mock_lib.set_complex.called is True

## set_complex()が1回でも呼ばれたか
mock_lib.set_complex.assert_called()

## set_complex()を呼んだ回数は4回か
assert mock_lib.set_complex.call_count == 4


# 1回だけ呼んでるメソッドの確認
## set_complex_dict()が1回呼ばれたか
mock_lib.set_complex_dict.assert_called_once()

## set_complex_dict()を呼んだ回数は1回か
assert mock_lib.set_complex_dict.call_count == 1

 

メソッドが呼ばれた時の引数を検証

<モックオブジェクト>.<検証対象のメソッド>.<検証用属性>で、対象クラスのメソッドを検証します。

検証用の属性として

  • call_args
    • 最後に呼ばれた時の引数を取得
  • call_args_list
    • 呼ばれた順に、引数をリストとして取得
    • callオブジェクトがリストになっているため、要素を指定してアンパックすることで、中身をタプルとして得られる

があります。

# call_argsの例
## 引数が1個の場合
args, kwargs = mock_lib.set_complex.call_args
assert args[0] == 'egg'
assert kwargs == {}

## 引数が複数の場合
multi_args, multi_kwargs = mock_lib.set_complex_dict.call_args
assert multi_args[0] == 'hoge'
assert multi_args[1] == {'fuga': 'piyo', 'くだもの': 'りんご'}
assert multi_kwargs == {}

## 名前付き引数を使っている場合
exist_args, exist_kwargs = mock_lib.set_complex_with_keyword.call_args
assert exist_args[0] == 'foo'
assert exist_kwargs == {'str_arg': 'bar', 'dict_arg': {'baz': 'qux', 'quux': 'foobar'}}


# call_args_listの例
list_args = mock_lib.set_complex.call_args_list
assert list_args == [call('ham'), call('spam'), call('egg'), call('egg')]
## callの中身はアンパックで取得
unpack_args, unpack_kwargs = list_args[0]
assert unpack_args == ('ham', )
assert unpack_kwargs == {}

 

メソッドが呼ばれた回数と引数を同時に検証

<モックオブジェクト>.<検証対象のメソッド>.<検証メソッド>で、対象クラスのメソッドを検証します。

検証メソッドとして

  • assert_called_with()
    • 最後に呼ばれた時の引数が一致するか
  • assert_called_once_with()
    • 指定した引数で1回だけ呼ばれたか
  • assert_any_call()
    • 指定した引数の呼び出しがあったか
  • assert_has_calls()
    • 順番通りに呼ばれたか
    • 順番通りでなくてもよいが、どの引数も呼ばれたか (any_order=True)

があります。

# 最後に呼んだ時の引数は'egg'か
mock_lib.set_complex.assert_called_with('egg')
# 引数'ham'は一番最初で呼んでいるため、以下の書き方ではテストが失敗する
# mock_lib.set_complex.assert_called_with('ham')

# 引数'hoge', {'fuga': 'piyo', 'くだもの': 'りんご'}で、1回だけ呼ばれたか
mock_lib.set_complex_dict.assert_called_once_with('hoge', {'fuga': 'piyo', 'くだもの': 'りんご'})

# 引数'ham'や'spam'で呼ばれたか
mock_lib.set_complex.assert_any_call('ham')
mock_lib.set_complex.assert_any_call('spam')

# 引数が'ham' > 'spam' > 'egg' > 'egg' の順で呼ばれたか
mock_lib.set_complex.assert_has_calls([call('ham'), call('spam'), call('egg'), call('egg')])
# ちなみに、同じ引数がある場合、片方を省略してもPASSした
mock_lib.set_complex.assert_has_calls([call('ham'), call('spam'), call('egg')])
# 順番は気にしないけど、どの引数でも呼ばれたか
mock_lib.set_complex.assert_has_calls([call('spam'), call('egg'), call('ham')], any_order=True)

 

メソッドが呼ばれていないことを検証

<モックオブジェクト>.<検証対象のメソッド>.<検証メソッド>で、対象クラスのメソッドを検証します。

検証メソッドはassert_not_calledです。

# uncall()は1回も呼ばれていないか
mock_lib.uncall_method.assert_not_called()

 

参考

 

ソースコード

GitHubに上げました。e.g._called_countディレクトリが今回のものです。
thinkAmi-sandbox/python_mock-sample

Pytnonで、unittest.mock.patch.objectのautospecとside_effectを使って、テスト対象の属性(self.attr)を更新する

Python mock テスト

Pythonにて、「メソッドを差し替え、テスト対象オブジェクトの属性を更新する」テストコードを作成する機会があったため、メモを残します。

なお、良いタイトルが思い浮かびませんでしたので、mock.object(autospect=True)のサンプルとして考えてください…

 
目次

 

環境

  • Python 3.6.0
    • unittest.mock.patch.objectを使用
  • pytest 3.0.6
    • テストランナーとして使用

 

状況

こんなテスト対象コードがありました。

class Target(object):
    def target_method(self):
        self.can_print = False
        # この中でself.can_printを更新しているが、戻り値は何もない
        self.validate()
        if self.can_print:
            return 'OK'
        return 'NG'

    def validate(self):
        is_ok = False
        # 複雑な処理の結果、is_okの値を変えている
        if is_ok:
            self.can_print = True

 
このコードの対してテストを書きますが、

  • 属性self.can_printは、メソッド内で初期化しているため、外部からデータを与えられない
  • validate()メソッドは、内部でデータベースなどで複雑な処理をしているため、is_ok=Trueとなるデータを用意できない

のため、validate()メソッドをモックに差し替えたいと考えています。

 
そこで、

class Test_Target(object):
    def test_can_not_patch(self):
        def validate_mock(self):
            self.can_print = True

        with patch.object(Target, 'validate', side_effect=validate_mock):
            sut = Target()
            actual = sut.target_method()
            assert actual == 'OK'

と、patch.object()の引数side_effectを使って、validateメソッドをvalidate_mockメソッドに差し替えようと考えました。

しかし、これではvalidate_mock()の引数の数が合わず、テストを実行するとエラーになります。

# > ret_val = effect(*args, **kwargs)
# E TypeError: validate_mock() missing 1 required positional argument: 'self'

 

対応

unittest.mock.patch.object()の引数autospecを使います。

autospecは、  

autospec は mock の API を元のオブジェクト (spec) に制限しますが、再帰的に適用される (lazy に実装されている) ので、 mock の属性も spec の属性と同じ API だけを持つようになります。さらに、 mock された関数/メソッドは元と同じシグネチャを持ち、正しくない引数で呼び出されると TypeError を発生させます。

(中略)

patch() か patch.object() に autospec=True を渡すか、 create_autospec() 関数を使って spec をもとに mock を作ることができます。 patch() の引数に autospec=True を渡した場合、置換対象のオブジェクトが spec オブジェクトとして利用されます。 spec は遅延処理される (mock の属性にアクセスされた時に spec が生成される) ので、非常に複雑だったり深くネストしたオブジェクト (例えばモジュールをインポートするモジュールをインポートするモジュール) に対しても大きなパフォーマンスの問題なしに autospec を使うことができます。

26.5.5.8. autospec を使う | 26.5. unittest.mock — モックオブジェクトライブラリ — Python 3.6.0 ドキュメント

とある通り、autospec=Trueとすることで、mockの属性と対象オブジェクト(Target.validate())の属性が一致します。

 
これで引数selfが使えるため、オブジェクトの属性self.can_printを更新できます。

class Test_Target(object):
    def test_can_patch(self):
        def validate_mock(self):
            self.can_print = True

        with patch.object(Target, 'validate', autospec=True, side_effect=validate_mock):
            sut = Target()
            actual = sut.target_method()
            assert actual == 'OK'

 
テストもpassしました。

 

ソースコード

GitHubにあげました。e.g._set_self_attrディレクトリが今回のものです。
thinkAmi-sandbox/python_mock-sample

Dockerで、Alpine3.4 + Apache2.4.25 + Python3.6.0の環境を作って、CGIを動かしてみた

Python Docker Apache

以前、httpd:alpineのApacheを使ってみました。
Docker for Macにて、httpd:alpineのApacheを使ってみた - メモ的な思考的な

今回は、DockerでAlpine3.4 + Apache2.4.25 + Python3.6.0の環境を作って、CGIPythonスクリプトを動かしてみます。

 
目次

 

環境

 

Dockerfile作成

当初、ApacheとPython3.6のDockerfileをマージしようかと考えました。

ただ、両者のDockerfileを比べると、

と、alpineのバージョンが異なっていました。試しに、PythonのDockerfileをalpine3.5としてコピペして作ってみましたが、うまくいきませんでした。

そこで今回は、Python3.6のalpine版のイメージ + ApacheをセットアップするDockerfileを作成することにしました。

Apacheのalpine3.4版を探したところ、このあたりがApache2.4.25 + alpine3.4の組み合わせでしたので、これを流用します。
https://github.com/docker-library/httpd/blob/0e4a0b59e1f4e2a5a14ca197516beb2d4df1ffb8/2.4/alpine/Dockerfile

 
追加箇所としては、

です。

それを反映させたDockerfileは以下です。

FROM python:3.6.0-alpine

# ここにhttpd:2.4.25-alpineの内容をコピー(記載は省略)

COPY httpd-foreground /usr/local/bin/

RUN ["chmod", "+x", "/usr/local/bin/httpd-foreground"]

COPY httpd.conf /usr/local/apache2/conf/

COPY hello.py /usr/local/apache2/cgi-bin/
RUN ["chmod", "755", "/usr/local/apache2/cgi-bin/hello.py"]

EXPOSE 80

CMD httpd-foreground

 

Apacheの設定

デフォルトの設定を確認

alpine3.4に設定されているApacheの状態が分からなかったため、以下を参考に、一度DockerでApacheを起動して確認します。

# ビルド
$ docker build -t alpine:python360_httpd2425_cgi .
...
Successfully built b49daed5ae56

# 起動
$ docker run -p 8081:80 --name temp alpine:python360_httpd2425_cgi

# Apacheでloadされているモジュールを確認
$ docker exec -it `docker ps | grep temp | awk '{print $1}'` /bin/bash
bash-4.3# httpd -M
Loaded Modules:
 core_module (static)
 so_module (static)
 http_module (static)
 mpm_event_module (static)
 authn_file_module (shared)
 authn_core_module (shared)
 authz_host_module (shared)
 authz_groupfile_module (shared)
 authz_user_module (shared)
 authz_core_module (shared)
 access_compat_module (shared)
 auth_basic_module (shared)
 reqtimeout_module (shared)
 filter_module (shared)
 mime_module (shared)
 log_config_module (shared)
 env_module (shared)
 headers_module (shared)
 setenvif_module (shared)
 version_module (shared)
 unixd_module (shared)
 status_module (shared)
 autoindex_module (shared)
 cgid_module (shared)
 dir_module (shared)
 alias_module (shared)


# 別のコンソールを起動して、Dockerを停止
$ docker stop temp
temp

# ローカルでhttpd.confを確認するため、
# tempコンテナからhttpd.confをローカルの任意のディレクトリ(ここではdir)へコピー
$ docker cp temp:/usr/local/apache2/conf/httpd.conf /path/to/dir/httpd.conf

 
httpd.confを読むと、コメントアウトされているものの#Include conf/extra/httpd-mpm.confとの記載があっため、そちらもローカルへコピーして内容を確認します。

$ docker cp 4ee9667e6b7e:/usr/local/apache2/conf/extra/httpd-mpm.conf /path/to/dir/httpd-mpm.conf

 

必要最低限の設定へと変更

httpd.confhttpd -Mを見ると、いくつか不要そうなのがあったため、ロードするモジュールを以下に変更します。

 
上記を反映したhttpd.confは以下です。

# ServerRoot: The top of the directory tree
ServerRoot "/usr/local/apache2"

# Listen: Allows you to bind Apache to specific IP addresses and/or ports
Listen 80

# LoadModule
LoadModule authz_core_module modules/mod_authz_core.so
LoadModule authz_host_module modules/mod_authz_host.so
LoadModule mime_module modules/mod_mime.so
LoadModule log_config_module modules/mod_log_config.so
LoadModule env_module modules/mod_env.so
LoadModule setenvif_module modules/mod_setenvif.so
LoadModule unixd_module modules/mod_unixd.so
LoadModule status_module modules/mod_status.so
LoadModule cgid_module modules/mod_cgid.so

# CGIをAliasで動かすので使用
LoadModule alias_module modules/mod_alias.so


# unixd_module settings
User daemon
Group daemon

# 'Main' server configuration

# ServerAdmin: Your address, where problems with the server should be e-mailed.
ServerAdmin you@example.com

# Deny access to the entirety of your server's filesystem.
<Directory />
    AllowOverride none
    Require all denied
</Directory>

# DocumentRoot
DocumentRoot "/usr/local/apache2/htdocs"
<Directory "/usr/local/apache2/htdocs">
    Options Indexes FollowSymLinks
    AllowOverride None
    Require all granted
</Directory>

# The following lines prevent .htaccess and .htpasswd files
<Files ".ht*">
    Require all denied
</Files>

# Log settings
ErrorLog /proc/self/fd/2
LogLevel warn


# log_config_module settings
LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" combined
LogFormat "%h %l %u %t \"%r\" %>s %b" common
CustomLog /proc/self/fd/1 common

# alias_module settings
ScriptAlias /cgi-bin/ "/usr/local/apache2/cgi-bin/"

# CGI directory
<Directory "/usr/local/apache2/cgi-bin">
    AllowOverride None
    Options ExecCGI
    SetHandler cgi-script
    Require all granted
</Directory>


# mime_module settings
TypesConfig conf/mime.types
AddType application/x-compress .Z
AddType application/x-gzip .gz .tgz

 

Pythonスクリプトの作成

Hello worldスクリプトを作成します。

CGIで動かすため、先頭のshebangも必要です。そのため、shebangに指定するPythonを調べてみました。

# コンテナに入る
$ docker exec -it `docker ps | grep temp | awk '{print $1}'` /bin/bash

# pythonのありかを調べる
bash-4.3# which python
/usr/local/bin/python

 
これを元に、Pythonスクリプトを作成します。

#!/usr/local/bin/python

# デバッグのために使用
import cgitb
cgitb.enable()

print('Content-Type: text/plain;charset=utf-8\n')
print('Hello World!')

 

Dockerを起動して確認

設定ファイルを色々修正したため、Dockerイメージを作り直して動作を確認します。

# コンテナの全削除
docker rm $(docker ps -a -q)

# イメージIDの確認
$ docker images
REPOSITORY          TAG                        IMAGE ID            CREATED             SIZE
alpine              python360_httpd2425_cgi    b49daed5ae56        About an hour ago   170 MB

# イメージの削除
$ docker rmi b49daed5ae56

# イメージの再作成
$ docker build -t alpine:python360_httpd2425_cgi .

# 起動
$ docker run -p 8081:80 --name temp2 alpine:python360_httpd2425_cgi

# 別のコンソールからアクセス
$ curl http://localhost:8081/cgi-bin/hello.py
Hello World!

動作しているようです。

 

ソースコード

GitHubに上げました。alpine_apache_python36_cgiディレクトリの中が、今回のものです。
thinkAmi-sandbox/Docker_Apache-sample

#jawsdays JAWS DAYS 2017に参加しました

イベント

3/11にJAWS DAYS 2017へ参加しました。
JAWS DAYS 2017

会場はTOC五反田メッセ(東京)でした。
TOC GOTANDA MESSE

 
会社ではAWSを使っているのですが、ほとんどお任せな状態です。ただ、今後のことを考えると、実際にAWSを触ったり色々と作りたいと考えていました。

そんな中、複数の同僚よりJAWS DAYS 2017をおすすめされました。これはググるキーワードを知るチャンスと思い、初参加を決めました。

今回はアカウントのセキュリティについてきちんと理解したいと考え、セキュリティセッションを中心に回りました。

以下、メモと感想です。

 
目次

 

不安で夜眠れないAWSアカウント管理者に送る処方箋という名のハンズオン

角山恵介さん

セキュリティについて、手を動かして理解するのが一番だろうと思い参加しました。

アカウントの作成からIAMのロールまわりまでのハンズオンでした。

そもそもIAMとはというところから解説があったのがありがたかったです。

また、ハンズオンの資料も詳しかったため、あとで復習する時の良い資料になりそうです。

 

AWS SECURITY DEATH \m/ ~セキュ鮫様からのお告げ~ by Security-JAWS

大喜多利哉さん、吉江瞬さん、森永大志さん (資料)

AWSのセキュリティについて、ネットワーク・WAF・AWS Configなど、全般的な内容のセッションでした。

以下、あとで調べるためのメモです。

  • ネットワークルーティング
    • セキュリティグループ(ステートフル)
    • ネットワークACL(ステートレス)
  • Direct Connect
  • AWS Shield
    • CloudFrontでShieldオプションを有効にするだけで使える
  • AWS WAF
    • CloudFrontを使っているならば、導入は簡単
    • 現時点では、機能が物足りない
      • POST通信のログが見れないなど
  • AWS Config
  • AWS Config Rules
    • マネージドルール
    • カスタムルール(例はAWS labのGitHubにある)

 

ランチタイムセッション

ありがたいことにお昼にお弁当とお茶が配布されました。そのため、お弁当をいただきながら聞いたセッションでした。

株式会社はてな

Mackerelの紹介とデモがありました。

名前は聞いたことがあったものの、どんなふうに使えばよいのかわからなかったため、デモを見ることができて良かったです。

デモでは、AWSと連携して簡単にセットアップしていました。

フリープランもあるようなので、まずは個人的に試してみるのが良いのかなと思いました。

 

アールスリーインスティテュート

SIを主業務としながら、リモートワークと社員のコミュニティ参加を支援している会社の紹介でした。

花粉のツライこの時期、花粉のなさそうな土地でリモートワークできそうなのは良さそうと感じました。

 

Datadog Inc.

こちらも名前は聞いたことがあったため、どんなものかをデモで見ることができて良かったです。

AWSのログなどを様々な角度から簡単にダッシュボード化していて、パッと見で分かりやすかったです。

 

EC2の管理で学ぶ、AWS Lambda入門

廣山豊さん

廣山のお題を元に、自分で手を動かしていろいろと構築するハンズオンでした。

普段Pythonを書いていることもあり、LambdaのBluePrintのコードも何をしているのか分かりやすくて良かったです。

また、LambdaのPythonでは、Boto3というSDKを使えばよいというのも分かりました。

 

DevOpsとか言う前にAWSエンジニアが知るべきアプリケーションのこと

照井将士さん(資料)

普段Webアプリ開発をしていることもあり、AWSを触るときにはどんな点を知っておくべきなのかを聞きたくて参加しました。

メインはデータベースまわりのお話で、デフォルト値からアプリにあった特性の設定へと変更するのが大事ということが分かりました。

また、オンプレ・クラウドにかかわらず、まずは基礎的なところをしっかりとやって、その後クラウドならではの考え方をするのが大事だという印象を受けました。

 

IAM 権限をこえて

大喜多利哉さん、大竹孝昌さん、好光泰章さん

再びセキュリティセッションに参加しました。今回はIAMまわりとそれを押さえた上でのセキュリティの話でした。

IAMやポリシー、AWS ConfigやAWS CloudTrailなどは本日何回も聞く単語であったため、AWSのセキュリティ設定ではこれらを押さえるのが基本というのがよく分かりました。

また、具体的なTipsやどんなホワイトペーパーを見ればいいのかの解説があり、とてもためになりました。

以下、あとで調べるためのメモです。

 

The AWS Japan Mafia トークセッション 2017

吉田真吾さん、上原誠さん、佐々木きはるさん、今井雄太さん

吉田さんがモデレータとなり、他の登壇者の体験談を聞くセッションでした。

事前に登壇者へ質問と回答を行ったものをスライドに投影し、セッション中で内容深掘りしていくスタイルだったので、話題がブレることなくて良かったです。

以下、印象に残ったところのメモです。

  • 転職について
    • 「一緒に働いて信頼できる人・楽しい人」とイメージできたので、転職できた
  • リモートワークについて
    • リモートワークがあるのは、当たり前になってきた
    • リモートワークが不利にならないようにするという姿勢
      • リモートワークはおまけではない
      • 普段の会議もハングアウトを使うなどの工夫
    • リモートワークの割合
      • 自社プロダクト開発なので、週1回出社
      • 直接顔を合わせて話すのも重要なので、週2,3回出社
    • リモートワークは信頼関係
  • コミュニティについて
    • 情報発信をしている人だと、何ができるのかの期待値が分かりやすい
    • コミュニティの中で、人前で話したり、何かを発信していくのが大事
  • 一緒に働く人について
    • 仲間を心の底から褒める事が重要
      • 同僚を技術者としてライバル視し、悪いところしか見ないと殺伐とした職場に
      • 文字や言葉にして伝える

 

懇親会

会場のパーティションを取り外して、A,B,Cの3会場を統合した場所で開催されました。

AWS Samurai 2016の発表とLTが行われました。

LTは真面目に参考になる話から、いわゆる技術の無駄遣いも色々と見ることができて、楽しめました。

「アレクサ」では反応しないが「荒木さん」では反応するというのが、Amazon Alexaのとても良いTipsでした。

 

その他

今回はセキュリティセッションを中心に回ったため参加できませんでしたが、災害やコミュニティ、女性などに関するセッションもありました。技術以外の分野でもいろいろな経験を聞ける場と感じました。託児ルームが用意されるなどの配慮があったのも良かったです。

 
また、入場する際レシーバーが全員に配られました。

パーティションで区切ってあるだけという会場の仕組み上、発表者の声が聞き取りづらいことがありました。そんな時にレシーバーを使って聞くことができたので、とてもありがたかったです。

 
一方、会場ではWiFiが用意されていましたが、途中で接続が厳しくなりました。会場の広さと1,000名を超える人数を考えると、インフラ担当の人お疲れ様ですと感じました。

 
最後になりましたが、運営されたみなさま、ありがとうございました。