SprocketsとWebpackerを使わずWebpackのみでassets周りを管理した話(テスト編)

前回の記事のテスト編
ponpoko-nalplus.hatenablog.com

CapybaraとChromedriverを使ったrspecを書いていく

RSpecをインストール

$ bundle exec rails generate rspec:install

RAILS_ROOT/spec/rails_helper.rb にCapybaraの設定とChromedriverの設定を追記する

require "spec_helper"
ENV["RAILS_ENV"] ||= "test"
require File.expand_path("../../config/environment", __FILE__)

abort("The Rails environment is running in production mode!") if Rails.env.production?
require "rspec/rails"

begin
  ActiveRecord::Migration.maintain_test_schema!
rescue ActiveRecord::PendingMigrationError => e
  puts e.to_s.strip
  exit 1
end
RSpec.configure do |config|
  config.fixture_path = "#{::Rails.root}/spec/fixtures"
  config.use_transactional_fixtures = true
  config.infer_spec_type_from_file_location!
  config.filter_rails_from_backtrace!
  config.include FactoryBot::Syntax::Methods

  config.after(:suite) do
    Kernel.system("rm -f #{Rails.root.join("spec", "webpack_build")}")
  end

  config.before(:each) do |example|
    if example.metadata[:type] == :system

      # type: :system 時の初回だけ yarn build:dev を走らせる
      unless  File.exist?(Rails.root.join("spec", "webpack_build"))
        Kernel.system("yarn", "build:dev")
        Kernel.system("touch #{Rails.root.join("spec", "webpack_build")}")
      end

      if example.metadata[:js]
        driven_by :selenium, using: :headless_chrome, screen_size: [1280, 800], options: {
          args: [
            "headless",
            "disable-gpu",
            "no-sandbox",
            { "lang" => "ja-JP" }
          ]
        }
      else
        driven_by :rack_test
      end
    end
  end
end

system spec を追加

require "rails_helper"
RSpec.describe "Tops", type: :system do
  describe "#index" do
    it "should render image tag", js: true do
      visit root_path
      expect(page).to have_css("img")
    end
  end
end

rails_helper.rb でwebpackのbuild:devを一回走らせることでJSのテストもできるようにはなった。
結構強引なやり方だと思うので、もうちょっとスマートなやり方を模索していきたい。

SprocketsとWebpackerを使わずWebpackのみでassets周りを管理した話(動かすまで編)

Rails 6 がリリースされてWebpackerが標準搭載されるようになった。
個人的にSprocketsとWebpackerってどっちかでよくないかと思ったので、Sprocketsは使わずWebpackerでassets周りを全部管理しようかと思った
でもこのWebpackerはフロントエンドのエンジニアの方々からあまり好かれていない様子。
それならWebpackだけでassets周りを管理できたらいいのかなと思いチャレンジしてみる。
せっかくなのでwebpack-dev-serverも使いたいのでその設定も
今回は使わないがフロントエンドでVueを使う想定していて作ったものなのでちょくちょくVueの設定も含まれている

出来上がったもの

github.com

参考にした記事

inside.pixiv.blog
qiita.com
medium.com

Railsプロジェクトの作成

testはMinitestではなくRSpecを使いたいので--skip-testする。

rails new . -d mysql --skip-turbolinks --skip-test

Gemfileから不要なものを消す

とりあえずこんな感じのGemfile

source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }

ruby '2.6.3'

# Bundle edge Rails instead: gem 'rails', github: 'rails/rails'
gem 'rails', '~> 6.0.0'
# Use mysql as the database for Active Record
gem 'mysql2', '>= 0.4.4'
# Use Puma as the app server
gem 'puma', '~> 3.11'
# Transpile app-like JavaScript. Read more: https://github.com/rails/webpacker
gem "rack-proxy"

# Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder
# gem 'jbuilder', '~> 2.7'
# Use Redis adapter to run Action Cable in production
# gem 'redis', '~> 4.0'
# Use Active Model has_secure_password
# gem 'bcrypt', '~> 3.1.7'

# Use Active Storage variant
# gem 'image_processing', '~> 1.2'

# Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', '>= 1.4.2', require: false

# Use Rack CORS for handling Cross-Origin Resource Sharing (CORS), making cross-origin AJAX possible
# gem 'rack-cors'

gem "slim-rails"

group :development, :test do
  # Call 'byebug' anywhere in the code to stop execution and get a debugger console
  gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
end

group :development do
  gem "web-console", ">= 3.3.0"
  gem 'listen', '>= 3.0.5', '< 3.2'
  # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring
  gem 'spring'
  gem 'spring-watcher-listen', '~> 2.0.0'
  gem "rubocop", "~> 0.63.1", require: false
end

group :test do
  gem "capybara"
  gem "selenium-webdriver"
  gem "database_cleaner"
  gem "webmock"
  gem "simplecov"
  gem "factory_bot_rails"
end

# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby]

package.jsonを設置してyarn installする

package.json はこんな感じ。

{
  "name": "webpack_pure",
  "private": true,
  "dependencies": {
    "@rails/actioncable": "^6.0.0",
    "@rails/actiontext": "^6.0.0",
    "@rails/activestorage": "^6.0.0",
    "@rails/ujs": "^6.0.0",
    "axios": "^0.18.0",
    "import-glob-loader": "^1.1.0",
    "moment": "^2.24.0",
    "trix": "^1.0.0" ,
    "vue": "^2.6.8",
    "vue-loader": "^15.7.0",
    "vue-template-compiler": "^2.6.8"
  },  
  "version": "0.1.0", 
  "scripts": {
    "build:dev": "webpack --progress --mode=development --config ./webpack.config.js", 
    "build:pro": "webpack --progress --mode=production --config ./webpack.config.js"
  },                                                                                                                                                                                                                                   
  "devDependencies": {
    "@babel/core": "^7.3.4", 
    "@babel/plugin-proposal-class-properties": "^7.3.4",
    "@babel/plugin-syntax-dynamic-import": "^7.2.0",            
    "@babel/plugin-transform-runtime": "^7.3.4",
    "@babel/polyfill": "^7.2.5",
    "@babel/preset-env": "^7.3.4",
    "@fortawesome/fontawesome-free": "^5.8.1",
    "babel-loader": "^8.0.5",
    "babel-plugin-macros": "^2.5.0",
    "css-loader": "^2.1.1",
    "mini-css-extract-plugin": "^0.5.0", 
    "node-sass": "^4.11.0", 
    "pug": "^2.0.3",
    "pug-plain-loader": "^1.0.0", 
    "sass-loader": "^7.1.0", 
    "vue-style-loader": "^4.1.2", 
    "webpack-cli": "^3.2.3",
    "webpack-dev-server": "^3.2.1",
    "webpack-manifest-plugin": "^2.0.4"
  }                                                                                                                                                                                                                                    
}

yarn install する

$ yarn install

webpack.config.js を設置する

webpack.config.jsはこんな感じ
gemのほうのwebpackerで出力されるconfigをカスタマイズしながら作った
RAILS_ROOT/frontend 以下にimage css javascript のファイルを置くのを想定

const path = require("path");
const glob = require("glob");
const webpack = require("webpack");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const VueLoaderPlugin = require("vue-loader/lib/plugin");
const ManifestPlugin = require("webpack-manifest-plugin");

let entries = {};
glob.sync("./frontend/entry/**/*.js").map(function(file) {
  let name = file.replace("./frontend/entry/", "").split(".")[0];
  entries[name] = file;
});

const assets_path = path.join(__dirname, "public", "assets");

module.exports = (env, argv) => {

  return {
    entry: entries,
    output: {
      filename: "js/[name]-[hash].js",
      chunkFilename: "js/[name]-[hash].chunk.js",
      hotUpdateChunkFilename: "js/[id]-[hash].hot-update.js",
      path: assets_path,
      publicPath: "/assets/",
      pathinfo: true
    },
    plugins: [
      new VueLoaderPlugin(),
      new MiniCssExtractPlugin({
        filename: "stylesheets/[name]-[hash].css"
      }),
      new webpack.HotModuleReplacementPlugin(),
      new ManifestPlugin({
        writeToFileEmit: true
      })
    ],
    module: {
      rules: [
        {
          test: /\.js$/,
          exclude: /node_modules/,
          loader: "babel-loader",
          options: {
            presets: [
              [
                "@babel/preset-env",
                {
                  targets: {
                    ie: 11
                  },
                  useBuiltIns: "usage"
                }
              ]
            ]
          }
        },
        {
          test: /\.vue$/,
          use: [
            {
              loader: "vue-loader",
              options: { extractCSS: true }
            }
          ]
        },
        {
          test: /\.pug/,
          loader: "pug-plain-loader"
        },
        {
          test: /\.(c|sc)ss$/,
          use: [
            {
              loader: MiniCssExtractPlugin.loader,
              options: {
                publicPath: path.resolve(__dirname, "public/assets/stylesheets")
              }
            },
            "css-loader",
            "sass-loader"
          ]
        },
        {
          test: /\.(jpg|png|gif)$/,
          loader: "file-loader",
          options: {
            name: "[name]-[hash].[ext]",
            outputPath: "images",
            publicPath: function(path) {
              return "/assets/images/" + path;
            }
          }
        },
        }
      ]
    },
    resolve: {
      alias: {
        vue: "vue/dist/vue.js"
      },
      extensions: [
        ".vue",
        ".mjs",
        ".js",
        ".sass",
        ".scss",
        ".css",
        ".module.sass",
        ".module.scss",
        ".module.css",
        ".png",
        ".svg",
        ".gif",
        ".jpeg",
        ".jpg"
      ],

      modules: ["node_modules"]
    },

    devServer: {
      clientLogLevel: "info",
      compress: true,
      quiet: false,
      disableHostCheck: true,
      port: 3035,
      https: false,
      host: "0.0.0.0",
      hot: true,
      contentBase: assets_path,
      inline: false,
      useLocalIp: false,
      public: "0.0.0.0:3035",
      publicPath: "/assets/",
      historyApiFallback: { disableDotRule: true },
      headers: { "Access-Control-Allow-Origin": "*" },
      overlay: true,
      watchOptions: { ignored: "**/node_modules/**", poll: true }
    }
  };
};

Hot Module Replacement の設定は下記部分のinlineをtrueに設定すれば有効になるがローカルで開発する場合のみ有効
EC2のインスタンス上で開発していたりする場合はJSのエラーが出るのでfalseにしといたほうがよさそう

    devServer: {
      clientLogLevel: "info",
      compress: true,
      quiet: false,
      disableHostCheck: true,
      port: 3035,
      https: false,
      host: "0.0.0.0",
      hot: true,
      contentBase: assets_path,
      inline: true, // ⬅︎これ
      useLocalIp: false,
      public: "0.0.0.0:3035",
      publicPath: "/assets/",
      historyApiFallback: { disableDotRule: true },
      headers: { "Access-Control-Allow-Origin": "*" },
      overlay: true,
      watchOptions: { ignored: "**/node_modules/**", poll: true }
    }

下記コマンドを叩くと RAILS_ROOT/public/assets/ 以下に manifest.json と各assetsが配布される
本番へデプロイするときに叩く用

$ yarn build:pro

RAILS_ROOT/app/helpers/webpack_bundle_helper.rbを設置する

RAILS_ROOT/frontend 以下にあるassetsファイル群を呼び出すためのhelper

module WebpackBundleHelper
  class BundleNotFound < StandardError; end

  def asset_bundle_path(entry, **options)
    valid_entry?(entry)
    asset_path(manifest.fetch(entry), **options)
  end

  def javascript_bundle_tag(entry, **options)
    path = asset_bundle_path("#{entry}.js")

    options = {
      src: path,
      defer: true
    }.merge(options)

    options.delete(:defer) if options[:async]

    javascript_include_tag "", **options
  end

  def stylesheet_bundle_tag(entry, **options)
    path = asset_bundle_path("#{entry}.css")

    options = {
      href: path
    }.merge(options)

    stylesheet_link_tag "", **options
  end

  def image_bundle_tag(entry, **options)
    raise ArgumentError, "Extname is missing with #{entry}" if File.extname(entry).blank?

    image_tag asset_bundle_path(entry), **options
  end

  private

    def manifest
      @manifest ||= JSON.parse(manifest_json_file)
    end

    def manifest_json_file
      return OpenURI.open_uri("http://railswebpack:3035/assets/manifest.json").read if Rails.env.development?

      File.read("public/assets/manifest.json")
    end

    def valid_entry?(entry)
      return true if manifest.key?(entry)

      raise BundleNotFound, "Could not find bundle with name #{entry}"
    end
end

RAILS_ROOT/lib/dev_server_proxy.rbを設置してRAILS_ROOT/config/environments/development.rbを追記する

開発環境側のRAILS_ROOT/app/viewsからwebpack-dev-server上で作成されているassetsを参照するためにproxyを使う
RAILS_ROOT/lib/dev_server_proxy.rb を作成する。中身はこんな感じ

require "rack/proxy"
class DevServerProxy < Rack::Proxy
  def perform_request(env)
    if env["PATH_INFO"].start_with?("/assets/")
      env["HTTP_HOST"] = dev_server_host
      env["HTTP_X_FORWARDED_HOST"] = dev_server_host
      env["HTTP_X_FORWARDED_SERVER"] = dev_server_host
      super
    else
      @app.call(env)
    end
  end

  private

    def dev_server_host
      "railswebpack:3035"
    end
end

dev_sever_host はwebpack-dev-serverが動いている環境のhostを指定する
今回はdocker上でやっていたのでこんな感じ

RAILS_ROOT/config/environments/development.rbでdev_server_proxy.rbをrequireする

require Rails.root.join("lib", "dev_server_proxy.rb")

Rails.application.configure do
defultで設定されているもの
...
  config.middleware.use DevServerProxy, ssl_verify_none: true # ⬅︎これを追記
end

image css js の使い方

まずはエントリーポイントを設定する
今回はここに設置RAILS_ROOT/frontend/entry/application.js
actiontextも使えるようにしておく

require("@rails/ujs").start();
require("@rails/activestorage").start();
import "../../../node_modules/@fortawesome/fontawesome-free/js/all";
require("../javascripts/channels");
require("@rails/actiontext");

import "../stylesheets/application";
const images = require.context("../images/", true);

// RAILS_ROOT/frontend/javascripts/ 以下のJSをインポートして使いたい場合はそれを記述しておく
// import "../javascripts/common";

slim での利用を想定

レイアウトファイルで読み込むCSSとJSの設定
RAILS_ROOT/app/views/layouts/application.html.slim

doctype html
html
  head
    title
      | Webpack Pure
    = csrf_meta_tags
    = csp_meta_tag

    / 使いたいエントリーポイント
    = javascript_bundle_tag "application"
    / 使いたいエントリーポイントの中でimportしているcss名
    = stylesheet_bundle_tag "application", media: "all"

  body
    = yield

画像の使い方
RAILS_ROOT/app/views/tops/index.html.slim

.center
  = image_bundle_tag("images/ruby.jpg")

次はこのwebpackを使った状態でcapybaraのテストを書いていく

rbenvでRubyを更新したのにUnicornが古いRubyを見にいってしまっていたときの対応

サーバー側のRubyをrbenvを使ってバージョンを上げて再デプロイしたら、古いほうのRubyをずっと見てしまう現象が発生した。

ダウンタイムをなくすように RAILS_ROOT/config/unicorn/production.rbに設定していたのを思い出し、capのログの中身を見てみたら以下のコマンドが流れていた

kill -s USR2 `cat /var/www/deploy/shared/tmp/pids/unicorn.pid` 

kill -s USR2 は「緩やかな再起動」コマンドなのですぐには反映されなくて古いほうのRubyを見てしまっていたようだった

サーバーに入って下記コマンドを叩いてから再デプロイしたら新しいRubyのほうを見てくれるようになった

kill -HUP `cat /var/www/deploy/shared/tmp/pids/unicorn.pid`

application_controller.rb のrspecの書き方

例えばログインしたユーザーのニックネーム等に不備があった場合まず編集画面にリダイレクトさせたいという処理
application_controller.rb

class ApplicationController < ActionController::Base
  before_action :authenticate_user!
  before_action :invalid_name_redirect_to

  def invalid_name_redirect_to
    redirect_to edit_user_path(current_user) if current_user.invalid_name?
  end
end


こんな感じでrspecがかけた
spec/controllers/application_controller_spec.rb

require "rails_helper"

RSpec.describe ApplicationController, type: :controller do
  let(:user) { create(:user, name: nil) }

  controller do
    def index
      render body: nil
    end
  end

  describe "#invalid_name_redirect_to" do
    before { sign_in user }
    
   example "ニックネームに不備があった場合プロフィール編集画面に遷移する" do
     get :index
     expect(response).to redirect_to(edit_user_path(current_user))
   end
  end
end

controller do の中にモックのような感じでindexを書いてリクエストを投げてやればテストできた

Rails5.1から render nothing: true が MissingTemplate になる

これだとダメになってた

class UsersController < ApplicationController
  def show
    render nothing: true
  end
end

5.1だとどちらかで書けるようになっている

class UsersController < ApplicationController
  def show
    render body: nil
  end
end
class UsersController < ApplicationController
  def show
    head :ok
  end
end

ActiveRecordのscopeの中でTime.nowを使ったときに起こる問題

model の中で scope を使うときに今の時間を比較してレコードをとってきたい場合に時間が固まってしまうことがある
developmentモードでは起こらずproductionモードのみで起こる。
今日登録したユーザーを表示したいとなった場合

class User < ActiveRecord::Base
  scope :today_scope, where("users.created_at BETWEEN :start_time AND :end_time", { start_time: Time.now.beginning_of_day, end_time: Time.now.end_of_day })
end
        

上記をproductionモードで走らせると起動した時間でTime.nowが固まってしまうため、ずっと起動しっぱなしで次の日になった場合正確な情報はとれない
正確な情報を取る場合はlambdaを使う

class User < ActiveRecord::Base
  scope :today_scope, lambda{ where("users.created_at BETWEEN :start_time AND :end_time", { start_time: Time.now.beginning_of_day, end_time: Time.now.end_of_day })}
end
        

これで今日登録したユーザーはとってこれる。

ちなみに以下のソースでも固まってしまうので注意が必要

class User < ActiveRecord::Base
  DISABLE = 0
  ACTIVE = 1
  

  scope :today_scope, lambda{ where("users.created_at BETWEEN :start_time AND :end_time", { start_time: Time.now.beginning_of_day, end_time: Time.now.end_of_day })}
  scope :active, today_scope.where(status: User::ACTIVE)
end
        
 @users = User.active.all
        

activeのscopeにlambdaがついていない状態でtoday_scopeを使うとtoday_scopeのlambdaは有効にならず時間が固まってしまう。

class User < ActiveRecord::Base
  DISABLE = 0
  ACTIVE = 1
  

  scope :today_scope, lambda{ where("users.created_at BETWEEN :start_time AND :end_time", { start_time: Time.now.beginning_of_day, end_time: Time.now.end_of_day })}
  scope :active, lambda{ today_scope.where(status: User::ACTIVE) }
end

activeにもlambdaを使うことによって時間は固まらなくなる
lambdaは->で書いた方が見やすいと思うので->を使った方がいいと思う

Rails3系統でaccepts_nested_attributes_forでネストしたパラメータでuniqueness validationが適用されなかった

accepts_nested_attributes_forでネストして、フォームから送られてくるパラメータ上で値が重複してもDBに保存されていな限りvalidationができなかった
下記のような状態だと不可能

class User < ActiveRecord::Base
  has_many :emails
  accepts_nested_attributes_for :emails
end
class Email < ActiveRecord::Base
  belongs_to :user
  
  validates :address, uniqueness: true
end

フォームから送られてくる値は以下

{
  "user" => {
    "name" => "hogehoge",
    "emails_attributes" => {
      "0" => {"address" => "hoge@example.com"},
      "1" => {"address" => "hoge@example.com"}
    }
  }


DBに保存されたものとフォームから重複して送られてくるもの両方をカバーする

class User < ActiveRecord::Base
  has_many :emails
  accepts_nested_attributes_for :emails

  validates_each :emails do |record, attr, value|    
      record.errors.add(attr, "が重複しています")  if record.nested_uniq?(value, [:address])
  end

  def nested_uniq?(records, attrs)
    attrs = attrs.map(&:to_s)
    nested_records = records.map do |record|
      record.attributes.values_at(*attrs).compact
    end
    nested_records.size != nested_records.uniq.size
  end
end
class Email < ActiveRecord::Base
  belongs_to :user
  validates :address, uniqueness: true
end


ActiveModel::EachValidatorを継承したものを使って汎用的なカスタムバリデータを実装するのほうがいいかもしれない