TL;DR
- HAML + ViewComponent でリンクを埋め込んだら、表示が
「設定ページ 」と末尾に空白が入ってしまった - 原因は HAML が出力行の末尾に
\nを付けること。render(...).stripで除去して解決 - ところが、それを守るはずのテストが
have_content(/「設定ページ\s*」/)だった。\s*は空白0個以上にマッチするので、バグがある状態(\n入り)でも通ってしまう。つまりバグを検出できない無意味なテストになっていた - 教訓:テストを書いたら、一度わざと直前の修正を取り消してみて、ちゃんと赤くなるか確認する
1. 何が起きたか
ある画面に、こんな案内文を出していました(例は汎用化しています)。
詳しくは「設定ページ」を開いてください。
「設定ページ」の部分はリンクです。リンクは ViewComponent 化してあり、ビュー(HAML)から I18n の文中に埋め込んでいます(クラス名やコードは説明用に架空のものにしています)。
-# ビュー側(HAML)
- link = render ArticleLinkComponent.new(t(".notice.link_label"), settings_path, class: "text-link")
#notice= t(".notice.more_info", link: link).html_safe
# locales/ja.yml
ja:
notice:
link_label: "設定ページ"
more_info: "詳しくは「%{link}」を開いてください。"
ところが実際の表示はこうなりました。
詳しくは「設定ページ ␣」を開いてください。
設定ページ と閉じ括弧 」 の間に、余計な半角空白が入っています。地味ですが、括弧の中に空白があるのは気持ち悪いし、デザイン上もよろしくない。
2. なぜ \n が入るのか
犯人は HAML の改行付与でした。
ArticleLinkComponent は内部に HAML テンプレートを持っていて、ざっくりこういう構造です。
class ArticleLinkComponent < ViewComponent::Base
haml_template <<~HAML
= link_to @name, @options, @html_options
HAML
# ...
end
HAML は各出力行の末尾に改行 \n を付ける仕様です。そのため render(ArticleLinkComponent.new(...)) の戻り値は、リンクの HTML の末尾に \n が付いた文字列になります。
これを I18n の 「%{link}」 に埋め込むと、最終的な HTML はこうなります。
詳しくは「<a href="...">設定ページ</a>
」を開いてください。
HTML では改行も空白文字として扱われるので、</a> の直後の \n がブラウザ上で空白1つとして描画され、「設定ページ ␣」 になっていたわけです。
本当に \n が付くのか確認する
実際に HAML 単体でレンダリングして確かめてみます。コンポーネントのレンダリング結果と、それを文中に埋め込む処理は分けて試すのがポイントです。
require "haml"
link_html = Haml::Template.new { '%a{href: "/settings"} 設定ページ' }.render(Object.new, {})
link_html
#=> "<a href=\"/settings\">設定ページ</a>\n"
link_html.end_with?("\n") #=> true
text = "「#{link_html}」を開いてください。"
puts text
# 「<a href="/settings">設定ページ</a>
# 」を開いてください。
link_html の末尾に見えない改行 \n が1つ付いていて、それを文中に埋め込むと 」 の直前に \n が挟まることが分かります。
タグや行の末尾に改行を入れるのは HAML の仕様で、抑制するための空白除去演算子 > / < も用意されています(HAML Reference: Whitespace Removal)。リンクを文中に差し込む今回の使い方だと、この末尾の \n が余計な空白として表に出てきます。
直し方
今回は呼び出し側で末尾の空白を落とすことにしました。
-# 修正後
- link = render(ArticleLinkComponent.new(t(".notice.link_label"), settings_path, class: "text-link")).strip
render(...) を括弧で囲んで .strip を付け、末尾の \n を除去します。これで表示は 「設定ページ」 に戻りました。
コンポーネント側で HAML の空白除去演算子 > / < を使って出力末尾を抑える、という直し方もあります。ただ今回は呼び出し側の .strip の方が手軽で、「埋め込む前に前後の空白を落としている」という意図もコードから読み取りやすいので、これを採用しました。
3. テストはどうなっていたか
この案内文には、こういうテストが付いていました。
it "案内文が表示されること" do
expect(page).to have_content(/詳しくは「設定ページ\s*」を開いてください。/)
end
一見ちゃんとしているように見えます。\s* で「空白が入っても大丈夫なように」吸収しているつもりです。
ですが、ここに落とし穴があります。
4. なぜこのテストがダメなのか
\s* は「空白文字(半角スペース・タブ・改行など)が0個以上」にマッチする正規表現です。つまりこのテストは、.strip の有無に関わらず通ってしまいます。
| 状態 | テストが見るテキスト | /「設定ページ\s*」/ |
"「設定ページ」"(文字列) |
|---|---|---|---|
バグあり(.strip なし) |
「設定ページ\n」 |
✅ 通る ← 見逃す | ❌ 落ちる ← 検出できる |
修正済み(.strip あり) |
「設定ページ」 |
✅ 通る | ✅ 通る |
\s* は個数無制限なので、もし将来 「設定ページ\n\n\n」 のように空白がもっと増えても通り続けます。
つまり、バグがある状態でも通るテストは、バグを検出する能力がないということです。今回直した .strip のバグは、まさにこのテストが守るべき対象だったのに、\s* が空白を吸収してしまっていたせいで、.strip があってもなくてもテストは緑のままでした。
「空白の揺れが怖いから \s* / \s+ で吸収しておこう」というのはやりがちですが、その揺れ自体がバグのときは、吸収した瞬間にテストが目をつぶります。空白に意味がある箇所では吸収しないことが大事です。
5. どう直すか
空白を吸収しない、素の文字列で検証します。
it "案内文が表示されること" do
expect(page).to have_content("詳しくは「設定ページ」を開いてください。")
end
上の表の通り、バグがある状態(\n 入り)ではテキストに 「設定ページ」(空白なしの並び)が含まれないのでテストは落ち、修正後は通ります。これでようやく、テストが .strip のバグを守れるようになりました。
「コードにわざとバグを入れたら、テストは落ちるか?」という今回の確認の仕方は、mutation testing(ミューテーションテスト)と同じ発想です。コードを機械的に少しだけ書き換えて、それでもテストが緑のままなら「そのテストは弱い」と判定する手法で、Ruby には mutant というツールもあります。そこまで導入しなくても、新しく書いたテストを一度だけ手で壊してみて赤くなるか見る、くらいでも今回のような「常に通るテスト」はだいぶ防げます。
6. おわりに
.strip 一個のバグとしては小さい話ですが、テストがあるのに防げていなかったというのが個人的には一番の収穫でした。\s* で空白を吸収していたせいで、修正前も修正後もテストは緑のまま。これではテストがある意味がありません。
空白に意味がある場所で \s* や \s+ を使うと、その空白の揺れ自体がバグだったときに見逃します。テストを書いたら一度わざと実装を壊して、ちゃんと赤くなるかを確認する。地味ですが、これだけで今回のようなテストはだいぶ減らせるはずです。
参考文献・ソース
動作・既定値は執筆時に使っていたバージョン(Capybara 3.40 / HAML 7.2 / RSpec 8.0 / ViewComponent 3.24)を前提にしています。公開前にリンク先と最新仕様をご確認ください。
-
Capybara — テキストマッチャ(
have_text/have_content) -
RSpec — マッチャ/rspec-expectations
- 公式: https://rspec.info/
- rspec-expectations: https://github.com/rspec/rspec-expectations
-
HAML — 空白の扱い(Whitespace Removal
>/<) - ViewComponent — コンポーネントのレンダリング
-
Ruby
Regexp—\s(空白文字クラス)と量指定子* -
Mutation Testing(mutant) — 「コードを壊してテストが落ちるか」を機械化する考え方
- GitHub: https://github.com/mbj/mutant