Rails アンチパターン - 錆びついたファクトリー (factory_girl)

技術書典2 に行ったら無性に本を書きたくなったけど、本書くのは 面倒 大変です。

というわけで、とりあえずブログに記事を1つ書いてみた。

factory_girl

factory_girl はテスト用データを作成するときに使う gem です。

下記は User のモデルを定義するファクトリーです。

FactoryGirl.define do
  factory :user do
    first_name "John"
    last_name  "Doe"
    admin false
  end
end

このファクトリーから User のテストデータを生成することができます。

FactoryGirl.create(:user)
#=> #<User id: 1, first_name: "John", ...

factory_girl は関連先のレコードを自動生成したり、連番を生成したり、結構多機能だったりします。

このあたりの詳細を知りたい方は公式の README を読むか、英語が苦手なら日本語の紹介記事などを読むと良いです。

錆びついたファクトリー

ここからが本題で、factory_girl で定義したファクトリーは時間が経ったり、作成者が未熟だと 錆びる (=ビルドしづらい、できなくなる)ことがあります。

錆びついたファクトリー例を紹介してみたいと思います。

2回以上ビルドできない

class User
  validates :nickname, uniqueness: true
end

FactoryGirl.define do
  factory :user do
    nickname "sinsoku"
  end
end

これはひどい。 uniqueness 制約により2回目のビルドは失敗します。

FactoryGirl.define do
  factory :user do
    sequence(:nickname) { |n| "nickname_#{n}" }
  end
end

上記のように sequence を使って回避すべきです。

build_stubbed なのにレコードが生成される

クラスの属性だけでテストができる( DB アクセスが不要な)ケースがあります。

class Book
  def published?
    published_at <= Time.current
  end
end

このようなメソッドのテストでは DB アクセスをしないように build_stubbed を使うべきです。*1

しかし、ファクトリーが下記のような定義だとレコードが生成されてしまいます。

FactoryGirl.define do
  factory :book do
    author { create(:user) }
    published_at '2017-04-10'
  end
end

これは association を使って定義すべきです。

FactoryGirl.define do
  factory :book do
    association :author, factory: :user
    published_at '2017-04-10'
  end
end

association を使うことで、 build_stubbed の時にレコードが生成されなくなります。

デフォルトのファクトリーが重い

FactoryGirl.define do
  factory :user do
    name 'serval'

    transient do
      friends_count 10
    end

    after(:create) do |_user, evaluator|
      create_list(:user, evaluator.friends_count)
    end
  end
end

デフォルト値のフレンズが多すぎます><

FactoryGirl.define do
  factory :user do
    name 'serval'

    factory :user_with_friends do
      transient do
        friends_count 10
      end

      after(:create) do |_user, evaluator|
        create_list(:user, evaluator.friends_count)
      end
    end
  end
end

デフォルトのファクトリーはシンプルに保ち、フレンズの多いファクトリーは別にしておきましょう。

条件の必要なファクトリー

class Servant
  belongs_to :master

  validate :must_be_valid_master

  TEAM = {
    'Artoria Pendragon' => 'Shirou Emiya'
  }

  private

  def must_be_valid_master
    errors.add(:master, ' is invalid') unless TEAM[name] == master.name
  end
end

FactoryGirl.define do
  factory :servant do
    association :master, factory: :user
    name 'Artoria Pendragon'
  end
end

このファクトリーは master が ‘Shirou Emiya’ のときだけビルドできます。

user = FactoryGirl.create(:user, name: 'Shirou Emiya')
servant = FactoryGirl.create(:servant, master: user)

このように、ビルド時に必要な関連を渡すようなファクトリーは使いづらいです。

FactoryGirl.define do
  factory :servant do
    association :master, factory: :user, name: 'Shirou Emiya'
    name 'Artoria Pendragon'
  end
end

FactoryGirl.create(:servant) だけでビルドが出来るようにしておくべきです。

その他

時間の経過によりバリデーションが追加・変更されたり、モデルの関連が変わってしまい、ファクトリーが錆びてしまうことがあります。

アンチパターンへの対策

幸い factory_girl には Lint がついています。

# lib/tasks/factory_girl.rake
namespace :factory_girl do
  desc "Verify that all FactoryGirl factories are valid"
  task lint: :environment do
    if Rails.env.test?
      begin
        DatabaseCleaner.start
        FactoryGirl.lint
      ensure
        DatabaseCleaner.clean
      end
    else
      system("bundle exec rake factory_girl:lint RAILS_ENV='test'")
    end
  end
end

上記のような rake タスクを作成し、 CI で常にファクトリーがビルドできるかチェックしておくのが良いでしょう。

おわり

パッと思い出せた factory_girl に関するアンチパターンを書いてみました。

他のアンチパターンはやる気と時間があれば書く…かも?

*1:無駄なレコードを生成するとテストが遅くなってしまいます。

個人 Rails アプリを CircleCI 2.0 で動くようにした

CircleCI 2.0 が使えるようになっていたので、アップグレードしておいた。

CircleCI の設定

今まで circle.yml だったけど、 .circleci/config.yml に変わっている。

Ruby の設定例は Language Guide: Ruby があるので、これを参考にすると良い。

version: 2
jobs:
  build:
    docker:
      - image: ruby:2.4.0
      - image: postgres
      - image: redis
    environment:
      PHANTOMJS_URL: https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-2.1.1-linux-x86_64.tar.bz2

    working_directory: ~/workspace
    steps:
      - checkout
      - type: cache-restore
        key: phantomjs-2.1.1
      - type: cache-restore
        key: gemfile-{{ checksum "Gemfile.lock" }}
      - run: |
          which phantomjs && exit
          curl --location --silent $PHANTOMJS_URL | tar xj -C /tmp --strip-components=1
          mv /tmp/bin/phantomjs /bin
      - run: apt-get update -qq && apt-get install -y build-essential nodejs
      - run: bin/setup
      - run: rake
      - run: yard -o doc
      - store_artifacts:
          path: doc
          destination: doc
      - type: cache-save
        key: phantomjs-2.1.1
        paths:
          - /bin/phantomjs
      - type: cache-save
        key: gemfile-{{ checksum "Gemfile.lock" }}
        paths:
          - /usr/local/bundle

注意点は デフォルトだと bundler の cache が効かない とか、 PhantomJS が入っていない とかくらい?

bin/setup の実行

Language Guide: Ruby の例に合わせて db:create db:schema:load を実行して db:migrate を実行しない方が少し速くなると思う。
ただ、 bin/setup が動作するのをチェックしたくて CI で毎回動かすようにしている。

Rakefile

Rakefile に下記のような定義をしていて、 rake を実行すると rubucop + parallel:spec が動くようにしている。

# frozen_string_literal: true
# Add your own tasks in files placed in lib/tasks ending in .rake,
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.

require File.expand_path('../config/application', __FILE__)

Rails.application.load_tasks

unless Rails.env.production?
  require 'rubocop/rake_task'
  RuboCop::RakeTask.new

  task(:default).clear
  task default: [:rubocop, 'parallel:spec']
end

改善できそうなところ

Using docker-composebackground を使えば、もうちょい改善とか実行速度アップとかできるかも?

まぁ、そのあたりは時間あるときに試す。時間あるときに。

Docker を使って Rust に入門してみた

前々から Rust の良いと聞いていたので、ちょっと入門してみた。

参考のサイト

公式の プログラミング言語Rust が分かりやすそうなので、これを見ながら最初の “Hello, World” まで進めた。

まぁ、環境構築しかやってないってことですね。

環境構築

このコミットの通りで、 Docker を使って Rust の環境を構築した。

新しい言語を触る場合、環境構築のどこでハマるか分からんので、 docker コンテナ使う方が楽。*1

次の記事は…

3日坊主にならないように、定期的に Rust 触ってブログに書きたいですね。そんな気持ちはあります。たぶん。

*1:ローカルで変な問題踏んだりとかもしないし

bundler_diffgems という gem を作りました #speee_lounge

2/18(土) と 2/25(土) に Speee さんのもくもく会に参加して、 gem が出来たので紹介。

speee.connpass.com

bundler_diffgems

bundler_diffgemsbundle update を支援するための CLI ツールで、GitHub の比較URL を簡単に表示できます。*1

インストール方法

$ gem install bundler_diffgems

でインストールできます。

使い方

いつものように bundle update をします。

$ bundle update

そのあと、 bundle diffgems を実行します。

$ bundle diffgems
rake: 11.3.0 => 12.0.0 - https://github.com/ruby/rake/compare/v11.3.0...v12.0.0
rspec: 3.5.0 =>
...

こんな感じで GitHub の比較URL付きでアップデートされた gem の一覧が表示されます。

速度

GitHub API の実行は parallel の gem を使っているので、そこそこ実用的な速度で比較URLが出るんじゃないかなーと思います。

フォーマット

Pull Request で見やすくするために md_table というフォーマットだけ増やしてます。

f:id:sinsoku:20170226183341p:plain

bundle update してプルリクを投げるスクリプト

私はこんな感じのスクリプトbin/update_gems に保存して、使っています。

#!/bin/bash
set -e

# TODO: You need to edit these variables
REPO=owner/repo
BASE_BRANCH=master

if [ -z "${GITHUB_TOKEN}" ]
then
  echo 'usage: GITHUB_TOKEN=<your 40 char token> update_gems'
  exit 1
fi

# Update gems
bundle update

# Push a commit
NOW=$(date +'%Y%m%d%H%M%S')
HEAD_BRANCH="gems/${NOW}"
BODY=$(bundle diffgems --escape-json -f md_table)
git checkout -b ${HEAD_BRANCH}
git add -u
git commit -m "Update gems ${NOW}"
git push origin ${HEAD_BRANCH}

# Create a pull request
API_URL="https://api.github.com/repos/${REPO}/pulls"
echo '{ "title": "Update gems '"${NOW}"'", "head": "'"${HEAD_BRANCH}"'", "base": "'"${BASE_BRANCH}"'", "body": '${BODY}' }' |
  curl -H "Authorization: token ${GITHUB_TOKEN}" -X POST --data-binary @- ${API_URL}

定期的に bundle update をする

CI で上記のスクリプトを実行すると定期的な bundle update が実現できます。
下記は AWS CodeBuild の buildspec.yml の例です。*2

version: 0.1

phases:
  pre_build:
    commands:
      - gem install bundler
      - gem install bundler_diffgems
      - git config user.name bundler_bot
      - git config user.email mail@example.com
  build:
    commands:
      - ./bin/update_gems

AWS で定期実行する例

f:id:sinsoku:20170315194805p:plain

おわり

gem のアップデートを定期実行していない人でも便利に使えると思うので、ぜひお試しください。

*1:compare_linkerに似てる

*2:これを AWS CloudWatch Event + AWS Lambda で定期実行してます

名古屋Ruby会議03に参加した #nagoyark03

2/11(土) に 名古屋Ruby会議03 に参加してました。

感想

良かった

会場が大須演芸場ってこともあり、他のイベントと雰囲気が異なっていてとても良かったです。 #nagoyark03 を見ると雰囲気がわかると思います。

発表者の口調もなんか落語っぽい感じになっていたり、2階でお酒飲みながら発表を聞いたり、なかなか出来ない経験が出来ました。

ちょっと気になった

電源・ネットワークに関する設備が貧弱だったのは少しだけ気になった。
使える無線LANとか無かったようなので、私は自分の WiFi ルータを使っていたけど、他の人も同じように対応してたのかな。

宿ないマン

「きっと何人かは遅くまで飲んでいるだろう」と思って、今回は宿の予約なしで参加していました。

朝近くまでお酒飲みながら技術の話をするのはやっぱり楽しいですね。 自分が知らない技術的な話題、考え方なども聞けて、かなり有意義だった。

ただ、翌日日曜の予定も潰れるのでオススメはできない。

mzp さんの prpr へのプルリク

mzp/prpr に前々から secret token のチェックについてプルリクを投げたかったので、この機会にプルリクを投げておいた。

東京にも帰り着き、プルリクも作り終わったので、これで私の #nagoyark03 は無事に終わりました。

最後に

朝早くから準備されていたスッタフ、発表者の方々お疲れ様です。
ありがとうございました!

転職DRAFTの友達紹介でオライリー本が貰えるのはいつなのか?

転職DRAFT は友達を紹介する(もしくは紹介される)とオライリー本が貰えます。

ただ、7月くらいに 友達の紹介 で登録して、更に8月に 友達を紹介 したのですが、まだ1冊しか頂けていない。

発送時期について

TOP に紹介キャンペーンについては書かれています。

f:id:sinsoku:20161209155120p:plain

ただ、ここにはオライリー本の発送時期については 明記されていません

f:id:sinsoku:20161209155501j:plain

8月に運営から頂いたメール

以下、メールの全文。

いつもご利用いただきありがとうございます。
転職ドラフト運営事務局でございます。

友達紹介成立の件でご連絡させて頂きました。

この度はお客様にはお手数をおかけし、誠に恐れ入ります。

sinsoku様の友達紹介は仰るとおり現在合計【2件】成立しております。
但し2件目の友達紹介は第二回転職ドラフト終了後の8/1に成立しておりました。
その為、2件目の書籍は第三回転職ドラフト終了後に贈呈させていただく形となります。

その為、誠に恐れ入りますが、今回は頂いた本のタイトルの中から、
今回欲しい本を【1冊】だけお選びいただけないでしょうか。
2冊目の書籍につきましては、第三回転職ドラフト終了後に改めて贈呈させていただければと存じます。

この度は友達紹介の贈呈日のルールが分かりにくい形だったかと存じます。
この度はsinsoku様にはご迷惑をおかけし、誠に申し訳ございませんでした。
今回のことを受け、より分かりやすいルールとなるよう改善させていただければと存じます。

本件につきまして、何卒宜しくお願い致します。

締切のシステムがよく分からないけど、なんか締切を過ぎてたらしい。
ただ、第三回転職ドラフト終了して1ヶ月くらい経つけど、連絡は特にない。*1

2冊目の書籍は贈呈されるのか

大した額のものじゃないので正直どうでも良いけど、リブセンスが書籍の発送状況の管理をどうやってるのか気になるなーと思いました。
今どきメールで書籍タイトルの返信を要求してくるようなサイトなので、担当者によるスプレッドシート管理とかなのかな。

*1:4ヶ月前の話なので完全に忘れてました

paiza.IO から paiza.IO API を使って再帰呼び出しを書こうとして動かなかった話

タイトルの通りだけど paiza.IO API を見つけた時にふと思い浮かんだので試してみた。

まぁ、これ動いちゃったら paiza.IO のリソースを使い潰せるので、ちゃんと対策してるんだろうなー。

コード 1

FizzBuzz です。途中にある pデバッグ目的のやつ。

require 'json'
require 'net/http'

PAIZA_API_URL = 'http://api.paiza.io/runners/create'
SLACK_API_URL = '<Slack Incoming WebHook URL>'
MAX_SIZE = 10
SOURCE = File.read(__FILE__)

def fizzbuzz(n)
  [].tap do |arr|
    arr << 'Fizz' if (n % 3).zero?
    arr << 'Buzz' if (n % 5).zero?
    arr << n.to_s if arr.empty?
  end.join(" ")
end

def post_runners(input)
  uri = URI.parse(PAIZA_API_URL)
  options = { language: :ruby, source_code: SOURCE, api_key: :guest }
  res = Net::HTTP.post_form(uri, options.merge(input: input))
  p input
  p options
  p res
  p res.body
end

def post_slack(text)
  uri = URI.parse(SLACK_API_URL)
  res = Net::HTTP.post_form(uri, payload: JSON.dump(text: text))
  p res
  p res.body
end

def main(accumulator, n)
  result = [].tap do |arr|
    arr << accumulator unless accumulator.empty?
    arr << fizzbuzz(n)
  end.join(', ')

  input = "#{result}\n#{n.next}"
  if n < MAX_SIZE
    post_runners(input)
  else
    post_slack(result)
  end
end

args = $stdin.read.split("\n")
p args
if args.size > 1
  main(args[0], args[1].to_i)
else
  p 'args are required'
end

このコードは実行できたけど、API で実行したコードではエラーが発生して止まっていた。

/usr/local/rbenv/versions/2.3.3/lib/ruby/2.3.0/net/http.rb:882:in `rescue in block in connect': Failed to open TCP connection to api.paiza.io:80 (getaddrinfo: Temporary failure in name resolution) (SocketError)

コード 2

API 実行により、Slack へ投稿するコード。

require 'net/http'
PAIZA_API_URL = 'http://api.paiza.io/runners/create'

def post_runners(source_code)
  uri = URI.parse(PAIZA_API_URL)
  options = { language: :ruby, source_code: source_code, api_key: :guest }
  res = Net::HTTP.post_form(uri, options)
  p res
  p res.body
end

source_code = <<-EOF
require 'json'
require 'net/http'
SLACK_API_URL = '<Slack Incoming WebHook URL>'

def post_slack(text)
  uri = URI.parse(SLACK_API_URL)
  res = Net::HTTP.post_form(uri, payload: JSON.dump(text: text))
end

post_slack('hello')
EOF

puts '---'
puts source_code
puts '---'

post_runners(source_code)

これも同じエラーが出ました。

まとめ

paiza.IO API を使って実行したコード内からは外部ネットワーク接続は無理っぽい。