要約
- システムの保守性をあげるために自動テストを書くのは良いアプローチといえる
- しかし自動テストを書く時間がなかったり書くスキルがないメンバがいるという諸事情はよく発生する
- そうした時は最初(書き始め)のタイミングで、テスト対象のクラス・メソッドの処理を最後まで処理が通る1ケースだけ書こう
- このアプローチはシステムの保守性をあげるだけではなく、個人・メンバのスキルアップにも繋がることがある
テスト容易性とは
- テスト容易性をGoogleで調べると難しそうな話が出てきますが、本記事では テストがしやすい というふんわりとした言葉の意味合いで使っていきます
- テスト容易性はシステムの保守性を支える1要素ともいえ、このテスト容易性を高めることでシステムの保守性を少しでも高めることができるものです。(もちろん銀の弾丸ではありません)
- 質とスピード by t_wadaさん
テスト容易性をあげるアプローチ
テスト容易性をあげるアプローチは様々な考えがあります。
例えば設計技法としてよく聞く
- 単一責任原則
- 疎結合
- 依存性注入
- デザインパターン
- アーキテクチャ
といったものをうまく活用していくと自然とテスト容易性が高いシステムができてくるとは思います。
しかし比較的簡単に実現できるもの(単一責任原則や依存性注入)は良いですが、
デザインパターンやアーキテクチャには実装に時間や知識・学習コストを要するものもあります。
特に人の入れ替わりがよく発生したり、あまり技術的なスキルを持たない方が多く入ってくるような現場 ではなかなか浸透しづらいものでもあります。
そこで、テスト容易性の敷居を落として「自動テスト書かない!」「書いたことない!」というメンバがいる中でも、
テスト容易性を最小限確保していくアプローチを考えました。
それが要約にも書いている
- 最初(書き始め)のタイミングで、テスト対象のクラス・メソッドの処理を最後まで処理が通る1ケースだけ書こう
というものです。
そもそもテストが容易な状況とは?
ここからは Ruby
, RSpec
を使って「テストが容易な状況」を考えていきます。
※RSpec
に関しては今回の記事から「結果が見やすい」「検証しやすい」という容易性を気にしなくてすむようにするために使っています。
今回この記事では「テストが容易な状況」を以下のように定義します。
- テストをするための準備・前提が少ない
実際にコードで見てみます。
class HelloWorld
def greet
"hello world"
end
end
HelloWorld#greet
は文字列を返す簡単なプログラムとしてこの世に生を受けました。
ではこのクラスに対するテストを書きます。
require_relative "./hello_world"
RSpec.describe "hello world" do
it do
hello_world = HelloWorld.new
greet = hello_world.greet
expect(greet).to eq("hello world")
end
end
立派な自動テストができました。
このテストは「準備・前提が少ない」テストといえます。
といっても「準備・前提がない」わけではありません。
この最小構成においても以下の準備が存在します。
- 準備
- テストツールの読み込み(RSpec)
- テスト対象のファイルの読み込み
- テスト対象の処理を呼び出す準備(インスタンスの生成)
ちょうど #greet
を呼び出す前のところまでが準備です。
といってもこれだけであれば十分「テスト容易性が高い」と言えます。
テスト容易性を落としていく
ではコードを変更してテスト容易性を落としてみます。
つまり「準備」や「前提」が追加されるテストを作っていきます。
上記の #greet
を、名前を受け取り、名前を最後に付け足す文字列を返すようにしました。
コードと自動テストは以下のようになります。
class HelloWorld
def greet(name)
"hello world, #{name}!"
end
end
require_relative "./hello_world"
RSpec.describe "hello world" do
it do
hello_world = HelloWorld.new
name = "toririn"
greet = hello_world.greet(name)
expect(greet).to eq("hello world, toririn!")
end
end
これによりテストにおける準備と前提が変化しました。
- 準備
- テストツールの読み込み(RSpec)
- テスト対象のファイルの読み込み
- テスト対象の処理を呼び出す準備(インスタンスの生成)
- 前提
- greetは引数を1つ受け取る
- その引数の値は
"toririn"
である
といっても、まだこれぐらいでは十分読みやすい、テストがしやすいのではないかと思います。
例えばこのコードで「名前の表示を最後じゃなくて最初に変更してほしい」という要望があったとしても、
修正とテストはすぐに出来るのではないでしょうか。
まだまだテスト容易性は高いと言えます。
一応例
class HelloWorld
def greet(name)
"#{name}!, hello world"
end
end
require_relative "./hello_world"
RSpec.describe "hello world" do
it do
hello_world = HelloWorld.new
name = "toririn"
greet = hello_world.greet(name)
expect(greet).to eq("toririn!, hello world")
end
end
もう少し変更してみます。
#greet
メソッドは ユーザオブジェクト
を受け取り、その名前と年齢を返すようにする、ということにしましょう。
class HelloWorld
def greet(user)
"#{user.name} and #{user.age}"
end
end
ユーザオブジェクト
とはすなわち #name
, #age
に応答ができるものであれば良いといえます。
これを加味してテストを修正します。
Ruby
には Struct
という構造体を簡単に扱える便利なものがありますので使わせてもらいます。
require_relative "./hello_world"
RSpec.describe "hello world" do
it do
hello_world = HelloWorld.new
User = Struct.new(:name, :age)
user = User.new("toririn", 18)
greet = hello_world.greet(user)
expect(greet).to eq("toririn and 18")
end
end
テストが修正できました!
が、少しお待ちください。
実はこの user は「nilが渡される」という可能性があることに気付きました。
この事をチームで話し合うと
「もしnilのときは "にゃーん" と返して欲しい」と言われました。
コードを修正しましょう。
class HelloWorld
def greet(user)
if user.nil?
"にゃーん"
else
"#{user.name} and #{user.age}"
end
end
end
require_relative "./hello_world"
RSpec.describe "hello world" do
it "ユーザーが与えられる場合に名前と年齢の文字列が返ること" do
hello_world = HelloWorld.new
User = Struct.new(:name, :age)
user = User.new("toririn", 18)
greet = hello_world.greet(user)
expect(greet).to eq("toririn and 18")
end
it "nilが与えられる場合ににゃーんが返ること" do
hello_world = HelloWorld.new
greet = hello_world.greet(nil)
expect(greet).to eq("にゃーん")
end
end
出来ました!
※#name,#age
に応答できないオブジェクトのときは、というのは今回は忘れてください
※ RSpec
の書き方については今回は触れませんが興味がある方はRSpec スタイルガイドを見てください。非常にためになります!
これでこのテストのための準備と前提は以下になりました。
- 準備
- テストツールの読み込み(RSpec)
- テスト対象のファイルの読み込み
- テスト対象の処理を呼び出す準備(インスタンスの生成)
- 前提
- greetは引数を1つ受け取る
- その引数の値は
#age, #nameに応答できるオブジェクト
もしくはnil
である
前提が変わりましたが、まだまだテスト容易性は高いのではないでしょうか。
そう、
「作り始め」のテスト容易性は、しばらく(多少の追加があっても)高い状況になることが多い ことが分かります。
テストの前提と準備を増やす
少し時間が過ぎ…
こうして設計技法を無視され HelloWorld#greet
は以下のように膨れてしまいました。
class HelloWorld
def initialize(type, remove_hello: false)
@type = type
@remobe_hello = remobe_fello
end
def greet(user, partner, relation)
# 複雑な検証条件
unless valid_user?(user) || relation.colleague?
return nil
end
# ...値の加工をしたり入れ替えたり
# 複雑な検証条件その2
unless valid_all?(user, partner, relation)
# どこでキャッチされるか分からない本当に使われているか怪しいraise
raise "Invalid!!!"
end
# ...本題の処理に入る前のいろいろな呼び出し。変数は適宜書き換えられたりして進めていく
case @type.to_sym
when :morning
# ...長い処理
when :noon
# ...長い処理
when :evening
# ...長い処理
when :night
else
# ignore とだけ書かれた本当にそれで良いのか分からない case の else
# ignore
end
# もはや何が入っているか分からないクリスマスプレゼントのような変数
return_greet
end
end
こういうのみた事ある、というコードかもしれません。
テストの方も見てみます。
require_relative "./hello_world"
RSpec.describe "hello world" do
before do
# 長い前提処理
# テストの実行速度を保つために入れられたインスタンス変数
@foo = xxxx
end
# ネストされた大量のコンテキスト
context "(xxxx)" do
# あとで書き換えられそうなletたち
let(:xxx) { xxxx }
let(:xxx) { xxxx }
context "(xxxx)" do
before(:all) do
# テスト実行速度をおとさないようにするための before all
end
context "(xxxxx)" do
# itのメッセージと内容が合っていない
it "xxxになること" do
# 大量のexpect, 長い結果、長い期待値
expect(xxxx.xxx.xxx).to be_xxx(["","",xxx,xxx,xxx,xxxxx,xx]).by_count(1).and_xxxx
expect(xxxx.xxx.xxx).to be_xxx(["","",xxx,xxx,xxx,xxxxx,xx]).by_count(1).and_xxxx
# 落ちた場所がわからないeachが使われたexpect
xxxx.each do |xxx, yyy, zzz|
# テストコードがもはやコード
if zzz.foo? && zzz == "foo" || yyy % 34 == 0
expect(xxx). to eq yyy
else
expect(xxx). to eq xxx
end
end
end
end
end
# 別のcontext, 長い処理が続く...
end
# テストファイルが1000行を超えてしまっている
end
げんなりするかもしれません。
テスト容易性は非常に低いといえるでしょう。
しかし、それでも、この状況はまだ 「テスト容易性がもっとも低い状況」ではありません。
テスト容易性がもっとも低い状況とは?
上記の膨れあがった HelloWorld#greet
ですが、
この状況においては救いがあります。
何故ならテストファイルを使って処理の内容を「実際に動かしながら理解できる」状況だからです。これは「テストができる状況」といえます。(時間はかかりますが)
ただ少なくともこのテストがあるおかげで、 HelloWorld#greet
内部の第一行にデバッグポイントを仕込むことも可能です。
それはつまりリバースエンジニアリングも可能といえます。
もしテスト修正工数がかさむのであればパターンをしぼってskipしてしまえば良いです。
選択肢は豊富です。
同じ状況において最も「テスト容易性が低い状況」は HelloWorld#greet
の第一行(valid_user?
)にも辿り着けない状況を意味します。
そしてそれは膨れ上がったプロダクトにおいては決して珍しいことではありません。
特に Rails
であれば
- Controllerの
before_action
や認可・認証周りのチェック - Modelの
callback
,validation
- 必須である関連テーブル
- 必須であるマスターテーブル
- 引数の多さ
からテスト対象メソッドの第一行にも辿り着けない状況はすぐに発生してしまいます。
この最悪な状況を回避するだけで少なくとも最小限のテスト容易性を確保することができます。
最小限のテスト容易性を確保するためにすること
することはただ1つです。
新規のクラス、メソッドを作る最初のタイミングで以下の自動テストを作れば良いのです。
- テスト対象のメソッドが実行できる
- 最後の行までたどり着く条件・パターンを用意しておく
たったこれだけです。
たったこれだけなのですが副次的に以下の効果を得られます
疎結合・単一責任を満たしていないことに気付きやすい
最後の行までたどり着く条件を書いていく時に「あれ? 面倒くさいぞ」と思うときがあります
この「面倒くさい」が出ているということは前提や準備が既に多いということ、それはすなわちオブジェクトが複数の責務を持っていたり、何かと密結合になっていると言えるかもしれません。
ただ、書き始め当初であれば、リファクタリングの工数は最小限です。
少しだけ時間をもらって綺麗に書き直す費用対効果(内部品質の向上)は十分に高いといえます。
有識者の意見を取り入れやすい
リファクタリングのやり方がぱっと分からなくても、
まだコード量や他の処理の結合が少ない状況であれば、
有識者に質問をしてスマートな回答がもらえるチャンスです。
もしかすると新しい設計技法(デザインパターンやアーキテクチャ)や便利なライブラリと出会えるかもしれません。
暫定や回避のための手段を使わなくて良い
既に面倒な状況になってしまった時は「あまり使わない方が良いよ」という暫定回避のためのライブラリやメソッドを使う機会が増えてしまいます。
例えば RSpec
にも allow_any_instance_of
といった非推奨ながら便利でパワフルな道具が用意されているように、面倒な状況でも一時回避・暫定対応を入れて進めていけるものがたくさん溢れています。
しかしこうしたものはあくまでも暫定や回避のためであり、非推奨な方法が増えていくとシステムの保守性が落ちていくのは明白です。
怪しくなったら誰かが直してくれやすくなる
テスト自動化のルンバ効果 という言葉があるように、
前提や準備が増えそうなときに、誰かがリファクタリングやデザインパターン・アーキテクチャをさっと適用してくれるかもしれません。
テストがない他人のコードは怖いですが、テストがある他人のコードは安心できるのでこうした修正もしやすいのです。
まとめ
- テスト容易性を高めてシステムの保守性(内部品質)をあげよう
- テスト容易性を高めるなら自動テストを用意しておくのが良い
- でも完璧は難しい。だからこそ自動テストを書くなら最初が良い。
- そしてそれは1ケースだけでも良い
- 自動テストを書く時に準備や前提が多いときは黄色信号
- 良いコードを書くと準備や前提は自然と少なくなるよ
おまけ: Rails Tips
- Controller に処理をたくさん書かない、before_actionなどコールバックは少なめに
- ただでさえ責務が多い(requestの受付, responseの返却、認可・認証関係のチェック、などなど)
- before_action は各テストの暗黙的な前提になる
- Modelのコールバックはほどほどに
- コールバックが邪魔してテストが書けなかったりすることがある
- コールバックの処理がテストの暗黙的な前提になってしまうことがある
- Modelのバリデーションはほどほどに
- 複雑なバリデーションが邪魔でテストデータを作るのが大変、それだけで複雑な前提が生まれる
- モデル側には共通のバリデーションだけにするというアプローチもある(DBの設定と同レベルのものだけにする)
- そして複雑なバリデーションはformオブジェクト側に委譲するなど
- モデル側には共通のバリデーションだけにするというアプローチもある(DBの設定と同レベルのものだけにする)
- 複雑なバリデーションが邪魔でテストデータを作るのが大変、それだけで複雑な前提が生まれる
- FactoryBotを活用する
- デフォルト値で作られるデータはバリデーションが通り、そのまま保存できる状況にしておく
- デフォルト値で関連テーブルのデータは作らない(traitで作る)
- 自動で作られる関連データがテストの暗黙的な前提になるのを避ける
- デフォルト値をexpectで利用しない。利用する時でもあえて指定してデータを作成する
- デフォルト値がテストの暗黙的な前提になるのを避ける