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の設定も含まれている
出来上がったもの
参考にした記事
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を継承したものを使って汎用的なカスタムバリデータを実装するのほうがいいかもしれない