仮想の会社でブログシステムを作るというプロジェクトを、Djangoを使ったテスト駆動開発でやったらどういうことが起きるか、という話を書きました。
実際にありそうなプロジェクト、ありそうな事件に対して、どのようにテスト駆動開発でアプローチしていくかの参考になれば。
(なお、筆者はDjango初心者なので、Django的におかしなことをやっているかもしれませんが、それは大目に見て?)
それでは始まり始まり〜。
…
…
…
あなたの環境、プロジェクトのミッション
あなたは職業訓練学校を卒業し、Railsあたりで作った簡単なポートフォリオサイト(Herokuにデプロイした異常におっそいやつ)をひっさげて、年収200万ぐらいのど零細Web系受託開発企業にうまく潜り込んだ。
勤務日初日。ワンマンで知られる社長と面談をすることとなった。挨拶もそこそこに仕事の話が始まる。
どうやら社長は自社パッケージ製品を作ってここらで大きく当てたいらしい。並行してやっているSESの売り上げが減っているのもあって、自社開発で資産を作っておきたいという算段。(Wantedlyあたりにも、自社開発ができると大々的に喧伝をしている)(年収200万だけど)
ブログが好きな社長はWordPressやMovable TypeのようなCMSを作って売りに出したいとのこと。
「ブログに詳しい」と豪語する社長の、自慢話9割の武勇伝を要約すると、社長が作りたいCMSの要件は下記のようなものだった。
プロジェクトの初期要件
- シンプルなブログシステム
- 管理画面から記事を投稿できる
- 記事はタイトルと本文を持つ
- 投稿した記事は、トップページに表示される
Qiitaで頻繁に記事を書いているあなたはブログの機能がたったこれだけであるはずがないと思った。これだけではWordPressやMovable Typeには勝てないだろうと。
そこで、もう少し詳しく要件を知りたい旨を伝えると、社長はめんどくさそうに、
「要件はこれから柔軟に決めていこう。アジャイル的に」
とだけ言って面談を打ち切った。
あなたは内心「自分にやれるだろうか」と不安になりながら、会議室を後にした。
(筆者注:要件がふんわりしていることを「アジャイル的に」と言うPMにろくな奴はいない)
あなたの労働環境
情シスなんていう高尚な人員はいないため、数日間はPCのセットアップや、環境構築で潰れた。その間にあなたが見た会社の状態は以下の通りであった。
- ブラック企業
- ワンマンな社長が全ての要件を決める。そのため、要件は頻繁に変わったり追加される
- 労働環境がひどく、よく人が急に辞める。そのため、引き継ぎがろくにない場合がある
散々な職場であった。
唯一の救い(?)であるところは、誰もプログラムのことは分からないし、自分の好きなように開発を進めても咎める人がいないことだった。(その代わりレビューと言うものが存在せず、自学しないと全く開発スキルは上がらないようだった)
あなたは夢中で読み耽ったテスト駆動開発と@t_wadaさんのtddbcのハンズオンでやったテスト駆動開発を試すチャンスと思い、ひとまず開発を開始してみることにした。
テスト駆動開発の環境を整える
社長が「これからはPythonの時代だ」と言うので、フレームワークにDjangoを採用した。
ひとまずあなたはDjangoチュートリアルを元にプロジェクトを作成した。
$ pwd
/Users/yhei
$ django-admin startproject blackcms
$ cd blackcms/
$ ls
blackcms manage.py
プロジェクトが作成できたら、次はテスト環境を整える。
Djangoは最初からtestの仕組みが備わっているらしい。コマンドを入れたら、テストができるようになっていた。
$ python manage.py test
System check identified no issues (0 silenced).
----------------------------------------------------------------------
Ran 0 tests in 0.000s
OK
tddbcのハンズオンでやったことだが、最初に必ず失敗するテストコードを書いてみる。下記のようなテストコードを書いた。
$ touch blackcms/tests.py
$ code blackcms/tests.py
from django.test import TestCase
class SampleTests(TestCase):
def test_sample(self):
self.assertTrue(False) # 必ず失敗する
テストを実行してみると
$ python manage.py test blackcms
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F
======================================================================
FAIL: test_sample (blackcms.tests.SampleTests)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/yhei/blackcms/blackcms/tests.py", line 5, in test_sample
self.assertTrue(False)
AssertionError: False is not true
----------------------------------------------------------------------
Ran 1 test in 0.003s
FAILED (failures=1)
Destroying test database for alias 'default'...
無事、テストが失敗することが分かった。
テストが失敗することが分かったことで、逆説的にテストを実行できる環境が整ったことを確認できた。
@t_wadaさん曰く、
「最初のテストが失敗することが保証されないと、テスト環境の構築ができているかどうか分からないまま開発に入ることになる」
とのこと。自明だが大切な作業だとあなたは思う。
TODOリストを作る
これでようやくテスト駆動開発に入ることができる。あなたはtddbcのハンズオンを思い出す。
確かTODOリストなるものを作っていたな。
もう一度社長が提示した要件を眺めてみる。
- シンプルなブログシステム
- 管理画面から記事を投稿できる
- 記事はタイトルと本文を持つ
- 投稿した記事は、トップページに表示される
この中から、簡単そうなものをピップアップしてみる。簡単なものからごく小さく始めるのが最初のテスト駆動開発の一歩だ。
- 記事はタイトルと本文を持つ
これなんかいい。簡単そうだ。これのTODOリストを考えてみる。
- 記事はタイトルを持つ
- 記事は本文を持つ
OK。こんなもんだろう。これは明らかにModelクラスを追加する感じだな。楽勝楽勝。。。と待て。通常であればいきなりプロダクトコードを書くところだが、これはテスト駆動開発だ。
まずはテストから書くんだった。
「記事はタイトルを持つ」のテストを書いて失敗させる
記事はタイトルを持つ、のテストを書く。tests.py に下記のコードを書く。
from django.test import TestCase
class 記事はタイトルと本文を持つ(TestCase):
def test_記事はタイトルを持つ(self):
self.assertEquals('', Post().title)
明らかにこのテストは失敗する。Postクラスなんてものはないからだ。テスト実行してみる。
$ python manage.py test blackcms
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
E
======================================================================
ERROR: test_記事はタイトルを持つ (blackcms.tests.記事はタイトルと本文を持つ)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/yhei/blackcms/blackcms/tests.py", line 5, in test_記事はタイトルを持つ
self.assertEquals('', Post().title)
NameError: name 'Post' is not defined
----------------------------------------------------------------------
Ran 1 test in 0.001s
FAILED (errors=1)
Destroying test database for alias 'default'...
目論見通りこのテストは「Postなんてねーよ!」と言われて失敗する。
この状態をテスト駆動開発ではテストが「レッド」であると呼ぶ。レッドとは文字通り赤色のことで、テストが失敗した状態をさす。
テスト駆動開発は、いついかなる時も、
- テストコードをはじめに書き
- 最初にテストがレッドの状態に持っていく
のがポイントだ。
テストを迅速にグリーン(成功)に持っていく
「レッド」とは逆に「グリーン」はテストが成功した状態を指す。最初にレッドにしたテストは、あらゆる馬鹿らしい手段を用いて迅速にグリーンにする。
こんな感じだ。
from django.test import TestCase
class Post():
title = ''
class 記事はタイトルと本文を持つ(TestCase):
def test_記事はタイトルを持つ(self):
self.assertEquals('', Post().title)
$ python manage.py test blackcms
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.003s
OK
Destroying test database for alias 'default'...
これで記事の実装はできた。とは絶対にならない。
Djangoの基礎を一応おさえているあなたはDBに保存されるであろう記事(Post)はmodels.pyあたりに書かれるべきで、tests.py内に書いたらどうやってViewから読み出すねん。テストコードをimportするんか??? と思う。
が、実際はそんなことは私もあなたも分かっている。
テストをグリーンのままリファクタリングする
テスト駆動開発のフローは、
- 失敗するテストを書いて、テストをレッドにしたら、
- 迅速にテストを成功させるコードを書いてグリーンにし、
- グリーンを保ったままリファクタリングを行う
である。今、2までが終わっている状態なので、次は「グリーンを保ったままリファクタリングを行う」である。
すなわち、テストがグリーンであることを保ったまま、Postクラスをあるべき場所(models.pyあたり)に配置する。
まずPostクラスをmodels.pyに定義し直し、
$ touch blackcms/models.py
$ code blackcms/models.py
class Post():
title = ''
テストコードからはmodels.pyからPostクラスを読み出すようにする。
$ code blackcms/tests.py
from django.test import TestCase
from .models import Post
class 記事はタイトルと本文を持つ(TestCase):
def test_記事はタイトルを持つ(self):
self.assertEquals('', Post().title)
テストを実行すると、
$ python manage.py test blackcms
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.001s
OK
Destroying test database for alias 'default'...
無事テスト成功となった。
テスト駆動開発の基本フロー
以上で、テスト駆動開発の1ループが回せた。テスト駆動開発は
- 要件からTODOリストを作り
- 簡単なTODOをピックアップ
- TODOからテストコードを書き、テストを失敗させる(レッド)
- 迅速にテストを成功させる(グリーン)
- グリーンを保ったままリファクタする
- 3に戻る
が基本である。レッド、グリーン、リファクタのサイクルを回していくことで、開発を進めていく。
残りのTODOを消化する
さて、現状のTODOリストは下記の通りだ。
- (済)記事はタイトルを持つ
- 記事は本文を持つ
「記事は本文を持つ」が残っている。
こちらもサクッとレッド、グリーン、リファクタのサイクルを回して、開発を行った。
現状のコードは下記の通り。
class Post():
title = ''
content = ''
from django.test import TestCase
from .models import Post
class 記事はタイトルと本文を持つ(TestCase):
def test_記事はタイトルを持つ(self):
self.assertEquals('', Post().title)
def test_記事は本文を持つ(self):
self.assertEquals('', Post().content)
テスト実行結果は下記の通り。
$ python manage.py test blackcms
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..
----------------------------------------------------------------------
Ran 2 tests in 0.001s
OK
Destroying test database for alias 'default'...
次のTODOを作ってみる
さて、今一度要件に立ち返ってみよう。
- シンプルなブログシステム
- 管理画面から記事を投稿できる
- (済)記事はタイトルと本文を持つ
- 投稿した記事は、トップページに表示される
次に取り掛かるべきちょうど良い要件はないだろうか。簡単なやつが良い。
ということであなたは
- 管理画面から記事を投稿できる
に着目した。
と、ここで若干困る。そう、「管理画面」である。Viewっぽい。Viewが絡むテストってどうやるんだろう。Djangoでは確かViewのテストのやり方もあったよな。。。と考えたところで気づく。
「記事を投稿できる」
の部分。ひとまずここだけ実装しておけば、管理画面から使うのも容易なのではなかろうか。
そういえば、@t_wadaさんも「そもそもViewにテストが必要なロジックを埋め込むな」とおっしゃっていた。
ということで、一旦Viewっぽい「管理画面」については無視して、「記事を投稿できる」の要件についてTODOを考えてみる。
「記事を投稿できる」のTODOを考えてテスト駆動開発する
記事を投稿できる、だとざっくりしているのでもう少し踏み込んでTODOを考えてみる。下記のようなTODOができた。
- タイトルと本文を指定して記事を保存できる
これに対するテストコードをtests.pyに引き続き書いてみる。初めは失敗するテストコードを書く。
class 記事を投稿できる(TestCase):
def test_タイトルと本文を指定して記事を保存できる(self):
post = Post()
post.title = 'タイトル'
post.content = '本文'
self.assertTrue(post.save())
テスト結果は無事にレッドとなる。
$ python manage.py test blackcms
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..E
======================================================================
ERROR: test_タイトルと本文を指定して記事を保存できる (blackcms.tests.記事を投稿できる)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/yhei/blackcms/blackcms/tests.py", line 16, in test_タイトルと本文を指定して記事を保存できる
self.assertTrue(post.save())
AttributeError: 'Post' object has no attribute 'save'
----------------------------------------------------------------------
Ran 3 tests in 0.003s
FAILED (errors=1)
Destroying test database for alias 'default'...
次は、これを迅速にグリーン(成功)に持っていくためのプロダクトコードを書く。models.pyにやや投げやり気味な下記コードを追加する。
class Post():
title = ''
content = ''
def save(self):
return True
何もせずにTrueを返すsaveメソッドを追加した。結果、
$ python manage.py test blackcms
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...
----------------------------------------------------------------------
Ran 3 tests in 0.002s
OK
Destroying test database for alias 'default'...
テストは無事グリーンになる。
ただ、この実装には全く意味がない。それはみんな分かっている。では何をするか。グリーンを保ったままリファクタリングを行う。
まず、そもそもDjangoをかじった方ならわかると思うが、DBに保存するならModelクラスを継承する必要がある。さらに、DB保存用のメソッドとして、(偶然にも)saveメソッドがModelクラスには用意されている。これをそのまま流用することでやりたいことはできるはずである。
そこでmodels.pyを下記のように変更する。
from django.db import models
class Post(models.Model):
# title = ''
# content = ''
title = models.TextField()
content = models.TextField()
def save(self):
super().save()
return True
Djangoのmodelsを使うにあたり、blackcms/settings.py にも下記を追加
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'blackcms', # これを追加
]
この状態でテストを実行すると
$ python manage.py test blackcms
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...
----------------------------------------------------------------------
Ran 3 tests in 0.003s
OK
Destroying test database for alias 'default'...
見事グリーンになる。
ただ、今一度テストを見てみると
class 記事を投稿できる(TestCase):
def test_タイトルと本文を指定して記事を保存できる(self):
post = Post()
post.title = 'タイトル'
post.content = '本文'
self.assertTrue(post.save())
post.save() が成功したかどうかだけをみるだけでは、保存できたかは確認できないはずである。
なのでsave()をした後に、実際にDBから保存したものが取ってこれるかを確認するテストへと変更する。
class 記事を投稿できる(TestCase):
def test_タイトルと本文を指定して記事を保存できる(self):
# post = Post()
# post.title = 'タイトル'
# post.content = '本文'
# self.assertTrue(post.save())
Post(title='タイトル', content='本文').save()
self.assertEquals(
('タイトル', '本文'),
Post.objects.values_list('title', 'content').first()
)
テスト実施をすると、依然グリーン。
$ python manage.py test blackcms
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...
----------------------------------------------------------------------
Ran 3 tests in 0.003s
OK
Destroying test database for alias 'default'...
この状態でさらにmodels.pyをリファクタリングする。
from django.db import models
class Post(models.Model):
title = models.TextField()
content = models.TextField()
# def save(self):
# super().save()
# return True
saveメソッドはオーバーライドする必要ないため、削除。テストも依然グリーンのままである。
これで
- タイトルと本文を指定して記事を保存できる
というTODOに対して、実装ができたように見える。
「記事を投稿できる」の異常系TODOを考える
ここまで実装して考えてみる。記事ってどんな条件でも保存できていいのだろうか? 例えばタイトルが空で保存されたら良くないんじゃない?
思い立ったあなたはTODOを追加する
- (済)タイトルと本文を指定して記事を保存できる
- タイトルが空の記事は保存できない
TODOを追加したら、まずはテストだ。失敗するテストコードを書く。
def test_タイトルが空の記事は保存できない(self):
with self.assertRaises(ValueError):
Post(title='', content='本文').save()
titleが空の状態でsave()メソッドを呼んだ場合、ValueErrorというExceptionが起きるようにしたい。
この状態でテストを走らせると
$ python manage.py test blackcms
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..F.
======================================================================
FAIL: test_タイトルが空の記事は保存できない (blackcms.tests.記事を投稿できる)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/yhei/blackcms/blackcms/tests.py", line 20, in test_タイトルが空の記事は保存できない
Post(title='', content='本文').save()
AssertionError: ValueError not raised
----------------------------------------------------------------------
Ran 4 tests in 0.004s
FAILED (failures=1)
Destroying test database for alias 'default'...
ValueErrorは発生しないため、テストはエラー、レッドとなる。
無事レッドにできたらどうするか、迅速にグリーンにする、だ。
models.pyのsaveメソッドを下記のようにする。
from django.db import models
class Post(models.Model):
title = models.TextField()
content = models.TextField()
def save(self):
if self.title == '':
raise ValueError
super().save()
この状態でテストを走らせると
$ python manage.py test blackcms
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
....
----------------------------------------------------------------------
Ran 4 tests in 0.006s
OK
Destroying test database for alias 'default'...
無事、グリーンに復帰できた。
以降、例えばこれはバリデーションにあたるため、それ用の関数を作った方が良いとか、Exceptionにmessageが必要とか、色々リファクタリングの余地はありそうである。が、一旦これで良しとして進める。
突然の仕様変更、そして退職へ
テスト駆動開発もだんだんと軌道に乗り始め、なんとか基本機能ができ始めたある日。急にあなたは社長に呼ばれるのであった。
社長曰く、記事を簡単にコピーできる機能をどこかに追加して欲しいとのことだった。WordPressとかだとそういうプラグインあるんだよねー、と。
だったら最初からWordPress使えよと思いつつ、帰り道。色々思案をする。
コピー元の記事IDを受け取ったらそれをコピーしてsave()したらいいんだよな。責務的にModelに書く話ではなく、Serviceに書く話だな云々。頭の中でテストコードとプロダクトコードが出来上がっていく。
しかし。
次の日。あなたが席についてVSCodeを立ち上げたところで、社長がズカズカとやってきた。
そして
「お前、午後から〇〇の現場に行け。Django歴3年ってことにしといたから」
とだけ言われたのだった。
驚いたあなたが、今やってるCMSのプロジェクトはどうするんだと問うと、
社長は、
「凍結。それより早く経歴書を書け。写真も撮ってこい」
と一方的に言い、そそくさとオフィスから出て行った。
…
…
…
…
その後、あなたはCMSのプロジェクトに帰ってくることなく退職することとなった。精神を病んだあなたは、実家に引きこもり、今はチャンネル登録者数30人のテック系YouTuberとして極貧生活を送っているらしい。
…
…
…
…
2年後、別のあなたがやってきた
TechFoolというプログラミングスクールに70万投資してPythonの勉強をした、もう1人の別のあなたは、githubにスクール課題のサンプルコードを載せたものを実績としてひっさげ、どうにかこうにかWeb系ど零細受託開発企業に潜り込んだ。年収は「交通費込みで200万」と言われた。
とにかく1年実務経験を積んでテック系YouTuberに転身しようと考えていたあなたは、どんなプロジェクトでもやるつもりだった。
そこで任されたのが自社CMSの開発だった。
自社CMSと言っても社長曰く、「シンプルなブログシステム」ということで、それ以外の要件は前任者がまとめているとのことだった。
ただ前任者は既に失踪しているらしく、パソコンのみが見つかった。
ソースコードがあるとの話だったので、資料を漁ったが、リポジトリのURLもドキュメントの記録もなかった。前任者のパソコン内を隅々まで調べ、ssh鍵とgitのconfigからどうにかリポジトリにアクセスし、ソースコードをgit pull することができた。
幸運にもDjangoを使っているらしい。Pythonなら得意だ。(というかそれしかできない)
テストコードがあったので、何気なく走らせてみた。
$ python manage.py test blackcms
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
E....
======================================================================
ERROR: test_単一の記事からそのコピーを作ることができる (blackcms.tests.記事のコピーができる)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/yhei/blackcms/blackcms/tests.py", line 26, in test_単一の記事からそのコピーを作ることができる
self.assertEquals('コピー - タイトル', original_post.duplicate().title)
AttributeError: 'Post' object has no attribute 'duplicate'
----------------------------------------------------------------------
Ran 5 tests in 0.007s
FAILED (errors=1)
Destroying test database for alias 'default'...
エラーが出たテストメソッドを見ると、下記のように書かれていた。
class 記事のコピーができる(TestCase):
def test_単一の記事からそのコピーを作ることができる(self):
Post(title='タイトル', content='本文').save() # TODO fixtureにしたい
original_post = Post.objects.first()
self.assertEquals('コピー - タイトル', original_post.duplicate().title)
テストコードを見たあなたは、なるほど、この辺りから開発再開すれば良さそうだなと思いつつ、これ、ほぼ何もできてなくない? と思った。
おわりに
この話はフィクションである。僕はWeb系に来て4年経つが、最後の章のように、リポジトリのURLすら残っていない状態でプロジェクトを任される、なんて経験をしたことはない。
しかし、似たような経験をしたことは3度ほどある。結構な確率だよなと思う。
昔はこのような目にあった時に、まず最初にドキュメントが残っていないことにムカついていた。
が、最近は考えが変わってきた。ドキュメントなんてあったって見ないのである。また、大抵ドキュメントは陳腐化しており、嘘が書いてあったり、開発者のメモ的な感じで要領を得なかったりと役に立たない。
その点、メンテされたテストコードはとても有用である。テストがエラーになった時はデプロイできないようになっているとなお最高で、どんなやばい奴でもデプロイのためにテストコードを大事にし続ける。
すると、テストコードが動く仕様書として未来永劫残り続ける。最悪、ソースコードさえあれば仕様の把握が可能、かもしれない。
例えば、最後の章で2年前のあなたが常駐先にドナドナされる前のわずかな時間を使ってギリギリ残したテストコードを見て欲しい。
class 記事のコピーができる(TestCase):
def test_単一の記事からそのコピーを作ることができる(self):
Post(title='タイトル', content='本文').save() # TODO fixtureにしたい
original_post = Post.objects.first()
self.assertEquals('コピー - タイトル', original_post.duplicate().title)
ここにはたくさんの情報が溢れている。
まずclass名やメソッド名から、仕様はだいたい想像できる。
duplicate()はまだ未実装だが、返り値がPostオブジェクトになるだろう。
複製されたオブジェクトのタイトルは単純なコピーではなく、「コピー -」というプレフィクスがつくこと。
社長から裏で言われた仕様なのか、あなたが気を利かせて作った仕様なのか分からないが、とにかく当時の仕様が書いてある。
tests.pyを見れば、全ての仕様が把握できる。もし、既存仕様に違反するようなことがあれば直ちにエラーが出る。デグレも起きにくくなる。
これほど有用な開発手法はないと感じている。特に人の入れ替わりが激しい現場においては、「仕様は全部そのスパゲッティコードに書いてあるぜ!?」ということが起きがちである。
そうした時に、自動テストが書いてあり、少なくともそれに違反していなければデグレはしていないということが担保できれば、冒険ができる。積極的にリファクタリングができる。最初に作ったやつらが誰もいなくなったとしても、後発メンバーで品質を上げていくことが可能になる。(ただし、テストコードがリーダブルであれば、の話だが)
また、テストが書けるということは、適切に責務分割され、疎結合な実装になっている(場合が多い)。テスト駆動開発は否が応でもテスタブルなコードにならざるを得ない為、後から振り返ってみると割といいコードだなあと思う(ことが多い)。
…
…
…
まとめる。
テスト駆動開発を実践すると、
- デグレを検知できるため、リファクタリングが容易になる
- テストしやすい実装≒疎結合のため、設計が良くなる
- テストコード自体が仕様書として機能する為、プロジェクトの保守性が上がる
というメリットがある。是非実践して見て欲しい。
もっと知りたければ、参考書籍も読んで欲しい。きっと役に立つはずだ。
参考図書
(これに加えてtddbcなどのテスト駆動開発のハンズオンに参加してみることをオススメする)