作りたいものがありすぎる

40歳を過ぎてプログラミングを始めた人の顛末とこれからなど

Laravel本の輪読会を完走した

二か月以上毎週通ったLaravel本の輪読会が先日、最終回を迎えて完走出来ました。

weeyble-php.connpass.com

読んだのは以下の本

PHPフレームワーク Laravel Webアプリケーション開発 バージョン5.5 LTS対応

PHPフレームワーク Laravel Webアプリケーション開発 バージョン5.5 LTS対応

ここでの記事にもしましたが、書籍を毎回1章毎に、参加者のいずれかが担当となって、あらかじめ読み込んで場合によっては資料を書いてそれを元に担当者が解説してゆく、というスタイルです。

PHPフレームワーク Laravel入門

PHPフレームワーク Laravel入門

Laravelの基礎は上の青い本で学び、有志の日本語リファレンスやウェブのノウハウで覚えてきましたが、この本は、さらにその上を行く難しさを感じる様なハードな内容となっていて、おそらく一人で読んで実践していたら心が何度も折れて挫折していたと思います。

これまでプログラム関連のイベントはもくもく会や勉強会等にも通っていましたが、自分が積極的に学んでいるフレームワークの、より高い水準での知識やノウハウを毎回学びつつ、知識を共有できる場に通い、自分よりも多くの知識や技術を持った人に、なんでも質問できたという事は、普段個人でコツコツやっている自分にとって、とても貴重な時間となりました。

最初は担当者の方の話をひたすら聞いてなんとかついてゆくのが精いっぱいでした、実際2章、3章は今でも理解できない事が多く、もう一度読み込もうと思ってますが、4章以降は、実際にアプリを作った自分が見て既存の部分も多くありつつ、新たなノウハウもたくさん学べました。おそらくこの会を一番ありがたがり、最も得たものが多いという自信があります。(なんだそれ)

『9章 テスト』の輪読担当になった事で、マジにやらないとマズイ状況に自らを追い込み、自ら学び、説明できる所までなんとかもって行き、つたないながらも、何とか担当をこなす事が出来ました。
知識の習得は大変でしたが、説明するのは講師の経験が生きたかもしれません。

また、つい先日、最終章の『テスト駆動開発の実践』を事前予習して、いわゆるTDD(テスト駆動開発)を本の内容に基づいて実践してみましたが、これまでプログラミングをして行く上で何度かあった、パッと世界が開ける瞬間を、再び味わう事ができました。

この本は私個人はサンプルとなるソースコードから、暗黙的な筆者の方のメッセージを強く感じました。例えば『3章 アプリケーションアーキテクチャ』『5章 データベース』『9章 テスト』等では、簡単なアプリを実装した上での解説があるのですが、そもそもの本題ではないアプリケーションの書き方に、強いポリシーを感じるのです。

その理論の詳細や意図は実は3章で語られてはいるのですが、私はほとんど理解できてないと思います。ただ、意図の様なものは強く伝わって来る。
そしてこの様なコードの書き方でアプリケーションを作る事の真意が、最終章では体感として理解できるものになっていたのかもしれません。

最後の『テスト駆動開発の実践』ではこれまでこの本で語られたノウハウが全て詰まった状態で簡単なアプリを実装してゆくのですが、まるでゲームの最終面で各面のボスが再度一気に攻めて来るアレの様な感覚ながらも、それを理解しながら、テンポの良い開発をサクサク行い、これまでは理解出来なかった処理の細かな分離などの理由が身をもって体感できました。

それはつまりテスト前提の開発とメンテナンス性ゆえの処理の細分化なのだな、というのが身をもってわかるのです。これはとても快感でした。

しかし、この快感をしってしまうと、自分がこれまで作ったアプリがいかにメンテナンス性の悪い拡張し辛くテストし辛いものであるのか、というのも見えてしまい。自分の作ったものが、エラいヤバいモノに見えてきてしまうのです。
なのでついつい中身を全部作り変えたい衝動に駆られますが、そこはぐっと堪えて、ここで得たノウハウを元に最小のリソースで如何に効果的に安定したものを早く供給できるか、に集中しようと思っています。

しかし、この快感までに辿り付けた輪読会メンバーは残念ながらあまり多くはありませんでしたが、その分、なんとなくゆるい結束力のようなものは出来たかもしれないですね。

次回以降も引き続きVue.jsの輪読会が始まるので、今度はフロント側をリッチにするノウハウを身に付けたいと思います。引き続き新たな知識をどんどん身に付けて、より良いものを作りたい次第。

【感想】知らないと失敗する ソーシャルメディアの黄金法則

こんな本を読んだのでその感想をば。

知らないと失敗する ソーシャルメディアの黄金法則: ?たった一つのメソッドがコミュニティをイキイキさせる? (MyISBN - デザインエッグ社)

知らないと失敗する ソーシャルメディアの黄金法則: ?たった一つのメソッドがコミュニティをイキイキさせる? (MyISBN - デザインエッグ社)

  • 作者: 福岡秀幸,古田英一朗,リンクデザイン株式会社,伊藤みゆき
  • 出版社/メーカー: デザインエッグ社
  • 発売日: 2019/01/01
  • メディア: オンデマンド (ペーパーバック)
  • この商品を含むブログを見る

実は私もソーシャルメディアでコミュニティ的なものを運営した経験があるし、逆に様々なコミュニティに参加してきているが、そこでの共通する法則がうまく言語化されている本です。
概要としては短編小説の体でソーシャルメディアの専門家が対話形式でそのノウハウや法則について詳しく話してくれる構成となってます。
ウェブやリアルに関わらずコミュニティを運営していたり、人が集まる事を価値とする仕事や趣味で携わる人が読むと、とても納得できる様な事が書いてありました。

各章の終わりに本のタイトルにもある法則の要約が書かれているのですが、それをズバリ書いてしまうと、本を読む楽しみが減ってしまうので、個人的な超要約でまとめると以下の3項目に集約されるかもしれません。

  1. 自分が盛り上げようと頑張っても息切れしてしまう
  2. 人を繋げる、紹介する、という事に気を使う
  3. 悪い事が起こらない様、最低限の行動やガイドライン作りのみ行う

私自身がコミュニティを作って維持しようとした際を振り返ってみるとスタートダッシュの際に1,番を極端に頑張って途中で息切れしてしまい、結果として2, をおろそかにして、トラブルが起こった際に3,の方法を取らず、やはり頑張り過ぎて疲れちゃったんだな。というのを、振り返って気付いた次第。

じゃあ、どうすればいいの?という事がこの本には書いてあります。ただ、ページ数も100ページ程で少々物足りなさがあるかもしれませんし、もしかしたらちょっと肩透かしをくらう様な結論かもしれませんが、コミュニティを運営してゆく上での心構えみたいなものが集約されていて、沢山の人の集まりを主催する方や、維持する方にとってはとても良い本ではないかと思います。

実は著者の方は知り合いだったりして、裏話を伺うとこの本は数年以上前に書かれて寝かせていたものだそうで、当時は今より勢いのあったSNSというもののウェブ上のコミュニティ運営のノウハウをご自身の経験から集約したものとなっているそうです。
たしかこの執筆の前後あたりにギークオフィスを立ち上げて、普段はウェブでコミュニティのメンバーがゆるく繋がり、リアルな集まる場所としてのオフィス兼イベントスペースとしての、コミュニティを現在も運営されています。
今流行りのコワーキングスペースの走りみたいなもので、それよりももっと人のつながりを重視するコミュニティ寄りなものを5年以上前からしてるので、かなり時代の先取り感ありますよね。

長い事寝かせておいて、今になって発売に漕ぎつけたのは、実は表紙や中のイラストを描いてくれる方が、ギークオフィスのメンバーなったから、というのも面白いエピソードだと思います。

実はこのブログで紹介した、私が作った滞在者確認アプリはこの方のニーズから生まれたサービスで、今後はこのようなリアルとウェブが融合したコミュニティの価値や存在がもっと大事になって、このような場所から様々なイノベーションが生まれる筈、という見解をもっており、私も完全同意しています。そしてそれは、最近徐々に形になりつつある。

が、もし今またこのような本を書いたら、ギークオフィスをはじめとしたさまざまなコミュニティの新たな見解やノウハウが山の様に溜まっているので、もっと面白い視点で色んな話を読めるかもしれません。
本人をその気にさせる為にも上のリンクからポチっとするのも良いかもしれませんね。

windows vagrant Homestead環境でLaravelアプリを追加する際の覚書

最近windows環境でLaravelを複数追加することがおおくなったので、メモを兼ねて記述しておきます。変な所あったらコメントもらえると助かります。

気を付ける事

まず Homestead.yamlの設定とhostsの設定からおこない、windows側と仮想環境側のフォルダが共有状態になる様に設定が出来るまでがんばる。
これを後回しにして .envの設定やコードとか書いてしまっても、場合によってはフォルダ内のアプリまるごと行方不明になるので、要注意。

Homestead.yamlの設定

# 例えば一つ目のアプリが以下の様にあった場合
folders:
    - map: C:/Vagrant/larabook
      to: /home/vagrant/larabook

sites:
    - map: larabook.test
      to: /home/vagrant/larabook/chapter09/public


Homestead.yamlの設定 二つ目のアプリを追加

---
ip: "192.168.10.10"
# 省略

# こんな風に設定を追加する
folders:
    - map: C:/Vagrant/larabook
      to: /home/vagrant/larabook

# 追加
    # map がwindows側のフォルダ、 larabook_tdd フォルダを作る事
    # to は仮想環境側のディレクトリ やはり larabook_tdd を mkdirする
    - map: C:/Vagrant/add_app_name
      to: /home/vagrant/add_app_name

sites:
    - map: larabook.test
      to: /home/vagrant/larabook/chapter09/public

#追加
    # map はローカル環境で使いたいドメインを指定、.dev .app は設定しない方が良い
    # to は上同様仮想環境側のディレクトリだが、laravelアプリ内のディレクトリ publicまでを指定する
    - map: add_app_name.test
      to: /home/vagrant/add_app_name/public


hostsの設定

windows10の場合以下のファイルを編集する。
最初は読み取り専用だとおもうので、書き込みできるようにwindowsのプロパティ等で設定する。
C:\Windows\System32\drivers\etc\hosts

host ファイル内の最後の行に以下の様に追加
yaml の最初にあるIPとローカル環境で使用したいyamlに設定したドメインを入れる
#でコメント入れられるので、何で追加したかメモとして残しておくと良い

192.168.10.10       larabook.test        # vagrant Laravel study setting
192.168.10.10       add_app_name.test     #Add vagrant Laravel study setting

設定をしたらvagrant 立ち上げてない場合は以下のコマンド
vagrant up

立ち上げてた場合は以下のコマンド(ssh で入っていた場合はいったんexitしてからコマンド)
vagrant provision
変更したyamlの設定ファイルの状態で再度vagrant が動作しますので、windows側、仮想環境側、両方のフォルダを開けて、どちらかにtest様にフォルダなりファイルなりを入れてみてください。
設定が上手くいっていれば、片方に入れたファイルが、もう片方の環境にも反映されるはずです。
上手いかなかった場合は yaml の設定が誤っているか、設定が再度読み込まれていないので、再度確認して、修正してから、 vagrant provisonを行って上手く行くまで試してみてください。
個人的にはこの設定が苦手で何度もやり直すことが多いです。

ファイルが両方の環境で無事共有できるようになったら、laravelをインストールなり、リポジトリクローンなりをして、該当のフォルダ内にLaravelアプリを入れれば設定完了です。

Laravel5.6インストール

以下のコマンドを仮想環境の /home/vagrant で実行する(Laravel5.6を入れる場合)

composer create-project --prefer-dist laravel/laravel add_app_name "5.6.*"

そうすると、一つ下の先ほどmkdirしたフォルダ内 /home/vagrant/add_app_name にアプリがインストールされます。
インストールが終わったら、ブラウザで http://add_app_name.test にアクセスして、すっぴんのLaravelの画面が出れば、設定完了!

しかし、更新しちゃうとマズいファイルはちゃんとエディタで色分けされてない。どうもこのインストール方法だとGitフォルダは作られていないので、add_app_name フォルダ内に移動してからgit init した方が良いですね。.gitignoreファイルは既にあるので、Gitが適用されれば編集しちゃいけないファイルは開発用エディタなら、色が薄く表示されるはずです。
あとはwindows側でエディタを開いて編集してごりごり.envの設定して開発を始める感じです。

【輪読会資料】達人に学ぶSQL徹底指南書第2版 第二部後半 読書メモ

以下の記事は2019/1/16 コワーキングスペース秋葉原Weeybleで行われる輪読会
[秋葉原] 達人に学ぶSQL徹底指南書 輪読会 第2部 魔法のSQL(第2部 RDBの世界) のための読書メモとなります。
以下の書籍の 第二部 9 18 GROUP BY と PARTITION BY ~ 23 SQLにおける存在の改装 のメモです。

達人に学ぶSQL徹底指南書 第2版 初級者で終わりたくないあなたへ (CodeZine BOOKS)

達人に学ぶSQL徹底指南書 第2版 初級者で終わりたくないあなたへ (CodeZine BOOKS)


前提として

私のSQLスキルはMySQLを前提として基本的な知識のみで、書籍としてSQL本を読むのは、リファレンスを除いてこれが2冊目となります。まだまだ知らない事だらけ、という事を前提に以下のメモを読んでいただければ幸いです。

18 GROUP BY と PARTITION BY

GROUP BY と PARTITION BYは似てる、ということだが、そもそもPARTITION BYを使った事がなった。

GOROUP BY は分けたあとに代表だけ表示
PARTITION BY は分けた後にそのまま全て表示

しかし、この2つはいずれも『指定されたキーで分割をしている』という事らしい、違いはGROUP BY はキーで分割後に『集約してまとめる』操作が入る。
これらで集合されたものは以下の特徴を持つ

  1. いずれも空集合でない
  2. ずべての部分集合の輪が分割する前の集合一致
  3. 互いにことなる任意の2つの部分集合が共通部分を持たない

集合論的にはパッキリ割れるし余りや重複も出ない。完全な割り当てをしてくれる。

この集合部分のひと塊を『類(るい)』と呼ぶ


群論SQL

MODという除算の余りを出す関数がSQLにはある(PHPだと %)これを使って、多数のデータのランダムサンプリング、もしくは、ほぼ同等のデータに振り分けをすることが簡単にできるので、MODでの振り分けは覚えておくと良いかもしれない。

SELECT MOD(num, 3) AS modulo,
    num
FROM Natural
ORDER BY modulo, num;

3で割った際の余りの数で振り分けをした例

modulo     num
--------   -----
0             0
0             3
0             6
1             1
1             4
1             7
2             2
2             5
2             8
...

当たり前だが多数のレコードのあるテーブルのユニークID(抜け漏れなし)を特定の数の余りで振り分けると、ほぼ同じ数でのグルーピングが可能となる。


19 手続き型から宣言型・集合指向へ頭を切り替える7箇条

はじめに

SQLはプログラミングにある手続き型の言語ではなく、宣言・集合型言語である、ここはこの概念を理解し生かすための実践ガイドとする章。

1. IF文やCASE文は、CASE式で置き換える。SQLはむしろ関数型言語と考え方が近い

MySQLにあるIFはむしろ特殊、またCASEは文(手続き)ではなく、一種の関数(数式の方の意味)としてとらえる

2. ループはGROUP BY句とウィンドウ関数で置き換える

SQLには文単位のループも存在しない。

3. テーブルの行に順序はない

テーブルはファイルよりも抽象度が高い。テーブルのレコードを『ファイル』の様に捉えない、テーブルは数学の「集合(set)」の一種である。 ビューにORDER BY を入れても意味はない、Oracleのrownum の様な考えも特殊な仕様ととらえる。

4. テーブルを集合とみなそう

テーブルの抽象性を理解するには自己結合を使ってみると良い。自己結合を使うと、好きな数だけ、集合を追加し、操作することができる。

5. EXISTS述語と「量化」の概念を理解する

SQLを支える理論は集合の他にも『述語論理』がある。「複数行を一単位として」扱う際、述語理論では『量化子』だが、SQLではEXISTSとなる。 また、使いこなす必要があるのは NOT EXISTSの方が大事。読みづらいがパフォーマンスが格段に良い。

6. HAVING句の真価を学ぶ

WHEREと異なりSQL集合論としてとらえる事が出来るHAVING, をもっと活用しよう HAVINGの練習をすると知らず知らずに集合論の本質の理解が進むでしょう。

7. 四角を描くな、円を描け

SQLは手続きではない四角と矢印の手順図とは概念がことなり、ベン図(〇の中に〇が現れる図)として集合でとらえる事が重要

感想

俄然なにが筆者をここまで執拗にSQL集合論を書かせるに至ったのかに興味がわいてきた。


20 神のいない理論

SQLの元になる論理学の話。従来は真と偽になる2値原理の古典論理が、1920年代に新たな3値論理が生まれSQLに生かされたらしい。

汝、場合により命題の真偽を捨てよ

3値論理学の体形をはじめて作ったのはポーランドの論理学者J.ウカシェヴィッツ関数型言語ポーランド記法「3+2」を「+3 2」を考案した人
論理学で真偽の二つ以外にも、「わからない、未知」となる概念の存在を提案した。

論理学の革命

神、宗教が支配していた時代は神による論理が主となる、神は全てを知っており、全ての真偽を知っている。という思想から、2値原理を当然としていたが、人間は全ての真理を知らないし、神に全てを訪ねも答えてくれる訳ではないので、「知らない、未知」という状態を論理学として取り入れるべき、という流れが出て来た。そもそもこのような提案ができたのは、宗教による支配が弱くなった時代背景もある。

人間の為の論理

データベースを扱うのは神ではなく人間なので、人間の認知や知識を表現するのに適した論理である3価理論(NULL, unknown)が採用された。 しかし、皮肉にもそれによって、人間の直感に反する奇妙な論理計算を導入せざるを得なくなった。


21 SQL再帰集合

集合のなかに集合を含む入れ子の集合、「再帰集合」の扱いを知るのは重要という話。

実務の中の再帰集合

そもそもノイマンは何故自然数再帰集合で定義しようとしたのか?(P57)

ノイマンによる自然数帰納的定義

0 = ∅
1 = {0}
2 = {0, 1}
3 ={0, 1, 2}
...

ノイマンの先輩たち

ノイマン以前に自然数を集合で定義した数学者、哲学者がいる。
ゴットローブ・フレーゲ 哲学者 述語理論をほぼ独力で創始した
エルンスト・ツェルメロ 数学者 集合論の体系整備、整列可能定理と選択公理

それぞれの自然数の機能的定義

自然数 ノイマン ツェルメロ型 フレーゲ
0 {∅}
1 {∅} {∅} {∅,{∅}}
2 {∅,∅} {{∅}} {∅,{∅},{∅,{∅}}}
3 {∅,∅,∅} {{{∅}}} {∅,{∅},{∅,{∅}}{∅},{∅,{∅}}}}
...

疑問

  1. 自然数の定義がこんなにたくさんあっていいのか?定義というのは普通1つなのでは?
  2. 何で自然数の定義に「集合」を使おうと思ったのか?

数とは何か?

ペアノの自然数の5つの公理

  1. 最初の数が存在する
  2. 任意の自然数aはその後者が存在する
  3. 最初の数はいかなる自然数の後者でもない
  4. 異なる自然数は異なる後者を持つ
  5. 最初の数がある性質を満たし、aがある性質を満たせばその後者もその性質を満たすとき、すべての自然数はその性質を満たす

ある自然数の次を数える関数を、後者関数と呼び suc(x) とかく。

0 = 0
1 = suc(0)
2 = suc(1) = suc(suc(0))
3 = suc(2) = suc(suc(suc(0)))
...

ノイマンペアノの自然数の定義に見合う構成方法を考えたといえる。 これにより、自然数を構成する材料として∅を使う必要もなくなった。

0: λfx.x
1: λfx.fx
2: λfx. f(fx)
3: λfx.f(f(fx))

正直この辺までくるとちょっとよくわからない…

SQLの魔術と科学

ランキング算出のクエリの理屈にこういうものがある、と把握することによって世界が広がり理解が深まるのではないだろうか?


22 NULL撲滅委員会

実務に置いて厄介なNULLにどう対処していけばよいかの指針を提案している

決意表明~スベテノDBエンジニアニ告グ~

(デザイナーにおけるIE死ね死ね団的なノリノリである) NULLは人間の感覚的にはわかりやすく設計段階でついついいれてしまうが、システムが複雑化するととても厄介なものになる。そこでここではより具体的な提案をまとめた章となっている。

なぜNULLがそんなに悪いのか   

  1. SQLのコーディングにあたり、人間の直感に反する3値論理を考慮しないといけない
  2. IS NULL , IS NOT NULL を指定する場合、インデックスの利用に制限が入りパフォーマンスが低下する
  3. 四則演算またはSQL関数の引数にNULLが含まれると「NULLの伝播」が起こる
  4. SQLの結果を受け取るホスト言語において、NULLの組み込み方が標準化されていない。また、DBMS間でもNULLの扱いに関する仕様が不統一
  5. 通常の列の値と異なり、NULLは行のどこかに余分なビットを持つことで実装されている。記憶領域の圧迫や検索パフォーマンス悪化の要因となる。
  6. NULLを含むカラムに作成するユニークインデックスの「ユニーク」の意味がRDBMSで異なる。例:複数のNULLを含む列にユニークインデックスを作成する際、エラーになったりならなかったり。
  7. NULLは値ではない為、ORDER BY 句によるソートの際のルールを意識する必要がある。NULLは定義含まれないが、実際は最大値か最小値として扱われ、実装によってデフォルトが異なるのでややこしい事になる。

もっとも忌むべき理由は1.
また3.も厄介 四則演算にNULL が入ると計算結果がNULLとなってしまう。
4~7は仕様違いによる厄介さ

しかしNULLを完全に排除することはできない

しかし、実務では『重要でない列にNULLが入ってくるのは目をつむる』位が実際の運用でのルールになっている。
カラムの制約でNOT NULL をしたとしても、外部結合や CUBE,ROLLUP付きのGROUP BY句を使うとNULLが入り込んでしまう。

コッドさんはNULL撲滅の最右翼、著者もそこに近い所に行きたい心情ではあるが、エンジニアの現実感覚として以下の方針を提案している

NULLは薬、用法容量を守って使う。使わざる得ない時のみに使う

次からは具体的なNULL排除の指針を提案してゆく

コードの場合ーー未コード化用コードを割り振る

true,false の様な2つの値のみ入るレコードは3つ目の値、未定義などに、NULLではなく、数字を当てはめると良い
例えば性別
1:男性 2: 女性 3:未知 9:適用不能 といった具合

例えばここに人ではなく、企業アカウントとしてレコード登録する際は 9:適用不能 を選択させる コッドの提案した『未知』と『適用不能』を値として設ける方法。

また、不明なレコードを入れなければならない場合、例えば数値として9999を入れるのではなく、そのカラムでは普段は使わない文字列 XXXX などを使うと良いだろう。 9999 という数字を持つユーザーが実際に存在する可能性があるなら数値は避けるべき。

名前の場合ーー「名無しの権兵衛」を割り振る

不明を表すデフォルトの値を入れると良い『不明』『UNKNOWN』など開発チーム内で共通了解の取られたものを入れると良い

数値の場合ーー0で代替する

NULLを0に変換してDBへ登録すると良い。筆者が実務で困った事は経験上あまりないらしい。
しかし、どうしても0とNULLを区別したい時だけNULLを許可すると良い。

日付の場合ーー最大値・最小値で代替する

日付が開始日や終了日を意味する場合は「0001-01-01」「9999-12-31」等の最大値、最小値を使うと良い。
しかし、デフォルト値がそもそもわからない誕生日など「未知」のNULLに相当する場合はNULLを許可しても良いでしょう。

指針のまとめ

  1. まずデフォルト値を入れられないか検討する。
  2. どうしようもない場合だけNULLを許可する。

というのが筆者の指針となる。


23 SQLにおける存在の階層

GROUP BY を使って集約をすると、集約キーを除いて、元のテーブルの列をそのまま参照できなくなるが、これは存在の階層を厳密に区別するSQL理論の現れ、ここからSQLの本質にせまる。

述語論理における階層、集合論における階層

P84 EXISTS述語の使い方を復習 EXISTSは高階関数である
2階 テーブルの集合
1階 テーブル(行集合)
0階 行

実はGROUP BY 句はEXISTS同様に階層がある。

なぜ集約すると、もとのテーブルの列を参照できなくなるのか?

以下の様なテーブルがあり、チームごとの平均年齢を出すクエリを考える

Teams

member team age
大木 A 28
逸見 A 19
新藤 A 23
山田 B 40
久本 B 29
橋田 C 30
...
SELECT team, AVG(age)
 FROM Tems
GROUP BY team;

これはちゃんと出力されるが、 次のはエラーになる。

-- チーム単位に集約するクエリ?
SELECT team, AVG(age), age
 FROM Tems
GROUP BY team;

理由はSELECT句に追加されたage 列を選択する事が出来ない為。 標準SQLではテーブル集約をした際、SELECT句に書ける要素は以下となる。

  1. GROUP BY句で指定した集約キー
  2. 集約関数(SUM, AVGなど)
  3. 定数

上記のエラーになったクエリの場合、 age は集約できない個人の年齢を差している。 個人の年齢は集約された際の「集団についての属性」ではない、当たり前だが、集団の統計属性なら出力できるが、個人は出力できない。と考えると良い。 過去のMySQLでは特別に可能だったが8.0以降ではやはりエラーになる様になった

GROUP BY で集約を行うと、SQLが扱う「行」という0階の存在から、「行の集合」という1階の存在に変化するため、「行」の属性は参照不能となる。

つまり、GOROUP BY で集団化されたものに、個人の〇〇を訪ねても駄目、という風に解釈すると良い。

もし、上記のクエリで名前を尋ねたい、とするなら仮に以下の様に MAXのmemberを求めるしかない

-- 正常
SELECT team, AVG(age), MAX(member)
 FROM Tems
GROUP BY team;

このクエリを応用すると「チームでの最高齢の年齢の人物」を求めるクエリも書ける。

-- チーム最高齢者も出力
SELECT team, MAX(age)
    (SELECT MAX(member)
      FROM Teams T2
      WHERE T2.team = T1.team
      AND T2.age = MAX(T1.age)
    ) AS oldest
FROM Tems T1
GROUP BY team;
team   max(age)    oldest
------   ----------   ---------
A         28              大木
B         40              山田
...

通常、WHERE句で集約関数は使えないが、このサブクエリ内では使える。MAX(T1.age) 理由は外側のT1テーブルを集約(1行目の MAX(age)かな? )したことによって、SELECT句集約関数が参照可能になる為。 その代わり今度は逆にサブクエリ内で age を裸で利用することはできない。つまり集約したルールはサブクエリと外のクエリで合わせなら利用できる。という事らしい。

単元集合も立派な集合です

GROUP BYをする際、そもそも、元のレコードでも1要素しかないものを集約した場合、どう動くか? (例となるTeamテーブルにはCチームに1メンバーのみのレコードがある)

実はこれも1単位でも1グループとして扱う。やはり階が0階から1階上がるという事、なので注意しましょう。ということ。

【輪読会資料】PHPフレームワーク Laravel Webアプリケーション開発 9章テスト 9-3『WebAPIテスト』

以下の記事は2019年1月10日、コワーキングスペース秋葉原Weeybleにて行われる [秋葉原] Laravel Webアプリケーション開発 輪読会 (9章 テスト)の輪読会資料の一部となります。 今回は 9-3 章部分の『WebAPIテスト』部分の記事をアップします。

また、元になっている書籍は以下となります。

PHPフレームワーク Laravel Webアプリケーション開発 バージョン5.5 LTS対応

PHPフレームワーク Laravel Webアプリケーション開発 バージョン5.5 LTS対応

  • 9-3 WebAPIテスト
    • 9-3-1 WebAPIテスト機能
    • 9-3-2 テスト対象のAPI
    • 9-3-3 APIテストの実装
    • 9-3-4 WebAPIテストに便利な機能

9-3 WebAPIテスト

WebAPIを検証するテストコードの実装

ここではよりユニットテストより粒度の高い大きいフィーチャーテストの解説となる
LaravelではHTTPリクエストをシミュレートしてテストする機能が用意されている、この機能を利用した例の説明となる。


9-3-1 WebAPIテスト機能

まず下記にあるような単純なAPIを扱う
routes\api.php抜粋

<?php
Route::get('/ping', function () {
    return response()->json(['message' => 'pong']);
});

/api/ping にアクセスすると json [message => 'pong'] が得られるごく単純なAPIとなっている

$ curl  http://larabook.test/api/ping
{"message":"pong"}


テストクラスの生成

テストクラスの生成は artisan make:testコマンドを使う。コマンドにクラス名指定で、 test/Feature/ 以下にテストクラスファイルが生成される。 --unit オプションは使わないので注意

9.3.1.3 フィーチャーテストクラスの生成例

$ php artisan make:test Api/PingTest
Test created successfully.

生成されたファイル tests\Feature\Api\PingTest.phpコマンドでパスを切った場合はディレクトリもよろしく作られる 9.3.1.4 生成されたフィーチャーテストクラスの名前空間

<?php
// 名前空間の生成もフィーチャーテストとしてよろしくやってくれる
namespace Tests\Feature\Api;

use Tests\TestCase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;

// 省略


HTTPリクエストの送信

HTTPリクエストのテスト実施は、疑似的なHTTPリクエストを送信するメソッドを利用する。 callメソッドはHTTPリクエストを疑似的に送信してくれる。

9.3.1.5 callメソッドの定義

<?php
public function call {
                        // 以下使用の際の引数の順序となっている?
    $method,            // HTTPメソッド
    $uri,               // URI
    $parameters = [],   // 送信パラメータ
    $cookies = [],      // cookie
    $files = [],        // アップロードファイル
    $server = [],       // サーバパラメータ
    $content = null     // RAWリクエストボディ
}

9.3.1.6 callメソッドの実行例

<?php
// GETリクエスト クエリストリング
$response = $this->call('GET', '/api/get?class=motogp&no=99');

// GETリクエスト $parameters
$response = $this->call('GET', '/api/get', [
    'class' => 'motogp',
    'no'    => '99'
]);

// POSTリクエスト
$response = $this->call('POST', '/api/post', [
    'email'    => 'a@example.com',
    'password' => 'secret-password',
]);

また、jsonの場合は以下の定義となる

9.3.1.7 jsonメソッドの定義

<?php
public function json($method, $uri, array $data = [], array $headers = [])

第3引数のリクエストヘッダは自動でも設定される

call,jsonメソッドにはHTTPメソッド毎のラッパー関数が用意されている。 通常はラッパーメソッドを利用し、細かなパラメーター指定が必要な場合にcall,jsonメソッドを利用すると良い。

メソッド 送信する疑似HTTPリクエス
get(\$uri, /$headers = ) GETリクエス
getJson(\$uri, \$headers = ) GETリクエスト(JSON)
post(\$uri, \$data, \$headers = ) POSTリクエス
postJson(\$uri, \$data, \$headers = ) POSTリクエスト(JSON)
put(\$uri, \$data, \$headers = ) PUTリクエス
putJson(\$uri, \$data, \$headers = ) PUTリクエスト(JSON)
patch(\$uri, \$data, \$headers = ) PATCHリクエス
patchJson(\$uri, \$data, \$headers = ) PATCHリクエスト(JSON)
delete(\$uri, \$data, \$headers = ) DELETEリクエス
deleteJson(\$uri, \$data, \$headers = ) DELETEリクエスト(JSON)


HTTPレスポンスのアサーション

送信したHTTPリクエストの結果検証をするアサーションメソッドがある。 Tests\TestCaseクラスにもあるが、 Illuminate\Foundation\Testing\TestResponceクラスの方が特化したアサーションの記述が容易なので作者はこちらを薦めている。

Illuminate\Foundation\Testing\TestResponceクラスの主なアサーションメソッドは以下 9.3.1.10 レスポンスをアサーションする主なメソッド(抜粋)

メソッド 内容
assertStatus(\$status) HTTPステータスコードが引数と一致していれば成功
assertSuccessful() HTTPステータスコードが2XXなら成功
assertRedirect(\$url = null) 次のいずれか 201,301,302,303,307,308 かつ、ヘッダのuriの値が app('uri)->to(\$uri) の値と一致すれば成功
assertHeader(\$headerName, \$value = null) レスポンスヘッダが存在(\$valueがnullの場合)もしくは該当ヘッダの値が$valueと一致すれば成功)
assertExactJson(array \$data, \$strict = false) レスポンスボディのJSONをデコードした配列が$dataと一致すれば成功
assertJson(array \$data, \$strict = false) レスポンスボディのJSONをデコードした配列に$dataが含まれていれば成功

以上を反映した/api/pingAPIテストの例は以下の通り

9.3.1.11 ping APIのテストクラス

<?php
declare(strict_types=1);

namespace Tests\Feature\Api;

use Tests\TestCase;

class PingTest extends TestCase
{
    /**
     * @test
     */
    public function get_ping()
    {
        $response = $this->get('/api/ping');
        // HTTPステータスコードを検証
        $response->assertStatus(200);
        // レスポンスボディのJSONを検証
        $response->assertExactJson(['message' => 'pong']);
    }
}

テストしてみる

$ ./vendor/bin/phpunit tests/Feature/Api/PingTest.php
PHPUnit 6.5.9 by Sebastian Bergmann and contributors.

.                                                                   1 / 1 (100%)

Time: 1.26 seconds, Memory: 12.00MB

OK (1 test, 2 assertions)

このようにWebAPIのテストでは疑似的なリクエスト送信をしてレスポンスが検証可能となっている。


9-3-2 テスト対象のAPI

9-2 で行った「データベーステスト」のポイント加算処理を利用して、ポイント加算を行うWebAPIのテストを通じてフィーチャテストの解説。

WebAPIの概要
リクエスJSONに含まれるcustomer_idに該当する顧客に対して add_point で指定したポイントを加算する

9.3.2.2 ポイント加算APIのクラス構成(左下4クラスはDBテストで使用したもの)

f:id:sakamata:20190109035018p:plain

各クラスの概要説明、ソースコードクラス名のリンクをクリックで閲覧可能

AddPointActionクラス app\Http\Actions\AddPointAction.php HTTPに関する処理とユースケースの実行を担う
API処理の親ファイル バリデートを __invoke の引数にフォームリクエストを入れる事で行う。また、 AddPointUseCaseクラスをコンストラクトインジェクションして処理を引っ張ってきてる

AddPointRequestクラス app\Http\Requests\AddPointRequest.php 要はバリデートの為のフォームリクエストファイル customer_idadd_point'required|int' のバリデートをしている

9.3.2.5 ルーティングの追加(routes/api.php)

<?php
// 追加
use App\Http\Actions\AddPointAction;
// 中略
Route::put('/customers/add_point', AddPointAction::class);

AddPointActionクラスはのメソッドは __invoke しているので、クラス名のみの指定となっている

コントローラーやアクションクラスは標準設定だと App\Http\Controller以下に置くことが必要だが、以下の設定を変更することでカスタマイズできる。

9.3.2.6 app\Providers\RouteServiceProvider の設定変更

<?php
// before
// protected $namespace = 'App\Http\Controllers';

// after
protected $namespace = ''; // 空文字にする

AddPointUseCaseクラス app\UseCases\AddPointUseCase.php

配下のAddPointService, EloquentCustomer, EloquentCustomerPointをコンストラクタインジェクションする。
runメソッドで カスタマーの情報(\$customerId, \$pointEvent, \$addPoint, \$now)を引数に、ポイント加算処理を走らせ、返り値として保有したcuntomer_pointtableのpointを返す。

PreConditionException

検証に失敗した際は、app\Exceptions\PreConditionExceptionにtrow そちらに処理は書かれてないが、下記のapp\Exceptions\Handler側でPreConditionExceptionが例外instanceされた際ステータス400とエラーメッセージを出力させる。

app\Exceptions\Handler

9.3.2.9 app\Exceptions\Handlerクラスによるエラーレスポンスの設定(抜粋)

<?php
    /**
     * Render an exception into an HTTP response.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Exception  $exception
     * @return \Illuminate\Http\Response
     */
    public function render($request, Exception $exception)
    {
        if ($exception instanceof PreConditionException) {
            return response()->json(['message' => trans($exception->getMessage())], Res::HTTP_BAD_REQUEST);
        }

        return parent::render($request, $exception);
    }

では以上のAPIを実際に使用してみる。 http://larabook.test/api/customers/add_pointにcostomer_id = 1 が 10point Addするcurlコマンドを叩く

$ curl -v -H "Accept: application/json" -H "Content-type: application/json" -X PUT -d \
'{"customer_id":1,"add_point":10}' http://larabook.test/api/customers/add_point -w "\n"

9.3.2.10 curlコマンドによる実行例

vagrant@homestead:~/lalabook/chapter09$ curl -v -H "Accept: application/json" \
-H "Content-type: application/json" -X PUT -d \
'{"customer_id":1,"add_point":10}' \
 http://larabook.test/api/customers/add_point -w "\n"
*   Trying 192.168.10.10...
* TCP_NODELAY set
* Connected to larabook.test (192.168.10.10) port 80 (#0)
> PUT /api/customers/add_point HTTP/1.1
> Host: larabook.test
> User-Agent: curl/7.58.0
> Accept: application/json
> Content-type: application/json
> Content-Length: 32
>
* upload completely sent off: 32 out of 32 bytes
< HTTP/1.1 200 OK
< Server: nginx/1.14.0 (Ubuntu)
< Content-Type: application/json
< Transfer-Encoding: chunked
< Connection: keep-alive
< Cache-Control: no-cache, private
< Date: Tue, 08 Jan 2019 09:00:50 GMT
< X-RateLimit-Limit: 60
< X-RateLimit-Remaining: 59
<
* Connection #0 to host larabook.test left intact
{"customer_point":110}

ステータスはHTTP/1.1 200 OKとなる。最後の行にcustomer_pointが110に増加したjsonが返ってきているのが確認できる
当然だが同じコマンドを叩く都度DBが更新され、customer_pointが10増加する

次にエラーとなる値で確認 add_point = 0 でPUTしてみる

vagrant@homestead:~/lalabook/chapter09$ curl -v -H "Accept: application/json" -H "Content-type: application/json" -X PUT -d '{"customer_id":1,"add_point":0}' http://larabook.test/api/customers/add_point -w "\n"
*   Trying 192.168.10.10...
* TCP_NODELAY set
* Connected to larabook.test (192.168.10.10) port 80 (#0)
> PUT /api/customers/add_point HTTP/1.1
> Host: larabook.test
> User-Agent: curl/7.58.0
> Accept: application/json
> Content-type: application/json
> Content-Length: 31
>
* upload completely sent off: 31 out of 31 bytes
< HTTP/1.1 400 Bad Request
< Server: nginx/1.14.0 (Ubuntu)
< Content-Type: application/json
< Transfer-Encoding: chunked
< Connection: keep-alive
< Cache-Control: no-cache, private
< Date: Tue, 08 Jan 2019 09:15:55 GMT
< X-RateLimit-Limit: 60
< X-RateLimit-Remaining: 59
<
* Connection #0 to host larabook.test left intact
{"message":"add_point should be equals or greater than 1"}

ステータスがHTTP/1.1 400 Bad Requestとなって、最後の行にエラーメッセージが返信されてきているのが確認できる。


9-3-3 APIテストの実装

テストでは状況別で以下の4つのケースを実装する

  • ポイント加算が正常完了する
  • バリテーションエラーになる
  • add_pointが事前条件エラーになる
  • customer_idが事前条件エラーになる

以下のクラスにテストを実装してゆく
tests\Feature\Api\AddPointTest

9.3.3.1 tests\Feature\Api\AddPointTestクラス(準備部分を抜粋)

<?php
declare(strict_types=1);

namespace Tests\Feature\Api;

use App\Eloquent\EloquentCustomer;
use App\Eloquent\EloquentCustomerPoint;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class AddPointTest extends TestCase
{
    use RefreshDatabase; // <---(1)

    const CUSTOMER_ID = 1;

    protected function setUp()
    {
        parent::setUp();
        //(1-2)
        Carbon::setTestNow();

        // (2) テストに必要なレコードを登録
        factory(EloquentCustomer::class)->create([
            'id' => self::CUSTOMER_ID,
        ]);
        factory(EloquentCustomerPoint::class)->create([
            'customer_id' => self::CUSTOMER_ID,
            'point'       => 100,
        ]);
    }

    // 以下にテストを追加して行く

}

(1) DBを利用しているので use RefreshDatabase; をしている
(1-2)Carbon::setTestNow();テスト用の日時を生成している
PHPで日付時刻の処理を書くなら Carbon がおすすめ より引用

setTestNow() はCarbonが基準とする現在の日時をモックで設定できる機能です。つまりロジック内でCarbonを適切に扱うことで時間に関するテストをユニットテストでも書けるようになります。

(2)テストに必要な値は9.2.2.7同様 factoryを使用して準備する。

正常ケース

APIで100point保持の会員に10point追加した際の動作を確認するシナリオ。
ここまでくると、だいたいパッと見て何やってるか判断できる位にはなりたい所。

9.3.3.1 tests\Feature\Api\AddPointTestクラス(正常ケースのテスト部分を抜粋)

<?php
    /**
     * @test
     */
    // 正常ケースのテスト
    public function put_add_point()
    {
        // (3) API実行
        $response = $this->putJson('/api/customers/add_point', [
            'customer_id' => self::CUSTOMER_ID,
            'add_point'   => 10,
        ]);

        // (4) HTTPレスポンスアサーション
        $response->assertStatus(200);
        $expected = ['customer_point' => 110];
        $response->assertExactJson($expected);

        // (5) データベースアサーション
        $this->assertDatabaseHas('customer_points', [
            'customer_id' => self::CUSTOMER_ID,
            'point'       => 110,
        ]);
        $this->assertDatabaseHas('customer_point_events', [
            'customer_id' => self::CUSTOMER_ID,
            'event'       => 'ADD_POINT',
            'point'       => 10,
            'created_at'  => Carbon::now(),
        ]);
    }


バリテーションエラーとなるケース

9.3.3.2 バリテーションエラーのテストメソッド

<?php
    /**
     * @test
     */
    public function put_add_point_バリデーションエラー()
    {
        // (2) API実行 パラメーター無しでアクセスしてる
        $response = $this->putJson('/api/customers/add_point', [
        ]);

        // (3) HTTPレスポンスアサーション
        $response->assertStatus(422);
        $expected = [
            'message' => 'The given data was invalid.',
            'errors'  => [
                'customer_id' => [
                    'The customer id field is required.',
                ],
                'add_point'   => [
                    'The add point field is required.',
                ],
            ],
        ];
        $response->assertExactJson($expected);
    }

(3) responce 422 はLaravelのバリテーションエラーのステータスコードとなっている。 assertExactJsonメソッドで期待値と一致するかを検証)でエラーメッセージの検証をしているが、レスポンスボティ全体を検証できるメリットと、テストメソッドの可読性を上げることができる為に利用している。

逆にランダムな値を含み、全体一致が難しい値等の検証は。assertJsonメソッド(配列で指定した値が含まれているか検証)を使う

9.3.3.3 assertJsonメソッドでレスポンスJSONの一部を検証

<?php
    /**
     * @test
     */
    public function put_add_point_バリデーションエラー_errorsのみ検証()
    {
        $response = $this->putJson('/api/customers/add_point', [
        ]);

        $response->assertStatus(422);

        // errorsキーのみ検証
        $expected = [
            'errors' => [
                'customer_id' => [
                    'The customer id field is required.',
                ],
                'add_point'   => [
                    'The add point field is required.',
                ],
            ],
        ];
        $response->assertJson($expected);
    }

また、JSONで帰って来た値の中に検証すべきメッセージが存在するかを確認したい場合、レスポンスのJSONを配列に変換して、key別に検証する以下の方法もある
便利な方法なので覚えておくと良い

9.3.3.4 レスポンスボディのJSONを配列に変換して検証

<?php
    /**
     * @test
     */
    public function put_add_point_バリデーションエラー_キーのみ検証()
    {
        $response = $this->putJson('/api/customers/add_point', [
        ]);

        $response->assertStatus(422);

        // レスポンスボディJSONを配列に変換して検証
        $jsonValues = $response->json();

        $this->assertArrayHasKey('errors', $jsonValues);

        $errors = $jsonValues['errors'];
        $this->assertArrayHasKey('customer_id', $errors);
        $this->assertArrayHasKey('add_point', $errors);
    }


add_pointが事前条件エラーとなるケース

add_pointが0かマイナス値の場合正常なエラーメッセージが返るかを検証するテストメソッド
9-1-4 データプロバイダを使っている(配列で検証する値を複数用意して @dataProvider アノテーションで宣言《コメントにメソッド指定》)

復習: @dataProvider アノテーション 一見コメントだが、このメソッドの配列を使ってテストしろって指示出来る奴

<?php
    /**
     * @test
     * @dataProvider dataProvider_put_add_point_add_point事前条件エラー
     */

9.3.3.4 add_pointの事前条件エラーを検証するメソッド

<?php
    /**
     * @test
     * @dataProvider dataProvider_put_add_point_add_point事前条件エラー
     */
    public function put_add_point_add_point事前条件エラー(int $addPoint)
    {
        // (1) API実行
        $response = $this->putJson('/api/customers/add_point', [
            'customer_id' => self::CUSTOMER_ID,
            'add_point'   => $addPoint,
        ]);

        // (2) HTTPレスポンスアサーション
        $response->assertStatus(400);
        $expected = [
            'message' => 'add_point should be equals or greater than 1',
        ];
        $response->assertExactJson($expected);
    }

    public function dataProvider_put_add_point_add_point事前条件エラー()
    {
        return [
            [0],
            [-1],
        ];
    }


customer_idが事前条件とエラーとなるケース

customer_idが存在しないid(999)だった場合に正常なエラーが返るかをテストするメソッド
9.3.3.5 customer_idの事前条件エラーを検証するテストメソッド

<?php
    /**
     * @test
     */
    public function put_add_point_customer_id事前条件エラー()
    {
        // (1) API実行
        $response = $this->putJson('/api/customers/add_point', [
            'customer_id' => 999,
            'add_point'   => 10,
        ]);

        // (2) HTTPレスポンスアサーション
        $response->assertStatus(400);
        $expected = [
            'message' => 'customer_id:999 does not exists',
        ];
        $response->assertExactJson($expected);
    }

最後に該当ファイルのテストを実施してテストが正常に終わる事を確認

vagrant@homestead:~/larabook/chapter09$ ./vendor/bin/phpunit tests/Feature/Api/AddPointTest.php
PHPUnit 6.5.9 by Sebastian Bergmann and contributors.

........                                                            8 / 8 (100%)

Time: 3.15 seconds, Memory: 20.00MB

OK (8 tests, 20 assertions)

無事正常なテストとして完了した


9-3-4 WebAPIテストに便利な機能

最後にWebAPIテストに便利な機能の紹介
tests\Feature\Api\MiddlewareTest.phpにテストコードが記載されている


ミドルウェアの無効化

HTTPリクエスト送信時にルーティング等で設定されているミドルウェアを無効にできる。
withoutMiddleware メソッドに無効にしたいミドルウェアクラス名を指定すると実行されなくなる

9.3.4.1 ミドルウェアを無効にする

<?php
    /**
     * @test
     */
    public function TeaPotMiddlewareを無効()
    {
        $response = $this->withoutMiddleware(TeaPotMiddleware::class)
            ->getJson('/api/live');

        $response->assertStatus(200);
    }

ミドルウェアを無効にするには withoutMiddlewareを引数無しで実行するか use Illuminate\Foundation\Testing\WithoutMiddleware;を宣言する

9.3.4.2 全ミドルウェアを無効にする

<?php
    /**
     * @test
     */
    public function 全てのミドルウェアを無効()
    {
        $response = $this->withoutMiddleware()
            ->getJson('/api/live');

        $response->assertStatus(200);
    }

9.3.4.3 全ミドルウェアを無効にする(WithoutMiddlewareトレイト)

<?php
declare(strict_types=1);

namespace Tests\Feature\Api;

use Illuminate\Foundation\Testing\WithoutMiddleware;
use Tests\TestCase;

class WithoutMiddlewareTest extends TestCase
{
    use WithoutMiddleware;

    /**
     * @test
     */
    public function 全てのミドルウェアを無効()
    {
        $response = $this->getJson('/api/live');

        $response->assertStatus(200);
    }
}


認証

認証が必要なAPIテストには以下の二つの方法がある

tests\Feature\Api\AuthTest.phpにサンプルコードがある

テスト時は通常のリクエストと同様ミドルウェアで認証が実行される

9.3.3.4 認証トークンを送信する例

<?php
    /**
     * @test
     */
    public function guard_api()
    {
        // 認証ユーザーを事前に生成
        factory(User::class)->create([
            'name'      => 'Mike',
            'api_token' => 'token1'
        ]);

        // 認証トークンをリクエストヘッダに設定して送信
        $response = $this->withHeaders([
            'Authorization' => 'Bearer token1'
        ])->getJson('/api/user');

        $response->assertStatus(200);
        $response->assertJson([
            'name' => 'Mike',
        ]);
    }

9.3.4.5 actingAsメソッドによる認証ユーザー設定例

<?php
    /**
     * @test
     */
    public function actingAsで認証ユーザ設定()
    {
        // 認証ユーザーを事前に生成
        $user = factory(User::class)->create([
            'name'      => 'Mike',
            'api_token' => 'token1'
        ]);

        // ミドルウェアを無効にして、actingAsメソッドに認証ユーザを設定
        $response = $this->withoutMiddleware()
            ->actingAs($user)
            ->getJson('/api/user');

        $response->assertStatus(200);
        $response->assertJson([
            'name' => 'Mike',
        ]);
    }

これだとテストを実行するとコントローラーやアクションではAuth::user()等でユーザーを取得できるらしい


コンポーネントのモック(Fake)

MailやNotification等が絡んだ機能でそのままテストをしてしまうと、例えばテストでMailが送信されてしまうが、このようなコンポーネントをモックにすれば、ミドルウェアに接続しないでテストが可能となる。ここではMailのモッククラスを紹介する。

MailのモックはMailファサードのfakeメソッドを利用する。fakeメソッドを利用するとサービスコンテナに登録されているインスタンスがモッククラスに置き換わる

注意 : アサーションメソッドを利用するにはsendメソッドでメールを送信する際に Illuminate\Contracts\Mail\Mailableインターフェースを実装したクラスを引数に指定する必要がある

9.3.4.6 MailFakeのアサーションメソッド

メソッド 内容
assertSent 指定されたメールが送信された事を検証
assertNotSent 指定されたメールが送信されてない事を検証
assertNottingSent メールが送信されてない事を検証
assertQueued 指定されたメールがメールキューに登録された事を検証
assertNotQueued 指定されたメールが送信されてない事を検証
assertNottingQueued メールキューに登録されてない事を検証

9.3.4.7 アサーションメソッドで送信内容が検証できる送信例 (routes\api.php)

<?php
Route::post('/send-email', function (Request $request, Mailer $mailer) {
    // Mailableインターフェースを実装したクラス
    $mail = new \App\Mail\Sample();
    // sendメソッドにMailableインターフェースを実装したクラスを指定
    $mailer->to($request->get('to'))->send($mail);

    return response()->json('ok');
});

9.3.4.8 ファサードのfakeメソッドを利用した例 (tests\Feature\Api\MailTest.php抜粋)

<?php
    /**
     * @test
     */
    public function Mailファサードfakeを利用したテスト()
    {
        Mail::fake(); // <--- (1) MailFakeに置き換え

        $response = $this->postJson('/api/send-email-facade', [
            'to' => 'a@example.com',
        ]);

        $response->assertStatus(200);

        // (2) MailFakeを利用したアサーション
        // 第二引数が数値になると、送信された件数を検証する
        Mail::assertSent(Sample::class, 1);
        // (3) 送信した$mailableの値を検証
        // 第二引数をDIにしてメソッドを検証に使える、hasToは有無の検証なのでbool値が返る
        Mail::assertSent(Sample::class, function (Mailable $mailable) {
            return $mailable->hasTo('a@example.com');
        });
    }

別の方法で、モックを利用せずにメール送信を防ぐ方法もある。logを利用すると送信内容をログファイルに出力する。テスト実行時のみメールドライバを切り替えるには以下の行を追加する。

9.3.4.9 MAIL_DRIVERをlogに設定 (phpunit.xml抜粋)

    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="CACHE_DRIVER" value="array"/>
        <env name="SESSION_DRIVER" value="array"/>
        <env name="QUEUE_DRIVER" value="sync"/>
        <env name="DB_DATABASE" value="app_test"/>
        <env name="MAIL_DRIVER" value="log"/> <!-- arrayを logにする -->
    </php>

9章は以上となる

まとめ 感想

すげえ疲れた、そもそもテストをするにもそれに至るまで、元のアプリケーション実装を読み込む必要があった、しかし見慣れない書き方満載で、テストに行くまでに調べる事も多く理解するまでが大変だった。しかし理にかなった設計というものを垣間見た気がする。 おそらく筆者の方もテストの章を通じて、アプリケーションの設計思想を見せたかったのやもしれない。
とはいえ、現状なんとかコードを読むことは出来るが、自分で書ける気はまだしない。しかし処理を分離するとテストもし易くなるというメリットはなんとなくつかめた。
最初にフレームワークに触れた時も、当時のベタな書き方に対して、こんなの使いこなせる気がしない!と思ったものだが、やはりしばらくすれば慣れてくるのだろう。

この章の資料を作るにあたってコード以外の部分のスキルも身に付いた。 vagrantの環境追加に始まり開発環境でのsslchromeでも閲覧可能にする方法の確立にはじまったが、エディタにvscodeを使うようになってツールに助けられた部分も大きい。複数のファイル間の呼び出し元クラスへ簡単に渡り歩けてコードを確認できり、ターミナルでシームレスにコマンドを叩いてテスト動作を確認できたりしたのはメリットだった。また、plantUMLを初めて使ってみて、書籍を同じクラス図等が作れるようになったり、自分のアプリケーションのコードと見比べたりも簡単に出来たので、理解の助けになった気がする。ツールはやはり良いものを使うべきだなぁ。という事を実感。 そしてこの3記事に渡るMarkdownのテキストも、全てまとめるとここが今2000行目!かなりのボリュームとなった(ソースコードは書籍GitHubのコピペが大半で申し訳ないが...) 今後は当初の目的であった自分のアプリのテストをゴリゴリ書いてデグレしにくい体制を早く作りたい。

【輪読会資料】PHPフレームワーク Laravel Webアプリケーション開発 9章テスト 9-2『データベーステスト』

以下の記事は2019年1月10日、コワーキングスペース秋葉原Weeybleにて行われる [秋葉原] Laravel Webアプリケーション開発 輪読会 (9章 テスト)の輪読会資料の一部となります。 今回は 9-2 章部分の『データベーステスト』部分の記事をアップします。

また、元となっている書籍はこちらとなります。

PHPフレームワーク Laravel Webアプリケーション開発 バージョン5.5 LTS対応

PHPフレームワーク Laravel Webアプリケーション開発 バージョン5.5 LTS対応

  • 9-2 データベーステスト
    • 9-2-1 テスト対象のテーブルとクラス
    • 9-2-2 データベーステストの基礎
    • 9-2-3 Eloquentクラスのテスト
    • 9-2-4 サービスクラスのテスト
    • 9-2-5 モックによるテスト(サービスクラス)

9-2 データベーステスト

データベースを利用したテストコードの実装

DBを利用するテストでは以下の様な手間がかかる作業が多い

  • テスト用DBの設定
  • テスト用レコードの登録
  • 対象クラスの処理後のレコード検証

本節はこれらを解説する


9-2-1 テスト対象のテーブルとクラス

本節で使うDBを確認する。あるアプリケーションの一部を見立てた会員のポイントを加算する処理を例に解説する

テーブル構成

9.2.1.1 テーブル構成を示すER図

f:id:sakamata:20190107172145p:plain

各tableの概要

テーブル名 概要
customers ユーザー情報
customer_points ユーザーの現在ポイントを収納
customer_point_events ポイント変化値とイベント名を収納

処理シナリオ

  • customer_point_events テーブルで加算イベント追加
  • customer_points テーブルが保持するポイントを加算
  • 1, 2, を同一トランザクションで実施
  • 処理失敗の際はロールバック

実装クラス

9.2.1.5 ポイント加算処理のクラス構成

f:id:sakamata:20190107171333p:plain

以下、各classのコードの記述があるが省略、詳しくは書籍、もしくはリンク先GitHubのコードを参照の事

app\Services\AddPointService.php
Serviceクラス : add メソッドで、複数のtableにtransaction付きでデータを書き込む複数メソッドの実行が記述されている

app\Eloquent\EloquentCustomerPointEvent.php
extends Model : customer_point_eventstableと関連付けし、registerメソッドで各カラム(cuntomer_id, event, point, created_at)にデータをsaveしている

app\Eloquent\EloquentCustomerPoint.php
extends Model : addPointメソッドでcustomer_pointテーブルの該当 customer_id のpointを更新している

app\Model\PointEvent.php
Model : 処理に必要な個別の項目の定義(DBカラム)と、呼び出し定義(getで始まるメソッド)記述のみをしている


9-2-2 データベーステストの基礎

テスト実行前にDBの状態を整える必要がある。テストに影響を与える環境を常に同じ状態に整えてからテストを実行することが重要。

テスト用データベースを利用

テスト用のDBを用意する 9.2.2.1 テスト用DBの作成例(MySQL

$ mysqladmin create app_test
# これでcreate database できるの知らんかった…

テスト時はこちらを使うようphpunit.xmlで設定する。

9.2.2.2 テスト用DBの設定(phpunit.xml 抜粋)

    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="CACHE_DRIVER" value="array"/>
        <env name="SESSION_DRIVER" value="array"/>
        <env name="QUEUE_DRIVER" value="sync"/>
        <!-- ここでテスト用データベースを指定 -->
        <env name="DB_DATABASE" value="app_test"/>
        <env name="MAIL_DRIVER" value="log"/>
    </php>


テスト用トレイトの利用

RefreshDatabaseトレイトについて
テスト実行時に自動でマイグレーションを行わせるには、RefreshDatabaseトレイトを使う。使用には以下の様に use させる

<?php
use Illuminate\Foundation\Testing\RefreshDatabase;

class EloquentCustomerPointEventTest extends TestCase
{
 use RefreshDatabase;  // 自動でマイグレーション実行
 // (省略)
}

現在のDBをテスト開始時に自動で migrate:refresh してくれる。
自動で(入れ子の)トランザクションがされるので、テスト時の内容は全てロールバックされ元に戻る

他にもDB制御するテスト用トレイトはある。詳細は書籍を見てください。


Factoryでテスト用レコードの準備

テストに必要なレコードを登録するにはFactoryが便利
利用は以下のコマンド make:factory を使用する
引数は生成するFactory名を指定
命名ルールは [Eloquentクラス名 + Factory] にすると良い。

9.2.2.4 make:factoryコマンドの実行例

$ php artisan make:factory EloquentCustomerFactory
Factory created successfully.

上記コマンドで以下にファイルが生成される
database\factories\EloquentCustomerFactory.php

9.2.2.5 生成されたEloquentCustomerFactory

<?php

use Faker\Generator as Faker;

$factory->define(Model::class, function (Faker $faker) {
    return [
        //
    ];
});

9.2.2.6 EloquentCustomer用に変更したCuntomerFactory

<?php
declare(strict_types=1);
// use で該当のEroquentクラスを呼ぶ
use App\Eloquent\EloquentCustomer;

// 5章.データベース 5-2-4で使用したFaker(ダミーデータライブラリ)を使用   
use Faker\Generator as Faker;

// define の第一引数に対象のEloquentクラス名を指定
$factory->define(EloquentCustomer::class, function (Faker $faker) {
    return [
        // Eroquantに設定するプロパティを連想配列で指定,Fakerでそれっぽい人名が入る
        'name' => $faker->name,
    ];
});

ちなみに use で呼ばれる該当のEroquentクラス App\Eloquent\EloquentCustomer;の中身はお馴染みの Model = table名でがっつり定義してるこれ

<?php
declare(strict_types=1);

namespace App\Eloquent;

use Illuminate\Database\Eloquent\Model;
/**
 * @property int $id
 * @property string $name
 */
final class EloquentCustomer extends Model
{
    protected $table = 'customers';
}

9.2.2.7 factory関数の利用例

<?php
// cuntomerテーブルに1レコード登録
factory(EloquentCustomer::class)->create();

// cuntomerテーブルに1レコード登録(nameを指定)
factory(EloquentCustomer::class)->create([
    'name' => '太郎',
]);

// cuntomerテーブルに3レコード登録
factory(EloquentCustomer::class, 3)->create();


データベースのアサーション

テスト中に変更したDBのレコード検証には、DBのアサーションメソッドを利用すると良い。
以下は実行例、使い方を見たまんまで書いてある

9.2.2.8 データベースのアサーションメソッド例

<?php
// customersテーブルにid=1のレコードが存在すれば成功
$this->assertDatabaseHas('customers', [
    'id' => 1,
]);

// customersテーブルにid=100のレコードが存在しなければ成功
$this->assertDatabaseHasMissing('customers', [
    'id' => 100,
]);

上記の様なレコードの有無で検証できないケース、例えばレコード数を測りたい場合はEloquentやクエリビルダを利用して検証する

9.2.2.9 クエリビルダを利用したアサーション

<?php
//customerテーブルに5件のレコードがあれば成功
$this->assertSame(5, \DB::table('customers')->count());


9-2-3 Eloquentクラスのテスト

では実際にDBのテストを行って行く
EloquentCustomerPointEventクラスのテストを行う。
app\Eloquent\EloquentCustomerPointEvent.php
(customer_point_eventstableと関連付けし、registerメソッドで各カラム(cuntomer_id, event, point, created_at)にデータをsaveしている)

テスト対象は registerメソッド
テストの際の事前条件と事後条件を想定する - 事前条件: customersテーブルに対象レコードがある。 - 事後条件: customer_point_eventsにレコードが追加されている。

以下は実装したテストクラス

9.2.3.1 EloquentCustomerPointEventTestクラス(書籍はEloquentCustomerPointTestとあり校正ミス)

<?php
declare(strict_types=1);

namespace Tests\Unit\AddPoint;

use App\Eloquent\EloquentCustomer;
use App\Eloquent\EloquentCustomerPointEvent;
use App\Model\PointEvent;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class EloquentCustomerPointEventTest extends TestCase
{
    use RefreshDatabase; // <---(1)

    /**
     * @test
     */
    public function register()
    {
        $customerId = 1;
        // (2) テストデータ登録
        // name入れてないのはP405 9.2.2.6 factory側でFakerで指定されてる為っぽい
        factory(EloquentCustomer::class)->create([
            'id' => $customerId,
        ]);
        // テスト対象メソッドの実行
        // コンストラクタの引数(DBカラム名)をそのままの順番で入れてる
        $event = new PointEvent(
            $customerId,
            '加算イベント',
            100,
            Carbon::create(2018, 8, 4, 12, 34, 56)
        );
        // test対象のクラスをnewしてDBに登録
        $eloquet = new EloquentCustomerPointEvent();
        $eloquet->register($event);

        // (4) データベースレコードのアサーション
        // 今登録したレコードがあるかを確認
        $this->assertDatabaseHas('customer_point_events', [
            'customer_id' => $customerId,
            'event'       => $event->getEvent(),
            'point'       => $event->getPoint(),
            'created_at'  => $event->getCreatedAt(),
        ]);
    }
}

本には書いてないがテストを実施してみる

vagrant@homestead:~/larabook/chapter09$ ./vendor/bin/phpunit tests/Unit/AddPoint/EloquentCustomerPointEventTest.php
PHPUnit 6.5.9 by Sebastian Bergmann and contributors.

.                                                                   1 / 1 (100%)

Time: 8.45 seconds, Memory: 16.00MB

OK (1 test, 1 assertion)

8秒以上かかった。初回はテスト用のDB作成からmigrateも走るので無理もないかも。


次にEloquentCustomerPointクラスのテストの実装

app\Eloquent\EloquentCustomerPoint.php
addPointメソッドでcustomer_pointテーブルの該当 customer_id のpointを更新している

addPointメソッドは下記の事前条件と事後条件を想定している

  • 事前条件1: customersテーブルに対象のレコードがある
  • 事前条件2: customer_pointテーブルに対象のレコードがある
  • 事後条件: customer_pointテーブルの対象レコードにpointが加算されている

これも一つ前のテストとほとんど同じ構成となっている。違いとしては addPointで元の 100ポイントから、10ポイント加算で110ポイントになっているかをテストしている。

9.2.3.2 EloquentCustomerPointTestクラス

<?php
declare(strict_types=1);

namespace Tests\Unit\AddPoint;

use App\Eloquent\EloquentCustomer;
use App\Eloquent\EloquentCustomerPoint;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class EloquentCustomerPointTest extends TestCase
{
    use RefreshDatabase;

    /**
     * @test
     */
    public function addPoint()
    {
        // (1) テストに必要なレコードを登録
        $customerId = 1;
       // 不要と思い込んでましたが、こっちのtableのidがPKなので必要ですね。
        factory(EloquentCustomer::class)->create([
            'id' => $customerId,
        ]);
       // ここは必須
        factory(EloquentCustomerPoint::class)->create([
            'customer_id' => $customerId,
            'point'       => 100,
        ]);

        // (2) テスト対象メソッドの実行
        $eloquent = new EloquentCustomerPoint();
        $result = $eloquent->addPoint($customerId, 10);

        // (3) テスト結果のアサーション
        $this->assertTrue($result);

        $this->assertDatabaseHas('customer_points', [
            'customer_id' => $customerId,
            'point'       => 110,
        ]);
    }
}

同様にテストを実施してみた。今度は2秒程度。テスト用のtableが出来た状態からのテストなので早かった。

vagrant@homestead:~/larabook/chapter09$ ./vendor/bin/phpunit tests/Unit/AddPoint/EloquentCustomerPointTest.php
PHPUnit 6.5.9 by Sebastian Bergmann and contributors.

.                                                                   1 / 1 (100%)

Time: 2.38 seconds, Memory: 16.00MB

OK (1 test, 2 assertions)

このようにDBを利用するEloquentクラスのテストもFactoryやデータベースアサーションを利用することで手軽なテストができる。


9-2-4 サービスクラスのテスト

AddPointServiceクラスのテスト方法について

app\Services\AddPointService.php
add メソッドで、複数のtableにtransaction付きでデータを書き込む複数メソッドの実行が記述されている

addメソッドの事前条件と事後条件の想定は以下の通り

  • 事前条件1: customersテーブルに対象レコードがある
  • 事前条件2: customer_pointテーブルに対象レコードがある
  • 事後条件1: customer_point_eventsテーブルに対象レコードがある
  • 事後条件2: customer_pointsテーブルの対象レコードにpointが加算されている

9.2.4.1 AddPointServiceTestクラス

<?php
declare(strict_types=1);

namespace Tests\Unit\AddPoint;

use App\Eloquent\EloquentCustomer;
use App\Eloquent\EloquentCustomerPoint;
use App\Model\PointEvent;
use App\Services\AddPointService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\TestCase;

class AddPointServiceTest extends TestCase
{
    use RefreshDatabase;

    const CUSTOMER_ID = 1;

    protected function setUp()
    {
        parent::setUp();

        // (1) テストに必要なレコードを登録
        factory(EloquentCustomer::class)->create([
            'id' => self::CUSTOMER_ID,
        ]);
        factory(EloquentCustomerPoint::class)->create([
            'customer_id' => self::CUSTOMER_ID,
            'point'       => 100,
        ]);
    }


    /**
     * @test
     * @throws \Throwable
     */
    public function add()
    {
        // (2) テスト対象メソッドの実行
        $event = new PointEvent(
            self::CUSTOMER_ID,
            '加算イベント',
            10,
            Carbon::create(2018, 8, 4, 12, 34, 56)
        );
        /** @var AddPointService $service */
        $service = app()->make(AddPointService::class);
        $service->add($event);

        // (3) テスト結果のアサーション
        $this->assertDatabaseHas('customer_point_events', [
            'customer_id' => self::CUSTOMER_ID,
            'event'       => $event->getEvent(),
            'point'       => $event->getPoint(),
            'created_at'  => $event->getCreatedAt(),
        ]);
        $this->assertDatabaseHas('customer_points', [
            'customer_id' => self::CUSTOMER_ID,
            'point'       => 110,
        ]);
    }
}

テンプレートメソッドを使う際はparentで継承元メソッドを呼ぶことを忘れないようにする parent::setUp()

テストメソッド内に前提条件を書いても良いが、不明瞭になるので、setUpメソッドに事前条件を書くのが望ましい。

ここで、サービスクラスのインスタンス化をしてる?(この書き方良くわかってない)

<?php
/** @var AddPointService $service */
$service = app()->make(AddPointService::class);

テスト実行結果

vagrant@homestead:~/larabook/chapter09$ ./vendor/bin/phpunit tests/Unit/AddPointServiceTest.php
PHPUnit 6.5.9 by Sebastian Bergmann and contributors.

.                                                                   1 / 1 (100%)

Time: 2.37 seconds, Memory: 16.00MB

OK (1 test, 2 assertions)


9-2-5 モックによるテスト(サービスクラス)

一つ前の 9-2-4 と別で、EloquentクラスをモックにしてDBにアクセスせずに、サービスクラスの処理のみをテストする方法もある。

無名クラスを使って、テスト対象のクラスが読み込む子供のクラスをその場で使い捨て利用する感じ。

9.5.5.1 モックを利用したAddPointSErviceクラスのテスト

<?php
declare(strict_types=1);

namespace Tests\Unit;

use App\Eloquent\EloquentCustomerPoint;
use App\Eloquent\EloquentCustomerPointEvent;
use App\Model\PointEvent;
use App\Services\AddPointService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\TestCase;

class AddPointServiceWithMockTest extends TestCase
{
    use RefreshDatabase;

    private $customerPointEventMock;
    private $customerPointMock;

    protected function setUp()
    {
        parent::setUp();

        // (1) Eloquentクラスのモック化
        $this->customerPointEventMock = new class extends EloquentCustomerPointEvent
        {
            /** @var PointEvent */
            public $pointEvent;

            public function register(PointEvent $event)
            {
                $this->pointEvent = $event;
            }
        };

        $this->customerPointMock = new class extends EloquentCustomerPoint
        {
            /** @var int */
            public $customerId;

            /** @var int */
            public $point;

            public function addPoint(int $customerId, int $point): bool
            {
                $this->customerId = $customerId;
                $this->point = $point;

                return true;
            }
        };
    }

    /**
     * @test
     * @throws \Throwable
     */
    public function add()
    {
        // (2) テスト対象メソッドの実行
        $customerId = 1;
        $event = new PointEvent(
            $customerId,
            '加算イベント',
            10,
            Carbon::create(2018, 8, 4, 12, 34, 56)
        );
        $service = new AddPointService(
            $this->customerPointEventMock,
            $this->customerPointMock
        );
        $service->add($event);

        // (3) テスト結果のアサーション
        $this->assertEquals($event, $this->customerPointEventMock->pointEvent);
        $this->assertSame($customerId, $this->customerPointMock->customerId);
        $this->assertSame(10, $this->customerPointMock->point);
    }
}

個人的な解釈
テスト対象のAddPointService.phpの一部はこうなっている

<?php
    public function __construct(
        // 2人の子供を起こして使える様に設計されているので...
        EloquentCustomerPointEvent $eloquentCustomerPointEvent,
        EloquentCustomerPoint $eloquentCustomerPoint
    ) {
        $this->eloquentCustomerPointEvent = $eloquentCustomerPointEvent;
        $this->eloquentCustomerPoint = $eloquentCustomerPoint;
        $this->db = $eloquentCustomerPointEvent->getConnection();
    }

テストコードは無名クラスとしてDBにアクセスするEloquentCustomerPointEventEloquentCustomerPointsetUp()メソッド内でnewして privateプロパティにしている。

AddPointServiceWithMockTest.php抜粋

<?php
private $customerPointEventMock;
private $customerPointMock;

protected function setUp()
{
        // 省略
    $this->customerPointEventMock = new class extends EloquentCustomerPointEvent
    {
        // 省略
    }

    $this->customerPointMock = new class extends EloquentCustomerPoint
    {
        // 省略
    }
}

これをadd()メソッド内で、AddPointServiceをnewさせる際に、コンストラクタインジェクションの引数に読み込んで利用している。
AddPointServiceWithMockTest.php抜粋

<?php
        $service = new AddPointService(
            $this->customerPointEventMock,
            $this->customerPointMock
        );

なんとか理解はできたが、自分で書いてサクサク利用できるようになるには、時間がかかりそう。


【輪読会資料】PHPフレームワーク Laravel Webアプリケーション開発 9章テスト 9-1『ユニットテスト』

以下の記事は2019年1月10日、コワーキングスペース秋葉原Weeybleにて行われる [秋葉原] Laravel Webアプリケーション開発 輪読会 (9章 テスト)の輪読会資料の一部となります。 今回は 9-1 章部分の『ユニットテスト』部分の記事をアップします。

また、元になっている書籍は以下となります。

PHPフレームワーク Laravel Webアプリケーション開発 バージョン5.5 LTS対応

PHPフレームワーク Laravel Webアプリケーション開発 バージョン5.5 LTS対応

PHPフレームワーク Laravel Webアプリケーション開発 輪読会資料

Chapter 9 テスト

テストコード実装の基礎と実践

目次

  • 9-1 ユニットテスト
    • 9-1-1 テスト対象クラス
    • 9-1-2 テストクラスの生成
    • 9-1-3 テストメソッドの実装
    • 9-1-4 データプロバイダの活用
    • 9-1-5 例外のテスト
    • 9-1-6 テストの前処理・後処理
    • 9-1-7 テストの設定
  • 9-2 データベーステスト
    • 9-2-1 テスト対象のテーブルとクラス
    • 9-2-2 データベーステストの基礎
    • 9-2-3 Eloquentクラスのテスト
    • 9-2-4 サービスクラスのテスト
    • 9-2-5 モックによるテスト(サービスクラス)
  • 9-3 WebAPIテスト
    • 9-3-1 WebAPIテスト機能
    • 9-3-2 テスト対象のAPI
    • 9-3-3 APIテストの実装
    • 9-3-4 WebAPIテストに便利な機能


なお、説明中のサンプルコードは以下のリンクより取得したものです。
PHPフレームワーク Laravel Webアプリケーション開発 laravel-socym/chapter09 by GitHub

9-1 ユニットテスト

Lravelでサポートされるテスト機能


余談

いつも見るサンプルコードの冒頭の奴declare(strict_types=1);は、 厳密な型チェックモードに設定している。 これを記述することによって型指定をして、間違った値が入るとエラーを出すそうです。

9-1-1 テスト対象クラス

ここではポイント算出のメソッドを元にtestを行う
app\Services\CalculatePointService.php

<?php
declare(strict_types=1);

namespace App\Services;

use App\Exceptions\PreConditionException;

final class CalculatePointService
{
    /**
     * @param int $amount
     * @return int
     * @throws PreConditionException
     */
    public static function calcPoint(int $amount): int
    {
        if ($amount < 0) {
            throw new PreConditionException('購入金額が負の数');
        }

        if ($amount < 1000) {
            return 0;
        }

        if ($amount < 10000) {
            $basePoint = 1;
        } else {
            $basePoint = 2;
        }

        return intval($amount / 100) * $basePoint;
    }
}

要約すると以下の処理をするメソッド
買い物をした際に付く、〇〇ポイントの算出をする機能。以下のルールで行う

購入金額 ポイント
0~999 ポイント無し
1,000~9,999 100円につき1ポイント
10,000以上 100円につき2ポイント


9-1-2 テストクラスの生成

ユニットテストを記述するテストクラスは以下のコマンドで生成する

$ php artisan make:test CalculatePointServiceTest --unit
Test created successfully.

以下のパスにファイルが生成される
tests\Unit\CalculatePointServiceTest.php

9.1.2.4 生成されたCalculatePointServiceTestクラス

<?php

namespace Tests\Unit;

use Tests\TestCase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;

class CalculatePointServiceTest extends TestCase
{
    /**
     * A basic test example.
     *
     * @return void
     */
    public function testExample()
    {
        $this->assertTrue(true);
    }
}

ここで大事な補足

書籍にはないが、 Testクラスを作成したらテスト対象となるクラスを use させないと駄目。 今回のテスト対象はApp\Services\CalculatePointServiceなので以下の一行をTestファイルのクラス宣言前に追加する。

<?php

namespace Tests\Unit;

use Tests\TestCase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;
// この行を追加
use App\Services\CalculatePointService;

class CalculatePointServiceTest extends TestCase
{
    // 省略
}

リスト9.1.2.2 testsディレクトリの構成

tests
├─Feature                                // フィーチャ機能テストのディレクトリ
│  └─ExampleTest.php
├─Unit                                   // ユニットテストのディレクトリ
│  ├─CalculatePointServiceTest.php      // 生成されたテストクラス(これ以外はデフォルト)
│  └─ExampleTest.php
├──CreatesApplication.php
└──TestCase.php                         // テスト基底クラス

テストクラスを実装する際は Tests\TestCase クラスを継承することが多いが、フレームワークの機能を使わない場合は PHPUnit\Framework\TestCase クラスを直接敬称しても問題ない。

9.1.2.3 テストクラスのクラス図

f:id:sakamata:20190105192040p:plain

生成されたtestClassには以下の様な test... で始まるメソッドが作られる
test... から始まるメソッドがテストメソッドとして実行される。
9.1.2.4 抜粋 生成された CalculatePointServiceTest クラス

<?php
    public function testExample()
    {
        $this->assertTrue(true);
    }

別の方法でコメントに @test アノテーションを付ける方法もある。作者はこっちをお勧めしてる
参考: アノテーションについて
9.1.2.5 @testアノテーション

<?php
    /**
    * @test
    */
    public function Example()
    {
        $this->assertTrue(true);
    }

さらにアノテーションと、メソッド名を日本語にすると(え!?) test結果の判別が付けやすくなる

<?php
    /**
    * @test
    */
    public function divide_除数がゼロなら例外を投げる()
    {
        // (略)
    }

9.1.2.6 テストの実行例
phpunitコマンドに続けてテストクラスファイルを指定する

$ ./vendor/bin/phpunit tests/Unit/CalculatePointServiceTest.php
PHPUnit 6.5.9 by Sebastian Bergmann and contributors.

.                                                                   1 / 1 (100%)

Time: 1.94 seconds, Memory: 10.00MB

OK (1 test, 1 assertion)

9.1.2.8 テストの失敗例

vagrant@homestead:~/larabook/chapter09$ ./vendor/bin/phpunit tests/Unit/CalculatePointServiceTest.php
PHPUnit 6.5.9 by Sebastian Bergmann and contributors.

F                                                                   1 / 1 (100%)

Time: 1.04 seconds, Memory: 10.00MB

There was 1 failure:

1) Tests\Unit\CalculatePointServiceTest::Example
Failed asserting that false is true.

/home/vagrant/larabook/chapter09/tests/Unit/CalculatePointServiceTest.php:19

FAILURES!
Tests: 1, Assertions: 1, Failures: 1.

コマンドの際 phpunit コマンドの引数を省略すると 全テストが実行される。よく使うので覚えておくと良い

9.1.2.9 全てのテストの実行例

$ ./vendor/bin/phpunit

但し、アプリケーションの規模が大きい場合は、テスト実行時間が恐ろしい事になるので安易やらない方が良い。(Ruby on Railsでの経験則)


9-1-3 テストメソッドの実装

ポイント付与のサンプルコードを元にテストメソッドを実装する
ここでは、ポイント算出ルールの境界値に沿ってテストを記述する
まず、購入金額が0円のパターンを検査する
0円の場合ポイントが0になるか?

9.1.3.2 主なアサーションメソッド(抜粋)

メソッド 内容
assertSame 型も含めて期待値と値が一致するかを検証
assertTrue 値がtrueかどうかを検証
assertReqExp 値が正規表現にマッチするかどうかを検証
assertArrayHasKey 値が配列の場合、指定したキーが存在するかを検証

tests\Unit\CalculatePointServiceTest.phpに以下のメソッドを追加

<?php
    /**
     * @test
     */
    public function calcPoint_購入金額が0ならポイントは0()
    {
        $result = CalculatePointService::calcPoint(0);
        $this->assertSame(0, $result); // $result が0である事を検証
    }

テストを実行すると2つのtestが通る事を確認できる

vagrant@homestead:~/larabook/chapter09$ ./vendor/bin/phpunit tests/Unit/CalculatePointServiceTest
PHPUnit 6.5.9 by Sebastian Bergmann and contributors.

..                                                                  2 / 2 (100%)

Time: 4.43 seconds, Memory: 10.00MB

OK (2 tests, 2 assertions)

同様に今度は購入金額1000円でのテストを書く

<?php
    /**
     * @test
     */
    public function calcPoint_購入金額が1000ならポイントは10()
    {
        $result = CalculatePointService::calcPoint(1000);
        $this->assertSame(10, $result); // $result が10である事を検証
    }

同様にテストが3つ通る事を検証

vagrant@homestead:~/larabook/chapter09$ ./vendor/bin/phpunit tests/Unit/CalculatePointServiceTest
PHPUnit 6.5.9 by Sebastian Bergmann and contributors.

...                                                                 3 / 3 (100%)

Time: 1.03 seconds, Memory: 10.00MB

OK (3 tests, 3 assertions)

こんな感じでテスト項目を増やすって事らしい


9-1-4 データプロバイダの活用

テストメソッドは引数と戻り値の組み合わせのみだが、データプロバイダは同じ処理に対して、異なるパラメータや引数を渡してテストする事ができて便利。
テストメソッドに渡すパラメータを指定するメソッドを用意する
データプロバイダメソッドは public にする必要がある

9.1.4.1 データプロバイダメソッドの例

<?php
    public function dataProvider_for_calcPoint(): array
    {
        return [
            '購入金額が0なら0ポイント'       => [0, 0],
            '購入金額が999なら0ポイント'     => [0, 999],
            '購入金額が1000なら10ポイント'   => [10, 1000],
        ];
    }

データプロバイダを利用するには、テストメソッドに @dataProviderアノテーションを指定する。

9.1.4.2 データプロバイダを利用したテストメソッド

<?php
    /**
     * @test
     * @dataProvider dataProvider_for_calcPoint
     */
    public function calcPoint(int $expected, int $amount)
    {
        $result = CalculatePointService::calcPoint($amount);
        $this->assertSame($expected, $result); 
    }

仮に以下の値をあえて間違った上でテストを実行してみる '購入金額が1000なら10ポイント' => [0, 1000],

vagrant@homestead:~/larabook/chapter09$ ./vendor/bin/phpunit tests/Unit/CalculatePointServiceTest
PHPUnit 6.5.9 by Sebastian Bergmann and contributors.

.....F                                                              6 / 6 (100%)

Time: 1.34 seconds, Memory: 10.00MB

There was 1 failure:

1) Tests\Unit\CalculatePointServiceTest::calcPoint with data set "購入金額が1000なら10ポイント" (0, 1000)
Failed asserting that 10 is identical to 0.

/home/vagrant/larabook/chapter09/tests/Unit/CalculatePointServiceTest.php:59

FAILURES!
Tests: 6, Assertions: 6, Failures: 1.

1エラーとなりエラーとなった配列のキー(日本語)と、配列の組み合わせ(0,1000)が出力されるので、エラー詳細が明快になる。

より多くのテストパターンを実装、数値の境界部分の値を追加している
9.1.4.6 データプロバイダメソッドに要素を追加

<?php
    public function dataProvider_for_calcPoint() : array
    {
        return [
            '購入金額が0なら0ポイント' => [0, 0],
            '購入金額が999なら0ポイント' => [0, 999],
            '購入金額が1000なら10ポイント' => [10, 1000],
            '購入金額が9999なら99ポイント' => [99, 9999],
            '購入金額が10000なら200ポイント' => [200, 10000],
        ];
    }

こんな感じでデータプロバイダメソッドでは複数の値、パラメータでテストが行える。


9-1-5 例外のテスト

throw で投げられた例外処理をテストするには、以下の利用方法がある

作者が薦めるのは3つめのアノテーション、ここでは

  • 例外がスローされるか?
  • スローされた例外が意図したものであるか?

を検証する

言葉の意味は expect Exception (期待される 例外)となる

以下3つの例はこのコードのみで完結する自己循環型のテスト例で、CalculatePointServiceをテストしている訳では無い

try/catchの利用

通常のPHPコードと同様にテスト対象を tryで囲む奴を使う

<?php
    /**
     * @test
     */
    public function exception_try_catch()
    {
        try {
            throw new \InvalidArgumentException('message', 200);
            $this->fail(); // (1)例外がスローされない時はテストを失敗させる
        } catch (\Throwable $e) {
            // 指定した例外クラスがスローされているか
            $this->assertInstanceOf(\InvalidArgumentException::class, $e);
            // スローされた例外のコードを検証
            $this->assertSame(200, $e->getCode());
            // スローされた例外のメッセージを検証
            $this->assertSame('message', $e->getMessage());
        }
    }

InvalidArgumentException 引数の型が期待する型と一致しなかった場合にスローされる例外。

expectException メソッドの利用

<?php
    /**
     * @test
     */
    public function exception_expectedException_method()
    {
        // 指定した例外クラスがスローされているか
        $this->expectException(\InvalidArgumentException::class);
        // スローされた例外のコードを検証
        $this->expectExceptionCode(200);
        // スローされた例外のメッセージを検証
        $this->expectExceptionMessage('message');

        throw new \InvalidArgumentException('message', 200);
    }

@expectedExcepsion アノテーションの利用

作者押しの例外テスト方法
アノテーション(コメント)部分に必要事項を書いてしまう

<?php
    /**
     * @test
     * @expectedException \InvalidArgumentException
     * @expectedExceptionCode 200
     * @expectedExceptionMessage message
     */
    public function exception_expectedException_annotation()
    {
        throw new \InvalidArgumentException('message', 200);
    }

9.1.5.4 購入金額が負数の場合のテスト

テスト例となる CalculatePointService classのメソッドに以下の様な throw 処理がある、これをテストする

<?php
        if ($amount < 0) {
            throw new PreConditionException('購入金額が負の数');
        }

ちなみにPreConditionExceptionだが、ファイルを見ても特に何も書いていないが、ちゃんとしたアプリケーションならここにちゃんと例外を起こした際に書くべき処理を書けって事かな?ここでのテストはちゃんとそこに正しい値が投げられるようになっているかを検証するまでを行う。

app\Exceptions\PreConditionException.php

<?php
declare(strict_types=1);

namespace App\Exceptions;

final class PreConditionException extends \Exception
{

}

例外テストの具体例

<?php
    /**
     * @test
     * @expectedException \App\Exceptions\PreConditionException
     * @expectedExceptionMessage 購入金額が負の数
     */
    public function calcPoint_購入金額が負の数なら例外をスロー()
    {
        CalculatePointService::calcPoint(-1);
    }

マイナスの値がcalcPointに投げられた際を検証するために アノテーションで ’@expectedException'で使用される例外処理先\App\Exceptions\PreConditionExceptionを指定する

つまり『この異常な値を放り込んだら、この Exceptionにこのメッセージが飛んでる?』って事をテストしてる。当然だが、メッセージをちょっとでも変えるとテストは通らずエラーになる。


9-1-6 テストの前処理・後処理

テスト前にDBに必要なの値の仕込みや、テスト後に値を削除変更する必要がある場合、テストメソッドに書くと煩雑になるので、別途専用にメソッドが用意されている。PHPUnit\Framework\TestClassにあるテンプレートメソッド

  • 前処理 setUp メソッド
  • テスト中 testメソッド
  • 後処理 tearDown メソッド

の順で呼ばれて処理される

また、テストクラス毎に呼ばれる以下もある

  • setUpBeforeClass メソッド
  • tearDownAfterClass メソッド

これらはテストメソッドが属するテストクラス毎に1回だけ呼ばれる

9.1.6.1 テンプレートメソッドの動きを見るテスト
どんな順番で処理が実行されるかを確かめるメソッド群

<?php
declare(strict_types=1);

namespace Tests\Unit;

use App\Services\CalculatePointService;
use Tests\TestCase;

class TemplateMethodTest extends TestCase
{
    public static function setUpBeforeClass()
    {
        parent::setUpBeforeClass();

        echo __METHOD__, PHP_EOL;
    }

    protected function setUp()
    {
        parent::setUp();

        echo __METHOD__, PHP_EOL;
    }

    /**
     * @test
     */
    public function テストメソッド1()
    {
        echo __METHOD__, PHP_EOL;
        $this->assertTrue(true);
    }

    /**
     * @test
     */
    public function テストメソッド2()
    {
        echo __METHOD__, PHP_EOL;
        $this->assertTrue(true);
    }

    protected function tearDown()
    {
        parent::tearDown();

        echo __METHOD__, PHP_EOL;
    }

    public static function tearDownAfterClass()
    {
        parent::tearDownAfterClass();

        echo __METHOD__, PHP_EOL;
    }
}

上記のテストを実行すると以下の様な順序でテストが行われる事が確認できる

vagrant@homestead:~/larabook/chapter09$ ./vendor/bin/phpunit tests/Unit/TemplateMethodTest.php
PHPUnit 6.5.9 by Sebastian Bergmann and contributors.

Tests\Unit\TemplateMethodTest::setUpBeforeClass
.Tests\Unit\TemplateMethodTest::setUp
Tests\Unit\TemplateMethodTest::テストメソッド1
Tests\Unit\TemplateMethodTest::tearDown
.                                                                  2 / 2 (100%)Tests\Unit\TemplateMethodTest::setUp
Tests\Unit\TemplateMethodTest::テストメソッド2
Tests\Unit\TemplateMethodTest::tearDown
Tests\Unit\TemplateMethodTest::tearDownAfterClass


Time: 1.1 seconds, Memory: 10.00MB

OK (2 tests, 2 assertions)

これらのテンプレートメソッドを使う際はparentで継承元メソッドを呼ぶことを忘れないようにすること
parent::setUp()


9-1-7 テストの設定

Laravelのルートディレクトリにあうphpunit.xmlファイルで以下の設定項目の編集が可能

  • PHPUnitに関する設定の変更が可能
  • また、テストディレクトリやテスト対象ファイルの追加や変更を設定可能
  • テスト実行時にPHPの設定を変更する
  • 環境変数によってアプリケーション設定を変更する

詳細な設定に関しては PHPUnit の公式マニュアルを参照のこと