Hotwire への道 3. JavaScript単体テスト編
この記事は、Redmine Advent Calendar 2022 の16日目の記事で、5日目の「Hotwire への道 1. アセットパイプライン編」、12日目の「Hotwire への道 2. Turbo Streams編」の続きです。前日の記事は Redmine Power【公式】さんの 「redmine.tokyoでご紹介された恩返しに(Wiki計測のご紹介)」でした。
要点
- Redmine に JavaScript の単体テストを追加する方法について検討した。
- コマンドラインでテストを実行するよりブラウザで実行する方が実態に合っている(と思う)。
- Hotwire ほとんど関係ないけど、導入の下準備にはなっている(はずだ)。
はじめに
筆者は半年前にRedmine本家に試行的にJavaScriptの単体テストを追加すべくパッチを投稿しました(Patch #37486: Add JavaScript unit tests)。
Redmineには今のところ、JavaScriptの単体テストのコードが存在していないからです(サーバサイドのテストの充実っぷりは凄いのですが)。
ただ、投稿しといて何ですが、このパッチはイマイチでした。テスト対象をなるべく現状から変更しないようにする一方で、コマンドラインでテストできるような形を目指した結果、テスト対象を動的に読みこむ必要がありコードが読み辛いものになってしまったのです(テストコードにテストの本筋と関係なくやたらと async と await が登場する)。
前回の記事のRedmine上のJavaScriptの利用状況の表を再掲しますが、当時筆者がテストした関数は、 1の107件の関数のうち6件のみでした。そのテストコードもちょっと書きにくく、今ふりかえってみると、ほかの開発者に「いっしょにこの方法で行きましょう」と呼び掛けるのは憚られるものでした。残念。
件数 | 調査方法 | ||
---|---|---|---|
1 | js ファイルで公開されている関数 | 107 | grep -E "^function" public/javascripts/*.js |
2 | viewファイルの中でヘルパーと一緒に使用されているもの | 45 | grep -r -E "link_to_function" app/views/* |
3 | viewファイルの中で、ページにscript要素で埋めこまれているもの | 58 | grep -r -E "javascript_tag" app/* |
4 | html要素のイベント属性の中に直接書きこまれているもの | 114 | grep -r -E "onclick |
5 | SJRのコードとして使用されるもの | 62 | find app/views/ -name “*.js.erb” |
方針の再検討
およそ半年して振り返ってみて、「コマンドラインでテストできるように」というところをいったん諦めてみてはどうか、と思うようになりました。
前回のパッチではテストをコマンドラインで実行するためにNodeJS を導入することが前提でした。そうすればテストは高速ですしCIも楽です。一方で、RedmineのようにESモジュール化されていない JavaScriptを大量にかかえている環境の場合、DOM環境を再現するJSDOM
というライブラリの設定が複雑になり、前述のようにコードが読みにくくなってしまいます(何か良い方法があって筆者が知らないだけかもしれないけど)。
テストで使用している mocha というテストライブラリは、NodeJSだけでなくブラウザとHTMLで動かすこともできます。このHTMLの部分をRailsで準備してあげれば良いということで以下のような仕様でテスト環境を作ってみました。
-
test/javascripts
以下に、JavaScriptのテストコードを書く。 -
http://localhost:3000/rails/info/frontend_test にアクセスすると、Redmineが
test/javascripts
以下のファイルをスキャンしてテストケースをビューに追加していく。ビューの読み込みが全て終わるとテストが実行され、テスト結果も表示される。
Railsアプリでは、開発時には、サーバの /rails/info/routes
とか /rails/info/properties
とかにアクセスすると開発に必要な情報が色々表示されるます。フロントエンドのテストもこの仕組みに準じて同じようなパスで確認できるようにしようというわです。
実装
ルートの追加は、 conf/initializer
以下にファイルを追加して対応します。変数server
のラムダ式が test/javascripts以下のテストコードを返すWebサーバの役割になります。
# frozen_string_literal: true
require 'rack/utils'
server = lambda do |env|
path = Rack::Utils.unescape(env["PATH_INFO"].to_s.sub(/^\//, ""))
fullpath = Rails.root.join('test', 'javascripts', path)
if (File.exist? fullpath)
content = File.binread(fullpath)
[
200,
{
"Content-Length" => content.length.to_s,
"Content-Type" => 'application/javascript;',
"Accept-Encoding" => "Vary",
},
[ content ]
]
else
[ 404, { "Content-Type" => "text/plain", "Content-Length" => "9" }, [ "Not found" ] ]
end
end
if Rails.env.development? || Rails.env.test?
Rails.application.routes.prepend do
get "/rails/info/frontend_test" => "frontend_test#index"
mount server => '/rails/info/frontend_test/test'
end
end
コントローラはindexアクションがあるだけの単純なものです。
# frozen_string_literal: true
class FrontendTestController < ApplicationController
layout false
def index
end
end
ビューはmochaの公式ページほとんどそのままです。
<!DOCTYPE html>
<html lang="<%= current_language %>">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<title><%= html_title %></title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<%= csrf_meta_tag %>
<%= favicon %>
<%= stylesheet_link_tag 'jquery/jquery-ui-1.13.2', 'tribute-5.1.3', 'application', 'responsive', :media => 'all' %>
<%= stylesheet_link_tag 'rtl', :media => 'all' if l(:direction) == 'rtl' %>
<%= javascript_heads %>
<%= heads_for_theme %>
<%= heads_for_auto_complete(@project) %>
<%= call_hook :view_layouts_base_html_head %>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/mocha@10.2.0/mocha.css">
<script src="https://cdn.jsdelivr.net/npm/mocha@10.2.0/mocha.js"></script>
<script>
mocha.setup('tdd');
</script>
<script src="https://cdn.jsdelivr.net/npm/chai@4.3.7/chai.js"></script>
</head>
<body>
<div id="wrapper">
<div id="main">
<div id="content">
<div id="container">
</div>
<div id="mocha">
</div>
</div>
</div>
<%= testcase %>
<script type="module">
mocha.run();
</script>
<div id="footer">
Powered by <%= link_to Redmine::Info.app_name, Redmine::Info.url, :target => '_blank', :rel => 'noopener' %> © 2006-2022 Jean-Philippe Lang
</div>
</div>
</body>
</html>
公式からの変更点は2点です。
- テストを追加する過程で順次、ESモジュールに移行するため、テストケースやテスト実行の部分には、すべて
type="module"
をつける。 - テスト対象となるコードはテストケースの中で直接importする
当初、HTML上のテストではESモジュール化したテストはできないと思いこんでいたのですが、調べてみると事例が紹介されていました[1]。
ビューの中のtestcase
がヘルパーです。test/javascripts 以下のJavaScriptファイルからscripto要素を生成します。
module FrontendTestHelper
def testcase
s = files.map do |f|
path = f.relative_path_from(root_path)
javascript_include_tag(File.join('/rails/info/frontend_test/test', path), type: :module)
end
s.join('').html_safe
end
def files
root_path.glob('**/*.js')
end
def root_path
Rails.root.join('test','javascripts')
end
end
テストファイルは以下の通り。全部だと長くなるので最初の方だけです。mochaのサンプルコードはほとんどが rspecっぽい形式なんですが、今回は Redmine のテストコードにあわせてTDD形式で書きました。
import { selectRows, addMultipleSelection,reverseRenderAction, normalRenderAction, reverseFolderAction, normalFolderAction } from '../../../../javascripts/context_menu.js';
const assert = chai.assert;
const html = `<!-- test for row clicking -->
<table>
<tr class="hascontextmenu"><td class="checkbox"><input type="checkbox"/></td><td class="noinput"></td></tr>
<tr class="hascontextmenu"><td class="checkbox"><input type="checkbox"/></td><td class="noinput"></td></tr>
<tr class="hascontextmenu"><td class="checkbox"><input type="checkbox"/></td><td class="noinput"></td></tr>
<tr class="hascontextmenu"><td class="checkbox"><input type="checkbox"/></td><td class="noinput"></td></tr>
<tr class="hascontextmenu"><td class="checkbox"><input type="checkbox"/></td><td class="noinput"></td></tr>
<tr class="hascontextmenu"><td class="checkbox"><input type="checkbox"/></td><td class="noinput"></td></tr>
</table>
<!-- test for size and position of menu -->
<div id="menu">
<div class="folder"></div>
<div class="folder"></div>
</div>`
suite('context menu', () => {
test('create contextmenu element', () => {
const menu = document.getElementById('context-menu');
assert.isNotNull(menu);
});
suite('when a row is clicked', async () => {
let $rows;
setup(() => {
document.getElementById('container').insertAdjacentHTML('afterbegin', html);
$rows = $('.hascontextmenu');
});
teardown(() => {
const container = document.getElementById('container');
const clone = container.cloneNode( false );
container.parentNode.replaceChild( clone , container );
});
test('When the checkbox is clicked directly, select the row', () => {
const target = $($rows[0]).find('input');
target.prop('checked', true);
const tr = target.closest('.hascontextmenu').first();
selectRows(target, tr, {} )
assert.isTrue(tr.hasClass('context-menu-selection'));
});
test('When the td containing the checkbox is clicked, toggle the checkbox and select the row', () => {
const target = $($rows[0]).find('td.checkbox');
const tr = target.closest('.hascontextmenu').first();
selectRows(target, tr, {} )
assert.isTrue(target.find('input').prop('checked'));
assert.isTrue(tr.hasClass('context-menu-selection'));
});
});
テストの実行
Rails を立ち上げて http://localhost:3000/rails/info/frontend_test にアクセスし、テストが実行されていることを確認します。
コード本体であれ、テストコードであれ、修正をした場合はブラウザを手動でリロードしてテストを再実行します。
CIのときはどうするんだ、という疑問もあるかもしれませんが、システムテストの一番最初に Capybaraでチェックすれば良いのではないでしょうか。
おわりに
さて、ここまで書いてきて、Hotwireと全く関係ない訳ですが、Hotwire「への道」ということでStimulusコントローラとかのテストの下準備とが整ったというところでご勘弁ください。
ただ試したわけではないのですが、ActiveSupportのファイル監視の機能と TurboStreamを組み合わせれば、テストコードが更新されるたびにブラウザリロード抜きのテスト再実行とかができるのでは? とも考えています。
また、今回は、initializer でラムダ式でWebサーバのかわりをさせましたが、importmap-rails を使えば、もっと簡潔に処理できるかもしれません。
RedmineへのHotwire導入はまだ始まったばかりです。Hotwire面白そうだけどゼロからアプリを構築するのはちょっと……とか思ってるそこのアナタ、格好の材料が転がっていますよ!!
月に一度のペースでRedmineの開発に興味がある、という参加者がRedmineパッチ会というオンラインミーティングを開いています。見学も自由です。興味のある方はぜひ参加してみてください。
Discussion