Djangoの自動テストをあきらめた話

Python Djangoを使ってWebアプリを作り始めて、今まで自動テストをちゃんとやったことがなかったので、いい加減にこの機会にちゃんと学ぼうと思って、30-40時間を学習と試行に費やしたけど結局諦めたという話。

何のために自動テストを使うのか

テスト駆動開発くらいの言葉もあるし、Qiitaにはこんな素晴らしい投稿をされている人もいて概念自体はとてもいいなと思うものの、作業量があまりにも多すぎる。感覚的には、動くアプリを書いて画面をチェックして開発を進めるだけっていうのの3倍以上の時間がかかる気がする。PythonやDjangoでなくてもなんだって同じだろうなという気がする。

CI/CDなんて概念も、もはや世の中では当たり前だよというような感じで語られているし、自分は、とにかく回帰テストとしてDjangoのテストフレームワークを使いたかった。関数ベースビューが何十にもなって、モデルの数もどんどん増えていき、何か新機能を付けたりバグを修正したりした時に、既存のうまくいっているところが動かなくなるのを防ぐのには確かにうってつけなので。

改修する、それがうまくいっているかテストする、そのテストをWebブラウザを操作して目検で確認するだけでなくコード化する、蓄積されたテストをすべてブン回して何もかもうまくいくことを確認して、本番環境にリリースする。ということが毎回できればよいなというお話です。

学習したこと、試したこと(CLIベースのテスト)

Django付属のTestCaseから試したが、まずこのテストは大きく二つの目的に使える。
その1はモデルのテスト、その2は疑似ブラウザ(Client)を使った画面動作のテスト。

モデルのテストはこんな塩梅になる。

class CompanyModelTests(TestCase):

    def setUp(self):
        self.cp = Company(
            name="test company",
            since=timezone.now(),
            start_year='same',
            start_month=4,
        )
        self.cp.save()
        return super().setUp()

    def test_cp_count(self):
        cp = Company.objects.all().first()
        self.assertEqual(len(Company.objects.all()), 1)

Companyというモデルを1件Saveしてあげて、テストメソッド(test_cp_count)にて、Companyのモデルの数を数えてあげたらちゃんと1になるよね、というテストコードになる。Webのチュートリアルとかを見ても、これくらいのサンプルコードは山ほどあるので、理解はしやすい。

しかし、こんなテストを大量に作ってどこまで意味があるのかと、この作業を延々続けることがばかばかしく思えてしまう。この例はほぼDjangoのフレームワークをテストしているだけだ。じゃあ、Modelに自分で追加したメソッドをテストすればいいということにはそれは賛成なのだけど、結局そんなメソッド単体でテストしたところで、基本はViewのロジックがテストされないと意味ないし、Viewが期待通り動いてくれてるかどうかを見るのが大事なのに、部品のテストにそこまで時間をかけるのが正解かどうか。だいぶ包含されるし。

そして、Viewをテストするとなると、その2.疑似ブラウザ的なClientを利用したテストになる。これはだいぶいいものに思えた。

class CompanyDisplayTests(TestCase):

    fixtures = ['test_data_01']

    def setUp(self):
        self.p_ad = Person.objects.filter(pk=2).first()
        self.client.force_login(self.p_ad.user)
        return super().setUp()

    def test_company_top_suc(self):
        ''' company top normal show'''
        response = self.client.get('/pj01/company_top')
        self.assertContains(response, "Project and Workcodes")
        self.assertEquals(response.status_code, 200)

    def test_wctype_change_order(self):
        ''' wctype_change_order '''
        self.client.force_login(self.p_ad.user)
        data = {"form-0-id": "1",
                "form-0-cp": "1",
                "form-0-name": "Normal",
                "form-0-is_active": "on",
                "form-0-ord": "03",
                "form-1-id": "2",
                "form-1-cp": "1",
                "form-1-name": "AO",
                "form-1-is_active": "on",
                "form-1-ord": "20",
                "form-TOTAL_FORMS": "2",
                "form-INITIAL_FORMS": "1",
                "form-MIN_NUM_FORMS": "0",
                "form-MAX_NUM_FORMS": "20",
                "modify": "Save"}
        
        response = self.client.post('/pj01/mt_wctype', data)
        response = self.client.get('/pj01/mt_wctype')
        self.assertContains(response, 'Succesfully Saved.')
        self.assertEquals(response.status_code, 200)

DjangoのTestCaseでは、self.clientという疑似ブラウザが使える。この例では、’/pj01/company_top’というURLをGETリクエストして、その結果返ってきたHTMLに入っている文字列と、レスポンスのHTTPステータスコードが200であることを検証して、いずれかに問題があればテストがエラーとなる。

同様に、画面に対するPOSTをすることも可能。それが上記サンプルの中のtest_wctype_change_order。postメソッドで呼んで配列を渡してやることでWebブラウザからのデータ入力、そのあとのメッセージの検証や、例からは省略したがModelを使ってDBにデータが想定通り格納されたかを検証させることもできる。

fixtureというDBの基本マスターデータをロードして、毎回まっさらなデータベースを用意してくれる思想もなるほどなと思った。前回のテスト実行の結果でデータが変わっていたら同じ結果が得られないという悩みはない。

そして、こんなコードを手でいちいち書いてトライアンドエラーするのもまた時間がもったいないので、コードを書いたあとに手動でブラウザを動作させてテストしてあげるときにうまくログを吐き出させて、上記のようなテストコードへ流用する情報、主にURLやPOSTデータをTextファイルに吐き出すようにMiddlewareを作ってやることによってテストコードを作る手間を省力化させてもみた。

しかしそれでも、画面でテストした通りのことを、こいつにサクッとやらせるまでにはどうしても至らなかった。
いくつか問題があって

  • Fixtureのデータの中に日付項目があると、なぜかテスト実行時の出力結果にその日付がダラダラと出力されてくる。意味も分からないし、だいぶデバッグもしたが止める方法が不明。
  • Fixtureがすべてのテストに影響するとなると、気軽に直すわけにもいかないし、でもテストを追加するたびにどうしてもFixtureを修正したくなるし非常にストレス。
  • 自分でブラウザからマニュアルテストしてうまくいっている機能が、自動テストでうまくAssertを通らないときに、デバッグしているときの謎の労力(エラーが出てる瞬間のDBの状態も見られないし)
  • 日付関係がしんどい。2022年10月にうまく通っていたテストが2023年2月にコケて、それをまたトラブルシュートすることの空虚さ。
  • テストメソッドをまたぐとDBが初期状態に戻る。このテストメソッドで登録したデータを、別のテストメソッドで参照画面で見よう、と思ってもそうはいかない。結局、データを登録して、削除して、もう一度登録して、編集して、ステータスを変えて、参照画面で見たらここがこう変わってて・・・といったユーザストーリー的なテストは一つのテストメソッドの中にどんどん書き足す必要がある。そして、途中であきらめたけど、登録したはずのデータがなぜかうまく残らなくてテストが通らない。
  • これだけ色々な問題をクリアしたところで、結局人が目で見たときに「あれ、ここ思った通り動いてないな」と気づく力には勝てない。自動テストはassertで指示されたところしかチェックしない。assertを死ぬほど書けばいいし、Djangoの場合は返されたHTMLやステータスコードだけではなくてコンテキストに対してチェックをすることもできるしDBをModel経由でチェックさせることもできるのだが、これも労力があまりにもかかる。

Webで色々調べたけれど、どれもいい解決策に至らず、僕ちゃんにはCI/CDなんてまだ早かったのかな、落ちこぼれだな、と泣いた。何をどうやって見ても、回帰テストが1コマンドでブン回せるというメリットに釣り合うとはとても思えないほどの労力がかかると思われた。

Djangoのtestcase.clientはそれでもどうしても使いこなしたかったので粘った。Seleniumを使ったLiveServerTestCaseも存在は知っていたけど、こちらは気軽にテストが回せないのが気にかかった。本当のWebブラウザをスクリプト操作でテストさせる場合、完全に直列でテストが走るから、テストコードが蓄積するほどに待ち時間が長くなるのが必至。テストコード自体のテストみたいなのが発生するの明白だから、1回あたりの実行時間が長くなるのはどうも気が進まなかった。

でもそれでも、本当にやりたいことは
・VsCodeでアプリを書いて
・横でブラウザでそれを叩いてみて期待通り動作するかを見る
・うまく動いていれば開発を続ける、期待と違ったら修正して再度見る
・一通りうまくいったことを確認したら、ブラウザで確認した手動テストを自動テストコード化して回帰テストとして蓄積する
ということなので、Seleniumにも行ってみることにした

学習したこと、試したこと(LiveServerTestCase)

やりたかったことは
①VsCodeでアプリを書き、manage.py runserverしたテスト環境で叩いて機能を作っていき
②大体満足したところで、Selenium IDEを起動、開発したコードが一通り叩けるような操作をレコード
③それをやる過程で、Selenium IDEのスクリプトの中にAssertを仕込んでいく
④PythonコードをExportする
⑤DjangoのLiveServerTestCaseを継承したテストクラスにスクリプトとして取り込む
⑥テストコードがうまく動くことを確認したら、テストコードとして蓄積していく
という流れ。

②と③は大まかにはうまくいったけど、Assertに関してはHTTPのステータスコードを取得する機能はないことが判明。公式にも、それをやろうとしていること自体が間違ってるよ的な見解が。あれば楽なのになと思ったけど、<h1>タグとかみればええやん、って書いてある。

それではとあきらめてAssert Textを多用していくが、、、全文一致しかAssertが通らず部分一致やる方法がどうもなさそうだ。これもつらい。

Pythonコードを出力した後、LiveServerTestCaseにはめ込んだら一応動いているような気もするが

TypeError: expected str, bytes or os.PathLike object, not NoneType

動作はしているっぽいけど吐き出し続けられる謎のこのエラーは一体何なのか。ググっても何もわからない。

さらに動いている画面を見るとCSSとかが読み込めておらず何とも調子が出ない。これも直し方あるのかな。

あきらめることに

CI/CDとか自動テストがこんなに大変なものなのかと愕然とし、いったん使うのをあきらめることにした。Djangoとして機能がすでに十分そろっていて、自分の頭が足りなくて使いこなせていないだけなのか、世の中の皆さんは大変な労力をかけてちゃんとやっていらっしゃるのか、本当はまだ全然普及してなくてマニュアルテストが大半の世の中なのか、謎。

自動テストは、回帰テストでGetした画面が想定外のステータスコード返してきてないかくらいをチェックするくらいにとどめて、マニュアルテスト基本でやっていくことにする。