目的
- テストコードの作成を少しだけ楽にしてくれるPythonのライブラリの紹介
- 実際にサンプルコードを作成して試してみる
目次
- そもそもテストコードって何?
- テストコードに関して、僕が困ったこと
- FactoryBoyとの出会い
- 実際に試してみよう!
そもそもテストコードって何?
偉大な先人たちの資料を参考にするのが良いと思うので、いくつかリンクを貼ることにします。
どちらも、テストコードとは何か?から丁寧に解説してくれています。
僕もテストコードのことは上記の記事で把握はしていたのですが
実際に書き始める環境になったのは、今年の1月からです。
今回は、テストをPythonで書いていた際に困ったこと、こうすれば行けるのでは?と思ったことを纏めたいと思います。
テストコードに関して、僕が困ったこと
テストの流れを図で解説すると、以下の様になります。
『実行』は既に存在している関数を呼び出すだけなので記述は簡単です。
『確認』はテストケースを作成する際に、どの様な結果になるのか想定できているはずなので、記述は簡単です。
僕が、一番困った箇所は『準備』の箇所です。それも、DB操作が関係する関数のテストです。
DB操作の関数をテストする際には、事前にDBにレコードをいくつか用意する必要が出てきます。
その際に、テーブル同士の関係によっては本来ならテストに関わってこない部分のレコードを作成しないといけません。
簡単な例を上げると、書籍テーブルと著者テーブルの2種類があるとします。
書籍テーブルのカラムには以下があるとします。
- ID
- タイトル
- 著者ID
- ISBN番号
- レコード作成日時
- レコード更新日時
著者テーブルには以下があるとします。
- ID
- 氏名
- レコード作成日時
- レコード更新日時
図にすると、こんな感じです。
図形の作成は以下の記事を参考に作成してみました。
めっちゃ便利!!ありがとうございます!!
DockerでサクッとDBからER図を作成する
さて、例えば『書籍の登録時に重複をチェックする』関数のテストを書くとしましょう。
そうすると
- 新規の書籍を登録する
- 既に登録されている書籍を登録する
の2パターンでのテストが欲しくなります。
1 は単純に書籍を登録するだけで、DBに事前にレコードを準備しなくても大丈夫ですが
2 はどうでしょうか??
テストの為にレコードを準備しないといけません。
今は、単純なテーブル構成なので作成は簡単です。
Pythonでsqlalchemyを使用していれば、Modelクラスを呼び出してsessionに突っ込んでお終いです。
簡単に書くと、こんな感じになると思います。
# テストデータ準備
model_author = Author(name='太宰治')
session.add(model_author)
session.flush()
model_book = Book(title='人間失格', isbn=4041099129, model_author.id)
session.add(model_book)
session.commit()
# テスト対象の関数を実行
これだけの為に、この量のテストコードを書くとなると実際のシステムでは
これ以上の量のコードを大量に書くことが予想できます。
今年の1月からのテストは、これの超肥大化バージョンでした。。。。
普通にテストデータの準備だけで、100行近くあったり。。
準備するだけのコードを全て合せると、数千行に行きそうな勢いです。。
テーブル設計の修正や、新規機能の開発ではテストの作成に実装よりも数倍かかるのが当たり前でした。
ここで、諦めて黙々と書くこともできるのですが
- メンテナンスのコストが非常に高い
- テストに対するbetter方法が知りたい(もっと楽をしたい!)
という思いがあり、色々と調べてみました。
FactoryBoyとの出会い
テストを作成するストレスというかモヤモヤに悩まされ続け
それでも、調べ続けると以下の記事がヒットしました。
Agile database integration tests with Python, SQLAlchemy and Factory Boy
Factory Boy is a tool for creating on demand pre-configured objects. It’s very handy and I personally suggest you to use it extensively.
事前に定義されているオブジェクトを生成してくれるそうです。
日本語での記事を見つけました。
これを見つけた時は、もしかしたら、もしかしたら
自分の困っている、あの難解なテストが無くなるかも!!とワクワクしました。
実際に試してみよう!
サンプル的に書いたので、1ファイルにモデルの定義や関数を書いてしまっています。
もう1ファイルに、テストコードを書いてあります。
まずは、モデルの定義やFactoryBoyの準備、テスト対象の関数を見ていきましょう
なお、フォルダ構成やDBのスキーマを分けてのテスト実行などを含めたソースは下記にあるので良かったら参照してみてください。
from sqlalchemy import *
from sqlalchemy.orm import *
from sqlalchemy.ext.declarative import declarative_base
import factory
from factory.alchemy import SQLAlchemyModelFactory
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime
from sqlalchemy.orm import relationship
import random
import json
import requests
"""DBとモデルの準備"""
DATABASE = "postgresql://postgres:@localhost:5432/sample_db"
ENGINE = create_engine(
DATABASE,
encoding="utf-8",
echo=False,
connect_args={'options': '-csearch_path={}'.format('test')}
)
session = scoped_session(
sessionmaker(
autocommit=False,
autoflush=False,
bind=ENGINE
)
)
Base = declarative_base(bind=ENGINE)
# 著者テーブルの定義
class Author(Base):
"""
AuthorModel
"""
__tablename__ = 'authors'
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(200))
created_at = Column(DateTime, default=datetime.now, nullable=False)
updated_at = Column(DateTime, default=datetime.now, nullable=False)
def __init__(self, name):
self.name = name
# Factoryクラスの定義
# SQLAlchemyModelFactoryはSQLAlchemyに対応したFactoryBoyのクラス
# Djangoでも使用できるよ
class AuthorFactory(SQLAlchemyModelFactory):
class Meta:
# Authorをモデルにする
model = Author
# 使用するセッションを指定
sqlalchemy_session = session
# Factoryクラスを呼び出した際に、作成したデータをコミットする
sqlalchemy_session_persistence = 'commit'
# どんなデータを作成するのか定義
name = factory.Sequence(lambda n: u'著者 %d' % n)
class Book(Base):
"""
BookModel
"""
__tablename__ = 'books'
id = Column(Integer, primary_key=True, autoincrement=True)
title = Column(String(200))
author_id = Column(Integer, ForeignKey('authors.id'))
isbn = Column(String(13))
created_at = Column(DateTime, default=datetime.now, nullable=False)
updated_at = Column(DateTime, default=datetime.now, nullable=False)
authors = relationship('Author', backref="books")
def __init__(self, title, isbn, authors):
self.title = title
self.isbn = isbn
self.authors = authors
class BookFactory(SQLAlchemyModelFactory):
class Meta:
model = Book
sqlalchemy_session = session
sqlalchemy_session_persistence = 'commit'
title = factory.Sequence(lambda n: u'タイトル %d' % n)
isbn = factory.Sequence(lambda n: str(random.randrange(10**12, 10**13)))
# SubFactoryがテーブル同士のリレーションを解釈してくれる
authors = factory.SubFactory(AuthorFactory)
"""DBとモデルの準備完了"""
"""テスト対象の関数"""
def register_book(target_isbn):
"""
書籍登録機能
"""
parameter = {'isbn': target_isbn}
query_result = requests.get(
'https://api.openbd.jp/v1/get', params=parameter)
book_info = json.loads(query_result.text)
title = book_info[0]['summary']['title']
isbn = book_info[0]['summary']['isbn']
author = book_info[0]['onix']['DescriptiveDetail']['Contributor'][0]['PersonName']['content']
Session = sessionmaker(bind=ENGINE)
session = Session()
authors = _fetch_author(session, author)
if _exist_book(session, isbn) == 0:
model_book = Book(title, isbn, authors)
session.add(model_book)
session.flush()
session.commit()
session.close()
def _fetch_author(session, author):
"""
著者が既に登録されているかチェック
"""
db_author = session.query(Author). \
filter(Author.name == author).\
all()
if len(db_author) == 0:
model_author = Author(author)
else:
model_author = db_author[0]
return model_author
def _exist_book(session, isbn):
"""
書籍が既に登録されているかチェック
"""
count = session.query(Book.id). \
filter(Book.isbn == isbn).\
count()
return count
次に、テストです。
書籍の登録機能で、既に登録してある書籍が登録されないことを確認しています。
import pytest
from sqlalchemy.orm import *
from sqlalchemy import *
from src.main import register_book, _fetch_author, ENGINE, Base, AuthorFactory, BookFactory, Book, Author, session
# DBのセットアップ
# テーブルの作成
@pytest.fixture
def db_SetUp():
Base.metadata.drop_all(ENGINE)
Base.metadata.create_all(ENGINE)
# テストケースが終わる度に、テーブルを削除
@pytest.fixture
def db_TearDown():
yield
session.close()
Base.metadata.drop_all(ENGINE)
Base.metadata.create_all(ENGINE)
# テストデータ作成
@pytest.fixture
def test_data():
BookFactory(isbn='4041099129', title='人間失格', authors=AuthorFactory(name='太宰治'))
class Test:
def test_register_book_already_exist(self, db_SetUp, test_data, db_TearDown):
"""
register_bookのテスト
指定されたISBNでの登録が、既に登録されている書籍の為、登録されないことを確認
"""
target_isbn = 4041099129
# 期待値
expected_isbn = str(target_isbn)
expected_title = '人間失格'
expected_author_name = '太宰治'
expected_registered_books_count = 1
# 試験対象の関数
register_book(target_isbn)
# 登録されている書籍と著者の情報を取得
db_book = session.query(Book.title, Book.isbn, Author.name).\
join(Author, Book.author_id == Author.id).\
filter(Book.isbn == str(target_isbn)).\
first()
# 登録済みの書籍の数を取得
db_books_count = session.query(Book).count()
# 評価
assert type(db_book.isbn) is str
assert db_book.isbn == expected_isbn
assert db_book.title == expected_title
assert db_book.name == expected_author_name
assert db_books_count == expected_registered_books_count
あんなに長く、辛いテスト準備のコードが
# テストデータ準備
model_author = Author(name='太宰治')
session.add(model_author)
session.flush()
model_book = Book(title='人間失格', isbn=4041099129, model_author.id)
session.add(model_book)
session.commit()
たった、1行になるんです!
BookFactory(isbn='4041099129', title='人間失格', authors=AuthorFactory(name='太宰治'))
これは、使えわない手はないですよね??
FactoryBoyは色々な使い方ができるので、調べてみて適切に使用すれば
これ以上のツールは無いと思います。
是非是非、使ってみましょう。
お終い