Hotwire への道 3. JavaScript単体テスト編

2022/12/25に公開

この記事は、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サーバの役割になります。

conf/initializer/frontend_test.rb
# 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アクションがあるだけの単純なものです。

app/controllers/frontend_test_controller.rb
# frozen_string_literal: true
class FrontendTestController < ApplicationController
  layout false

  def index
  end
end

ビューはmochaの公式ページほとんどそのままです。

app/views/index.html.erb
<!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' %> &copy; 2006-2022 Jean-Philippe Lang
</div>

</div>
</body>
</html>

公式からの変更点は2点です。

  • テストを追加する過程で順次、ESモジュールに移行するため、テストケースやテスト実行の部分には、すべて type="module" をつける。
  • テスト対象となるコードはテストケースの中で直接importする

当初、HTML上のテストではESモジュール化したテストはできないと思いこんでいたのですが、調べてみると事例が紹介されていました[1]

ビューの中のtestcaseがヘルパーです。test/javascripts 以下のJavaScriptファイルからscripto要素を生成します。

app/helpers/frontend_test_helper.rb
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形式で書きました。

test/javascripts/context_menu.test.js
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パッチ会というオンラインミーティングを開いています。見学も自由です。興味のある方はぜひ参加してみてください。

脚注
  1. mochaをブラウザから使うES modulesでテストを書く ↩︎

Discussion