サブカル業界Developers 勉強会 Vol.5 で Cache Stampede の話をした #subcul_dev

サブカル業界Developers 勉強会 Vol.5 (オンライン/オフライン同時開催!!) - connpass

前日に急に寝台特急の存在を思い出したので、サンライズ出雲で上京した。寝台車は寝てたら勝手に着くので便利だなぁ。強制的に 7 時から活動させられるので朝活しやすいのもポジ。

登壇枠

Cache Stampede (検索用ワード: Thundering Herd) という話をした。

Cache Stampede 対策のうち 3 案は以下のスライドから貰ってきた。

www.slideshare.net

ZOZO さんも同じ分類で話をしていましたね。

techblog.zozo.com

4 案目とした SWR も 6-7 年前から定番かなー。

\キャッシュと上手に付き合おう!!/

パネルディスカッション

主に「サブカル企業のスタッフはオタクなのか」というお題で 5 社でパネルディスカッションしました。

パネルは「toB v.s. toC」という枠に当てはめようというモデレータの力が働いていた気がする。これはプロダクトにはかなり影響するが、社内文化は違いを感じないなというのが自分の感想だった。

実際、熱量を持っている開発者と意思決定者との距離は、施策選びにとても影響がある。そこで距離の近い共同開発を行える関係性、任せて貰える信頼関係を作るのに腐心している。

一方で、社内文化については、オープニングでも触れられたが、雀魂 夏の企業対抗戦 2023 が特徴的で面白かったなと思う。パネル登壇 5 社中 3 社が出場している。

こういうイベントにどんどん参加できる環境とか、およそどんな話題でも食いついてくる人がいる社内 Slack とかは福利厚生だろう。

懇親会

前回に引き続き、オフライン懇親会も開催した。オフライン懇親会は新しい人との繋がりを作りやすいのが本当に便利で、知り合いと知らない人とが親しそうに話をしているところに「どんな繋がりなんですか」を携えて入っていくと、どんどん繋がっていく。これを繰り返して顔なじみを増やしてきたんだよなぁ、と再認識した。

一方で、はてなからも 2 人参加していたけどあまり引率できなかったのは失敗だった。懲りずにまた来てください。次は紹介して回ります。

サブカル業界 Developers は引き続き開催していくつもりなので、connpass で Join Group して新着イベントを拾える状態にしておいてください。

git revert 時は理由を入れよう

コミットを何らかの理由で取り消したいときに git revert を使う。

git revert -m1 MERGE_COMMIT したときは

Revert "Merge pull request #1 from onk/repo"

This reverts commit 35b7e7c517c438b805d442ff45242334bb91eda5, reversing
changes made to 07989ab9643e204a0d26a19152c5ace2657cf421.

という commit になる。

GitHub だと UI 上からも revert できて、そのときは以下のようになる。

Pull Request を revert する画面。本文は「Reverts onk/repo#1」
「revert」ボタンを押すと revert PR を作成する

どちらも、このまま進めることはできてしまうが、「revert した」という事実だけしか残っていない。なぜ revert する羽目になっているのかという Why がどこにもない。これは後世でとても困るのです。

revert commit のコミットメッセージもしくは revert PR の description に、理由を入れて欲しい。

「... の考慮が漏れていて動作確認時に ... というエラーが出たため」とか。Slack のリンクだけでもいいです。

理由が全員の記憶から揮発してしまったら「前回 revert した記憶があるが、当時ダメだった理由って潰せてるんだっけ?」という質問に答えられず、不安の中で次のリリースをすることになるからです。

オンボーディングは3ヶ月で3連勝を目指す

先日 ヘンリーで活躍中の id:Songmu を訪問 | はてな卒業生訪問企画 [#3] - Hatena Developer Blog という対談記事でもオンボーディングについて話したんだけど、社内では最近「3ヶ月で3連勝」を標語にしている。

オンボーディングとは

採用や異動などでチームにジョインした後に行う、早期戦力化のための施策のこと。開発環境のセットアップやチームの説明だけじゃなく、戦力になるまでのプロセス全部を指していそう。

僕らは各アカウント作成や開発環境構築はチェックボックス化してドンドン進められるようにしている *1 し、事業の説明、組織の説明、システム構成の説明をする場を設定したり、技術スタックへの入門のための実績解除システムを用意したりしてきた。

これらのドキュメントに従って作業を進め、実績を解除していくことで、作業的・技術的な道標はあるものの、オンボーディングプロセスとして見ると、教科書を読んだり講義を受けたりワークショップに参加したりするだけではまだ足りず、もっと実践的な具体的なタスクが欲しい。そして、その具体的なタスクに関して、3ヶ月で3連勝を達成したい。

3ヶ月で3連勝

元ネタは fukabori.fm でエムスリーの山崎さんが語っていた内容です。

自分がチームでやっていけそうという感覚を手に入れるために、できるだけ短期間で成功体験を積んで貰いたい。更に単発じゃなくて3連発で成功すると「このチームで良かった!」と思えるんじゃないか。そのためにチームがちゃんと応援する。オンボーディングタスクに対して、チームメイトが積極的に関与していく状態でありたい。

対義語(?)は「お手並みを拝見する」かもしれない。

「活躍してもらうためのサポートがあなた (受け入れる側) の仕事です」というのを明確にするために標語が欲しくて、また見守りコーナーで「活躍できていますか?」と問いかけるための指標も欲しくて、「3ヶ月で3連勝」は失敗を検知しやすいしアクションも取りやすいなと思って取り入れているのでした。

まずはデプロイ成功を初日に持ってくるとか、ちょっと難しめの 1 スプリントかかるタスクや、2-3 スプリントかかるタスク、ちょっとした気になりを解消するタスクを用意していくとかをするだろう。タスクサイズがバランス良いかとか、大玉や改善タスクも含めて3ヶ月のスケジュールができている?みたいな確認も自然に取れるのが良いところだと思っている。

3連勝するためのアクション

3連勝を達成するためには、本人の力だけではなく周囲のサポートも必要で、両方がうまく組み合わさっていることが望ましい。どちらか一方だけでは不十分であろう。サポートするをより明確にするための標語でもあるが、本人が連勝を目指してアクションを取るための標語でもある。

本人の力をより引き出すためには、大声で作業をするというのをいつも伝えている。

blog.studysapuri.jp

社内 Scrapbox や Slack に書き残しながら作業をしていると、悩みを素早く拾えるし、リモート下でも非同期にサポートできるようになり、より短期間で成功する確度が上がっていると思う。

また、日々大声で作業をし続けていたり、スプリントレトロスペクティブがあったり、隣のチームメンバーとの 1on1 で最近やったことをまとめて説明する時間を取ったりで、他人に伝わるように説明する力を養えているんじゃないかなー。上手くサポートしたいので、障害物があるかを確認したいし、障害物があれば高速に排除したい。

サポートする側としては、目の前のタスクを成功させるためのサポートの他には、良いタスクを選ぶというのが大事になる。より多くの領域に触れるようなタスクを選ぶことを心がけるし、本人がモチベーションのあるタスクを見つけて、目標にする、というのも考えて活動している。

書かなければいけない採用リンク

みたいなことを考えながらオンボーディングに取り組んでいるのでした。

株式会社はてなでは、あなたの応募をお待ちしています。 https://hatena.co.jp/recruit

株式会社はてなに入社しました

株式会社はてなに入社しました

株式会社はてなに入社しました - hitode909の日記

6 年目で所属は引き続き弊社です。触っていく範囲がまたちょっと広がりました。

YAPC::Kyoto 2023 で ORM について喋ってきた

資料は こちら です。

背景

アーキテクチャ的に何かを足したいとき、我々はチーム開発を行っているのだから、チームの共通認識を変えるということになる。認知負荷が高い場合は提案を拒否されてしまうので、認知負荷をできる限り小さくして導入したい。つまり差分の最小化です。*1

現在のコードベースと、入れたいアーキテクチャを対比させつつ、こう導入するのがベストと見切るところが今回のトークの面白ポイントです。

PoEAA のデータソースのアーキテクチャに関するパターン

PoEAA は 20 年前の本なので、当時の開発風景を想像できる人と会話しながら読むと良いです。エリックエヴァンスの DDD 本も似た時期ですね。2002 年は Java 1.4 がリリースされた頃。デザインパターンUMLXML が流行っていた。ライブラリのパッケージマネージャやセントラルリポジトリがまだ無い。*2

再利用性があり生産性が高いらしい「オブジェクト指向プログラミング」に全ての夢を託していたあの時代。また、もちろん Rails も無いので migration (アプリケーションエンジニアが DB を気軽に変更し続ける) という文化もまだ発見されていない。

PoEAA では、アプリケーションはドメインを表現するようにしたい。データベースは容易には変更できないので、ドメインと結合させない、DataMapper パターンに自然となっていくだろう、という語り口で書かれている。その後、現実は密結合で加速する方が認知負荷が低くて扱いやすい、DB のリファクタリングをどんどんやる、そもそもドメインとデータソースを分離するのは割と無理筋、という方向になったっぽいと思う(観測範囲)。

データソースのアーキテクチャに関するパターン 4 つを構造で分類して説明するというアイディアは PHP/アクティブレコードってなに? から借りた。*3

greppability

Sample::Repository::User みたいに、各テーブルに対応するクラスを作ることの是非かぁ。そもそも世の中を席巻している ActiveRecord パターンはテーブルに対応するクラスを作るので、そこで違和感を持つことは無かったなぁ。

ただテーブル名を文字列から定数にするだけではなく、ちょっと複雑なクエリ置き場を作る。クラスにしたことで、こういうものはココに書くと決められる、という意味を付けられたんじゃないか。まさに TableDataGateway として整理した。

greppability に関しては、現実世界のオペレーションとして「どこからこのテーブルが触られているのか」というのは時々眺める必要がある。謎データが入った原因を探したり、急に垂直分散する必要が出たり。*4 少しでも日々の作業が楽になるように多少の工夫はしたい。

SQL が分からなくならない?

Perl の DB 周りのライブラリには基本的にどこで発行した SQL なのかをコメントで残す機能がある *5 ので、スロークエリの検知や集計さえできていれば発生した問題には対応できる。 また、集計や検知するためのサービスが現代では整っていて、自前で pt-query-digest 等で集計しなくても Performance Insight が教えてくれるので、頑張らなくても見つけられるんじゃないかと思っています。

今は移行直後なのでみんな SQL への脳内マッピングができる状態だけど、「ORM しか触ったことのない人」が増えた世界だと「発行される SQL を見てくれ!!」と言いたくなりそうですね。例えば Devel::KYTProf を入れて流れる SQL を眺めながら開発するよう働きかけるとか、それをやってもログだと眺める人が少ないのでブラウザ上にどうにかして表示するとかが必要になるかもしれません。

そもそも問題が発生しないようにするのは難しいよねー……。工夫はして防ぐけど、たまにやらかしが出うる、勝ち目の少ない戦いっぽいと思う。

ドメイン

質疑もあった。(まとめてくれている id:stefafafan は本当に助かる)

低レベルな API としての SQL、もうちょっとだけ高レベルな (CRUD だけを司る) TableDataGateway、もうちょっと高レベルな (ある程度ドメインロジックも持つ) ActiveRecord、更に高レベルな (テーブルと疎結合になってドメインそのものを表す) DataMapper パターンとあって、DDD のドメインは DataMapper パターンのドメインが一番近いんですよね。

たぶん質問は「Repository」という言葉に引っ張られたのだと思う。DDD のリポジトリ (ドメインとデータストアとのマッピングを行う、まさに DataMapper のこと) ではなく、TableDataGateway(特定のテーブルに対する CRUD を集めたもの)に Sample::Repository::User という名前を付けました。高レベルなものは低レベルのものを組み合わせて実現するので、やればできますが、僕らのアーキテクチャだと既にもう一個上に置いてある Service 層をドメインロジック置き場としているので、新たなドメインモデルを作る理由が無かった、が回答になりました。

これは ドメインモデル貧血症 なのではないかという指摘もありそうですね。その通りと思います。困るようになったら改善するかもしれません。が、僕は 95% のものはテーブルと紐付いたドメインモデルで十分と思っているので、必要なところでだけどうにかする、をやりそう。

話せなかった内容

使用メモリが倍増したのでリリースして慌てて取り下げたとか、JSON を BLOB カラムに入れている場合があって encode_utf8 で一度ハマったとか、トランザクション境界が合ってなくて導入しづらいものがあったとか、入れる上でのトラブルが一通りあるんだけど、あのペースで喋っても時間いっぱいだったので入れられなかった。

Repository や Row を扱いやすくするための工夫や、ライブラリに送った PR についても話したかったけどこれも時間の関係で見送り。具体的には Row をトップレベルの名前空間に置いたのは、特にやって気持ちが楽になった工夫です。タイプ数が多いと普段使いしづらい。ドメインオブジェクトであり、普段から触るものだと認識しやすくするためには、浅いところに置くのが望ましいと思っています。

まとめ

  • 対象領域を整理しよう
    • 今回は ORM を PoEAA の語彙で 4 パターンに、さらに 2 軸で整理した
  • 自分のコードベースを見極めよう
    • どのパターンに近いのか、例外的に扱っているところはあるか等
    • どうあるべきかを考えることになるので、自然とアーキテクチャと仲良くなる
    • アーキテクチャが綺麗だと例外が少ない
  • ギャップが少なくなるようにして導入に持ち込もう
    • 感覚じゃなく、整理して、だからこれが最適解だ、を突きつける
    • 今までとのジャンプアップの大きさは注意しよう
      • 大きすぎると認知負荷が高すぎて導入できない
  • いちど整理すると面白副作用が出てくるのでオススメ
    • 混沌だと手が出せなかったが、整理済みなら改善できる
      • 認知負荷を下げるとハードルが下がるらしい

参考資料とか

*1:なんでも差分最小が良いわけではなく、理想と現実のギャップを捉えた上で、落としどころを探すことになる

*2:Maven も無く Ant で入れていたと思う

*3:のだけれど、今見ると 403 だなー? :thinking_face:

*4:ISUCON じゃない現実でもそういうことはある

*5:Teng の場合は https://github.com/nekokak/p5-Teng/blob/0.33/lib/Teng.pm#L272-L287

デュアルトラックアジャイルとの向き合い方。あるいはエンジニアとビジネスの距離感

昨日(もう日付余裕で回ってるので一昨日だな)Findy さん主催のイベントで話してきた。

speakerdeck.com

背景

近年「エンジニアは事業貢献してこそ」「エンジニアもユーザファーストでビジネス貢献」といった言説がIT界隈で増えて来ている感じがしている。

……とたまたま昨日関連してるようなしてないような話をしている エンジニアとビジネスの距離感の難しさ|ばんくし|note という記事があったので書き出しを真似してみたんだけど。

昨今、ビルドトラップに陥るな、アウトプットじゃなくアウトカムに着目しろ、って言われることが増えてますよね。でも僕は逆張りして、アウトプットにまず着目しろという声を上げておきたいのです。

開発生産性(いろいろある*1)の話をするときに、ディスカバリーとデリバリーの 2 軸で考えるのはコモディティ化してきたと思う。でも、それによって、デリバリーの重要性が薄くなっているとも思っている。両輪と口では言いつつ、意識は最近見つけたディスカバリー側に寄っちゃってるんじゃないか。

ディスカバリーもデリバリーもまだまだ改善の余地があるときに、開発チームはどちらを優先して伸ばすべきか。これは僕は基本的にデリバリーだと思っています。開発チームはね。

デリバリーが先、ディスカバリーが後

作る速度を上げるのがデリバリー、作るものを決めるのがディスカバリーなので、デリバリーは車の整備、ディスカバリーはカーナビの整備と言えると思う。

ナビがポンコツでも、方向ベクトルの角度が 90 度以内に入っていればゴールに近づくことはできるし、稀にドンピシャの方向を示すこともある。でも車が走らなかったらまったく近づかない。高速にアウトプットできるという状態をまず目指したい。

高速にアウトプットできる場合、良い企画に当たったら高速にアウトカムを作れる。企画なんてそもそも当たるも八卦当たらぬも八卦だし*2、当てる確度を上げていくには机上の空論をこねくり回すんじゃなく結果を見て調整するイテレーティブな改善サイクルを繰り返した方が良いよね、というのも経験主義なスクラムをやっている僕らはよく知っている。

なので、良い企画のときには爆速でリリースしてあげたいし、そもそも高速に仮説検証を回せる体制を作っておきたい。デリバリーの強さがディスカバリーの強さを引っ張り上げる。逆は無い。*3

強いチームを作るにはイテレーションが必要

まともに回っていない開発チームのときに、ディスカバリーの問題にしたくないなーとも思っている。これはそもそも論になりすぎるとか、小さな改善が発生しなくなるとかが起きるからです。

まともに回っているとは、ふりかえりが機能して毎イテレーション何かしらの改善が生まれているとか、キックオフが行われたり月例会が行われたりしてチーム全員が自分たちの As Is/To Be を理解しているとか、そういう「ふつうの開発チーム」が普通に備えているべきものを普通にやっている状態を指しています。

チームで同じ方向を目指して開発していくための前提みたいなところですね。この状態になっていないうちに「ディスカバリーにチーム全体がもっと目を向けないと」という風潮になると、本来改善しなければならない開発チームとして機能していない部分が放置される。タックマンモデルで言う統一期、機能期が訪れず、ずっと形成期や混乱期に居る状態。

  • 必要な情報がちゃんと流れる
  • 役割分担が自然とされて阿吽の呼吸で動ける
  • 自分たちの行動規範を持っている
  • チームへの帰属意識がある
  • 目標達成にコミットできる

という良いチームには、イテレーションを回して、コミュニケーションのトラブルを解決した結果、ようやく辿り着ける。改善された実感を得て、このチームなら良くなっていきそうと思えている必要がある。

なので、高速にアウトプットできる、なんでもいいからリリースを繰り返せるチームをまず目指すべきだと思っています。

デリバリーもディスカバリーも 80 点をまず目指したい

80 点を取るまでは 20% の努力でできる。2 つの軸があるならそれぞれ 80 点を取るのをまず目指すべきで、個人の努力とチームの努力で 80 点は数ヶ月で達成できると思う。*4

デリバリーは↑で書いたような普通のチームを作りましょう。ディスカバリーは 90 度以内になりましょう。ここまでは自分のプログラマやプランナーという職種の専門性の話でしょう。

僕はある程度のレベルまではそれぞれ伸ばせが良いと思っている。何よりもコスパが良い。で、ある程度を満たしていないのに反対側に手を出すのは、目の前にある自分が解決すべき問題を他責にする行為なんじゃないかと感じる。

当時もこう書いていた。*5

視座を上げるときのアンチパターン

視座を高く持って視点を探そうとすると,例えば朝会に遅刻をした時に 「そもそもリモートで参加できない状態がおかしくないですか」 みたいな発言をしがちになる。

視座・視点の切り替えは得てして論点ズラしに使われる。 他人の視座に立つというのは,他責にすることではない。

デリバリーが普通になってから、ディスカバリーと高次元にバランスさせた状態を目指しましょう。

相手が自分よりも困っているなら、もちろんチームなので助けに行きたいけど、そうじゃないのに越境してるのは、もうちょっと何かあるだろって思います。

高次元にバランスさせるところが業界の標準的な悩みになっているんだとしたら、そんな素晴らしいことはないですね。逆張りした話をここまで書いてきたけど、当然そうありたいと思っています。

*1:https://qiita.com/hirokidaichi/items/53f0865398829bdebef1

*2:他職種の仕事を貶めるような言い方だけど、プランナー専門職や凄腕の PO が異常に頼りになることは本当によく知っています

*3:当たった結果、金を出して強いチームを作れるようになるかもしれないし、当たり続けることで求心力が高まって強いチームになるかもしれない。そもそも当たらないとチーム解体になるみたいな話も置いてある

*4:何が 100 点で何が 80 点なのかの基準を揃えないと会話が成り立たないので、チームではそういう話をしなきゃいけなかったりします

*5:コロナ禍ですっかりリモートワークになっちゃったので例としてはもう微妙

RSpec では context 間の違いを表現するときにのみ let を使う

Test which reminded me why I don't really like RSpec | Arkency Blog (日本語訳:Rails: RSpecが好きでないことを思い出したテスト(翻訳)|TechRacho by BPS株式会社) を見ての感想。

元のコードのイマイチなところは 4 つあって、

  • paramsbefore で書き換えている *1
  • it "will succeed" の文言
  • it { is_expected.to be_success }expect(result.success?).to eq(true) が混ざっている
  • let が不思議な順序で連発されていて事前条件を読み解けない

すべて、これによって何をテストしているのかが分かりづらくなっているという問題を引き起こす。

paramsbefore で書き換えている

let(:params) { { last_name: "something" } }
subject(:result) { described_class.call(current_user: user, params: params) }
...
describe "checking Address update" do
  let(:new_zip_code) { Faker::Address.zip_code }
  before { params[:zip_code] = new_zip_code }

  context "when user has address and want to change something in their address" do
    let!(:address) { create(:address, owner: user) }

    it "will succeed" do
      expect(result.success?).to eq(true)
      ...

この before は上の方で定義してある let との合わせ技で、かなり厳しい。

このクラスでは、異なる結果が得られる主な入力は params である。

この入力がどう変わると出力がどう変わるかは明確にわかるはずだが、これを call に明示的に渡さない理由がわからない。

にまったく同意で、そのために describe で切り分けているのだから、params に何が渡るのかは describe 内で明示する。何をテストしているのかを明確にするというのを意識したい。今は params の違いをテストしているのだから、params が何なのかをそのまま書く。let で定義して subject に渡しているので、let で上書けば良い。

 let(:params) { { last_name: "something" } }
 subject(:result) { described_class.call(current_user: user, params: params) }
 ...
 describe "checking Address update" do
   let(:new_zip_code) { Faker::Address.zip_code }
-  before { params[:zip_code] = new_zip_code }
+  let(:params) { {
+    last_name: "something",
+    zip_code: new_zip_code,
+  } }

   context "when user has address and want to change something in their address" do
     let!(:address) { create(:address, owner: user) }

     it "will succeed" do
       expect(result.success?).to eq(true)
       ...

「他のところで作ったものを before でちょこっと書き換えよう」はだいたい悪手。あと多分ココは last_name: "something", を残す必要なくて、zip_code のテストをしたいので zip_code のみになっている方が望ましいと思う。

it "will succeed" の文言

describe "checking Address update" do
  ...
  context "when user has address and want to change something in their address" do
    ...
    it "will succeed" do
      ...
    end
  end

  context "when user has not have any address and want to change something in their address" do
    ...
    it "create address with given params" do
      ...
    end
  end
end

これは何のテストをしているのか読み解くのが難しいと思うけど、よくよく読むと、やりたいことはただの upsert である。おそらく実装側はこうなっている。

if params[:zip_code]
  address = current_user.address || current_user.build_address
  address.zip_code = params[:zip_code]
  address.save!
end

upsert のテストをしているというのが分かるように説明を書く。なお、テスト対象や事前条件と context の説明は一致していて、context 直下に before もしくは let が来るのが良い分割。テストの違いを contextbefore, let で表現するのだ。

ついでに何をテストしているのか分かりやすいように「params[:zip_code] を渡したとき」というのも説明文として表現するとより親切だろう。

context "with params[:zip_code]" do
  let(:params) { {
    zip_code: new_zip_code,
  } }
  ...
  context "when address exists" do
    before { create(:address, owner: user) }
    ...
    it "will be updated" do
      ...
    end
  end

  context "when address does not exists" do
    ...
    it "will be created" do
      ...
    end
  end
end

describe, context, it の説明文を駆使して何をテストしているのかを明らかにするのは、可読性の高いテストを書くための基本です。

it { is_expected.to be_success }expect(result.success?).to eq(true) が混ざっている

context "when address exists" do
  ...
  it "will be updated" do
    expect(result.success?).to eq(true)
    expect(address.reload.zip_code).to eq(new_zip_code)
  end
end

context "when address does not exists" do
  ...
  it { is_expected.to be_success }
  it "will be created" do
    expect { result }.to change(Address.all, :count).from(0).to(1)
    expect(Address.last.zip_code).to eq(new_zip_code)
    expect(user.address).to eq(Address.last)
  end
end

update 側では result.success? と、更新されたことの 2 個の expect があるが、 create 側では result.success? は単独のテストとして切り出されているし、書き方も違う。

書き方が違うと「この違いに何か意味があるのか?」という疑問が湧いてしまい、テスト対象がぼやける。テストの粒度は揃える。

また、success? であることはあまり主眼ではないので、目立たないように定型文っぽくなっているとより良い。つまり eq(true) とハッキリと対象をテストするよりも、読み下して読み飛ばせる方が望ましい。

 context "when address exists" do
   ...
+  it { is_expected.to be_success }
   it "will be updated" do
-    expect(result.success?).to eq(true)
     expect(address.reload.zip_code).to eq(new_zip_code)
   end
 end
 
 context "when address does not exists" do
   ...
   it { is_expected.to be_success }
   it "will be created" do
     ...
   end
 end

こうすると、全 context で「result.success?」と「詳細な副作用」をテストしている、と整理できる。

let が不思議な順序で連発されていて事前条件を読み解けない

context "when user has parent and want to change something" do
  let(:parent) { create(:parent) }
  let(:user) { create(:user, profession: student) }
  let(:student) { create(:student, parent: parent) }

  it { is_expected.to be_success }
  ...

ここでは parentupsert するときの update 側をテストしたい。そのため、 テスト対象 (subject) である Api::Students::Update.call(current_user: user, params: params)user 側に「parent を持っている User である」という事前条件がある。

最終的に欲しいのは user なので、せめてこの順に並んでいて欲しい。

  let(:parent) { create(:parent) }
  let(:student) { create(:student, parent: parent) }
  let(:user) { create(:user, profession: student) }

また、事前条件なんだから before で整える、というのも自然な考え方だろう。let と違い遅延評価されず、スコープが絞られるため、怖れずに上から順に読めるようになり、処理を追うのが簡単になる。

context "when user has parent and want to change something" do
  before {
    parent = create(:parent)
    student = create(:student, parent: parent)
    @user = create(:user, profession: student)
  }
  let(:user) { @user }

  it { is_expected.to be_success }
  ...

let(:user) { create(:user, profession: @student) } でも良いんだけど、私は before で作ったインスタンスlet にパスするだけが好きです。この方が事前条件を整えた後に context に受け渡している感がある。

僕なら最初の 5 分でこうリファクタリングする

RSpec.describe Api::Students::Update do
  let(:user) { create(:user) }
  let(:params) { { last_name: "something" } }
  subject(:result) { Api::Students::Update.call(current_user: user, params: params) }

  context "when user is a student" do
    it { should be_success }
  end

  context "when user is teacher" do
    let(:user) { create(:user, :teacher) }
    it { should be_failure }
  end

  context "with params[:zip_code]" do
    before { @new_zip_code = Faker::Address.zip_code }
    let(:params) {
      {
        last_name: "something",
        zip_code: @new_zip_code,
      }
    }

    context "when address exists" do
      before { @address = create(:address, owner: user) }

      it { should be_success }
      it "will be updated" do
        result
        expect(@address.reload.zip_code).to eq(@new_zip_code)
      end
    end

    context "when address does not exists" do
      it { should be_success }
      it "will be created" do
        expect { result }.to change(Address.all, :count).from(0).to(1)
        expect(Address.last.zip_code).to eq(@new_zip_code)
        expect(user.address).to eq(Address.last)
      end
    end
  end

  context "with params[:parent]" do
    before { @parent_first_name = Faker::Name.female_first_name }
    let(:params) {
      {
        parent: { first_name: @parent_first_name },
      }
    }

    context "when parent exists" do
      before {
        @parent = create(:parent)
        student = create(:student, parent: @parent)
        @user = create(:user, profession: student)
      }
      let(:user) { @user }

      it { should be_success }
      it "will be updated" do
        result
        expect(@parent.reload.first_name).to eq(@parent_first_name)
      end
    end

    context "when parent does not exists" do
      it { should be_success }
      it "will be created" do
        expect { result }.to change(Parent.all, :count).from(0).to(1)
        expect(Parent.last.reload.first_name).to eq(@parent_first_name)
        expect(user.profession.parent).to eq(Parent.last)
      end
    end
  end
end

こうしておくと -f d で実行したときにも読み解きやすい。

Api::Students::Update
  when user is a student
    is expected to be success
  when user is teacher
    is expected to be failure
  with params[:zip_code]
    when address exists
      is expected to be success
      will be updated
    when address does not exists
      is expected to be success
      will be created
  with params[:parent]
    when parent exists
      is expected to be success
      will be updated
    when parent does not exists
      is expected to be success
      will be created

change はあんまり使いたくないなー (expect { result }.to はちょっと読みづらくない?) とか、多分 create の方のテストを先に書くだろうな (条件が複雑な方をより後でテストしたいので、レコードが存在している場合は後ろに書きたい) とか、一つのテストの中で複数の expect を書くなら aggregate_failures を使えとかの思いはあるけど、シュッと直すならこうかなー

元記事のリファクタリング後と何が違うか

  • describe (context) のネストは維持している
    • テスト対象が明確に違うので、ネストしておく方が自然
    • params[:zip_code]params[:parent] があるときの、それぞれの upsert を確認している
  • context の説明はより実装寄りに
    • ユニットテストなので実装寄りの言葉で問題無いし、その方が実装を書き換えるときにテストの対応を取りやすい
  • subject を引き続き使う
    • テストしたいのは params の違いなので、params に着目したい。
    • 元記事のリファクタリング後だと params は 70 文字目辺りから始まっていて、どこに違いがあるのかを読み解くのが困難
    •     result = Api::Students::Update.call(current_user: user, params: { parent: { first_name: parent_first_name } })
      
    • subject に執着しているわけではなく、テスト間の違いをもっと目立たせろと思っています
  • letcontext 間の差を表現するために使う
    • よりテスト間の違いに目を向けさせるためです
    • let は遅延評価&後勝ちなところが大きなメリットで、まさに context 内で上書きしたいときに使う語彙です
    • そのため、私は積極的に before やローカル変数、インスタンス変数を使い、let を温存します
      • 今回の書き換えでも user, params のみが let になっていて、何をテストしているのか読み解きやすいんじゃないか?
      • テーブルテストと同じことを contextlet でやっているのです

subjectインスタンス変数に反対の派閥があるのは知っていて、異論はあると思うけど、私は「テスト対象が分かりやすくあった上で Ruby/RSpec の表現力にあやかりたい」と考えているので、こう書いています。

合わせて読みたい:Rails Developers Meetup 2017 で RSpec しぐさについて話した - onk.ninja

*1:公開当初は「params を merge している」と書いていたのでこういう反応があった https://twitter.com/qsona/status/1629840312389742593