はじめに
この記事は自分のための記憶を整理したりまとめたいという理由で書く記事です。もちろん読まれても恥ずかしく無いように書くますが、あくまでも自分で整理できればそれでOKな記事でありますので、説明不足ということがあるかと思いますが、あしからず...。
記事の内容
- この記事はTDDの形で開発をすめていきます。テストフレームワークはRSpecを使っています。
- 簡単なArticleとUserのモデルを使った記事管理システムを作っていきます。
- 認証はgithubAPIを使ったOAuth認証を用います。
- databaseはapiに集中するためにデフォルトのsqlite3を使います
- part1はindexアクションの実装までを目標に進めていきます
- この記事は長くなります。
# versions
ruby 2.5.1
Rails 6.0.3.1
RSpec 3.9
- rspec-core 3.9.2
- rspec-expectations 3.9.2
- rspec-mocks 3.9.1
- rspec-rails 4.0.1
- rspec-support 3.9.3
開発の準備
$ rails new api_name -T --api
-T はminitestに関わるファイルを生成しない
--api はこのプロジェクトはapiのためだけに使いますという宣言を前もってすることで、apiのためのファイル構成で始めることができる(ビューファイルが生成されないなど)
gem 'rspec-rails' # rspecのgem
gem 'factry_bot_rails' # テスト用のデータを入れるgem
gem 'active_model_serializers' # serializeするためのgem
gem 'kaminari' # pagenateするためのgem
gem 'octokit', "~> 4.0" # githubユーザーを扱うためのgem
以上のgemをdevelopmentの中に挿入
bundle install
これでいったん開発の準備は終わる
では開発していく
まず全体的な流れをなぞっておくと、
indexアクションの実装
↓
認証関係の実装(userモデルの実装)
↓
createアクションの実装
↓
updateアクションの実装
という流れで進めていく
articleモデルを作成
$ rails g model title:string content:text slug:string
こんな構成のモデルを作成する
そして先ほど作成されたmodels/article_spec.rb
にテストコードを記述していく
require 'rails_helper'
RSpec.describe Article, type: :model do
describe '#validations' do
it 'should validate the presence of the title' do
article = build :article, title: ''
expect(article).not_to be_valid
expect(article.errors.messages[:title]).to include("can't be blank")
end
end
end
このテストの内容はもしもタイトルが空だった時にエラーメッセージを出す、というものと、もう一つはvalidationに引っかかるというテスト
$ rspec spec/models/article_spec.rb
を実行し、テストを動かす
その後エラーが出ることを確認する
今回の場合は
undefined method build
というようなエラーが出る、これはfactory_botが動いていない証拠なので、以下を追加
config.include FactoryBot::Syntax::Methods
その後再びrspecを実行
$ rspec spec/models/article_spec.rb
expected #<Article id: nil, title: "", content: "MyText", slug: "MyString", created_at: nil, updated_at: nil> not to be valid
validationに引っかからずにtitleがnilで保存されてしまっていることがわかる
なので、実際にmodelにvalidationを記述していく
class Article < ApplicationRecord
validates :title, presence: true
end
再度テストを実行して通る
そしてこれをcontentとslugも同じように記述していく
it 'should validate the presence of the content' do
article = build :article, content: ''
expect(article).not_to be_valid
expect(article.errors.messages[:content]).to include("can't be blank")
end
it 'should validate the presence of the slug' do
article = build :article, slug: ''
expect(article).not_to be_valid
expect(article.errors.messages[:slug]).to include("can't be blank")
end
validates :content, presence: true
validates :slug, presence: true
テストを実行し全て通る
3 examples, 0 failures
そしてもう一つvalidationをかけておきたいのが、slugが一意かどうか
なので、uniqueに関するテストも追加で記述
it 'should validate uniqueness of slug' do
article = create :article
invalid_article = build :article, slug: article.slug
expect(invalid_article).not_to be_valid
end
一度articleをcreateで作り再びbuildでarticleを作る。そして二つ目のarticleには一つ目のarticleのslugを指定しているので、二つのarticleのslugは同じものになる。これでテストをする。
ちなみにcreateとbuildの違いはデータベースに保存するかどうかで、それによって用途は変わってくる。
また、createを全てに使ってしまうと、重くなってしまうので、データベースに保存する必要のない(生成後直接expectで判定される)ものなどはbuildを使いメモリの消費をできるだけ減らす。
テストを実行しエラーが出るのを確認
expected #<Article id: nil, title: "MyString", content: "MyText", slug: "MyString", created_at: nil, updated_at: nil> not to be valid
そのままuniqueではないのに保存されてしまっているのがわかる
なので、modelを記述していく
validates :slug, uniqueness: true
その後テストは全てクリアする
これでarticleモデルのテストは終わり
articles#index
次はコントローラーを実装していく、まずはルーティングからテストしていく
articles#index routing
spec/routing/
を作成
その後spec/routing/articles_spec.rb
を作成
中身を記述していく
require 'rails_helper'
describe 'article routes' do
it 'should route articles index' do
expect(get '/articles').to route_to('articles#index')
end
end
これはrouteが正しく動いているかを確認するテスト
実行するとエラーが出る
No route matches "/articles"
/articles
のルーティングは存在していないというエラーが出る
なのでルーティングを追加する
resources :articles, only: [:index]
テストを実行するがエラーが出る
A route matches "/articles", but references missing controller: ArticlesController
これは/articleというルートは存在しているが、articlescontrollerに当てはまるものが存在していないということなので、実際にコントローラーに記述していく
$ rails g controller articles
controllerを作成
中身を記述していく
def index; end
テストがとおる
ついでにshowアクションも実装していく
it 'should route articles show' do
expect(get '/articles/1').to route_to('articles#show', id: '1')
end
resources :articles, only: [:index, :show]
def show
end
そしてテストを実行し、通ることを確認する。
articles#index 実装
次に実際にcontrollerの中身を実装していく
まずはテストから書いていく
spec/controllersというファイルを作りその中にarticles_controller_spec.rb
というファイルを作っていく
require 'rails_helper'
describe ArticlesController do
describe '#index' do
it 'should return success response' do
get :index
expect(response).to have_http_status(:ok)
end
end
end
このテストは単純に、get :indexのリクエストを送り、200番が返って来ることを期待するというテスト。:okは200と同じ意味。
そのままテストを実行する。
expected the response to have status code :ok (200) but it was :no_content (204)
というメッセージが出て、テストが失敗する。
このメッセージは200を期待していたが204が返ってきたことを意味する。
204は何も返ってこなかった、ということなので、大体はdeleteやupdateなどのレスポンスで利用される。しかし今回は200が返ってきて欲しいので、controllerを編集していく。
def index
articles = Article.all
render json: articles
end
内容はシンプルで、データベースから全てのarticleを取り出し、それをrenderで返すという処理をしている。
json形式で返すためにjson:
という書き方をしている
ちなみに、render articles
とそのまま返しても200が返るのでこのテストは成功する。json形式ではないresponseは好ましいとは言えない。後にserializerを使ってresponseを解析していくが、その時にはやはりjsonに変換しておく必要があるので json:はつけ忘れないようにする。
それではjson形式を確認するtestを書いていく
it 'should return proper json' do
create_list :article, 2
get :index
json = JSON.parse(response.body)
json_data = json['data']
expect(json_data.length).to eq(2)
expect(json_data[0]['attributes']).to eq({
"title" => "My article 1",
"content" => "The content of article 1",
"slug" => "article-1",
})
end
いったんこのテストを実行する。説明は必要な時に書いていく。
Validation failed: Slug has already been taken
まずこのエラーが出る。slugはunique出ないとvalidationにかかってしまう。なのでfactory botを編集して一つひとつのarticleをuniqueにする。
FactoryBot.define do
factory :article do
sequence(:title) { |n| "My article #{n}"}
sequence(:content) { |n| "The content of article #{n}"}
sequence(:slug) { |n| "article-#{n}"}
end
end
このようにsequenceを使えば、一つ一つのarticleの名前をuniqueにすることができる。
これで実行してみる。
Failure/Error: json_data = json[:data]
no implicit conversion of Symbol into Integer
このようなメッセージが出て失敗する。これは[:data]が存在しないことによるエラーで、この[:data]はserializerを使って形式を変換したときのものなので、serializerを使って、jsonの形式を変換していく。
最初にあらかじめ
gem 'active_model_serializers'
という記述をしてgemを入れているので、それを使って、記述していく。
まずはserializer用のファイルを作っていく。
$ rails g serializer article title content slug
これにより、app/serializers/article_serializer.rb
が作成される。
そしてその新しく導入したactive_model_serializerを適応させるために新しく記述する。
ActiveModelSerializers.config.adapter = :json_api
このファイルを作り、記述を追加することで新しく導入したserializerを適応させる。
これにより、defaultで使われていたものを変更することができ、[:data]などで取り出すことができる形式に変換することができる。
それぞれのレスポンスの違いを見ていく。
ActiveModel::Serializer導入前
JSON.parse(response.body)
=> [{"id"=>1,
"title"=>"My article 1",
"content"=>"The content of article 1",
"slug"=>"article-1",
"created_at"=>"2020-05-19T06:22:49.045Z",
"updated_at"=>"2020-05-19T06:22:49.045Z"},
{"id"=>2,
"title"=>"My article 2",
"content"=>"The content of article 2",
"slug"=>"article-2",
"created_at"=>"2020-05-19T06:22:49.049Z",
"updated_at"=>"2020-05-19T06:22:49.049Z"}]
ActiveModel::Serializer導入後
JSON.parse(response.body)
=> {"data"=>
[{"id"=>"1",
"type"=>"articles",
"attributes"=>
{"title"=>"My article 1", "content"=>"The content of article 1", "slug"=>"article-1"}},
{"id"=>"2",
"type"=>"articles",
"attributes"=>
{"title"=>"My article 2", "content"=>"The content of article 2", "slug"=>"article-2"}}]}
中の構造が変わっていることがわかる。また、serializerで指定していない、created_atやupdated_atの属性は切り捨てられている。
これで再びテストを実行すると成功する。
成功したものの重複した表現が多いので、少しリファクタリングをする。
get :index
だが、これは複数回送ることが多いので、まとめて一回で定義する。
describe '#index' do
subject { get :index }
このように記述することで、まとめて記述できる。
その定義を使うためには定義した場所から下でsubject
と打つだけで使うことができる。
仮に、その下の階層でもう一度定義された場合は、そのあとから定義した方が使われる。
それを使い二箇所にsubjectと置き換えることができる。
it 'should return proper json' do
articles = create_list :article, 2
subject
json = JSON.parse(response.body)
json_data = json['data']
articles.each_with_index do |article, index|
expect(json_data[index]['attributes']).to eq({
"title" => article.title,
"content" => article.content,
"slug" => article.slug,
})
end
そして、この部分はeach_with_indexを使うことで、重複した表現をまとめることができる。
そしてさらに
json = JSON.parse(response.body)
json_data = json['data']
この二つの記述は繰り返し使うことが多いので、helperメソッドに定義しておく。
spec/support/
を作成し、その下にjson_api_helpers.rb
を作成する。
module JsonApiHelper
def json
JSON.parse(response.body)
end
def json_data
json["data"]
end
end
これを全てのファイルで扱えるようにするために、spec_helper.rbでincludeさせておく。
config.include JsonApiHelpers
そしてsupportを読み込ませるようにしたの記述のコメントアウトを外しておく。
Dir[Rails.root.join('spec', 'support', '**', '*.rb')].sort.each { |f| require f }
これにより、以前の記述を省略して書ける。
it 'should return proper json' do
articles = create_list :article, 2
subject
articles.each_with_index do |article, index|
expect(json_data[index]['attributes']).to eq({
"title" => article.title,
"content" => article.content,
"slug" => article.slug,
})
end
これで、indexはほぼ完成した。しかし、記事の順番が最新が最後になってしまう。最新は最初に来るように、sortを設定する。
まずは期待するテストから書く。
it 'should return articles in the proper order' do
old_article = create :article
newer_article = create :article
subject
expect(json_data.first['id']).to eq(newer_article.id.to_s)
expect(json_data.last['id']).to eq(old_article.id.to_s)
end
以上の記述を追記する。
2度articleをcreateし、古い方と新しい方を作る。
そして、それらがjson_dataの何番目かで、最新が、先に来ているかどうかを判定する。
to_sメソッドを使っているのはserializerを使うとvalueの全てが文字列に変換されるので、to_sメソッドを使って、factorybotで生成したデータは文字列にしなければいけない。idも文字列に変換されているので注意が必要。
これでテストを実行する。
rspec spec/controllers/articles_controller_spec.rb
expected: "2"
got: "1"
このようなメッセージが出力される。
まだ、sortを何も触っていないので、当たり前だが、最後のarticleは最初に来ている。
sortを実装したいのだが、メソッドとしてmodelに記述したいのでmodelのテストをまず先に書く。
メソッド名は.recentとする。
describe '.recent' do
it 'should list recent article first' do
old_article = create :article
newer_article = create :article
expect(described_class.recent).to eq(
[ newer_article, old_article ]
)
end
end
これはcontrollerのテストと似たようなことをしているが、違うのはcontrollerとは分離していて、メソッドを呼び出す処理のみにフォーカスしていること。要するにこのテストのみで完結する。
described_class.method_name
これにより、クラスメソッドを呼び出すことができる。
この場合のdescribed_classはArticleとなるが記述するファイルにより、そこに何が入るかは変わって来る。
あと、updateした時に古い記事が最近に来るかどうかもテストしたい。
なので以下を追記する。
it 'should list recent article first' do
old_article = create :article
newer_article = create :article
expect(described_class.recent).to eq(
[ newer_article, old_article ]
)
old_article.update_column :created_at, Time.now
expect(described_class.recent).to eq(
[ old_article, newer_article ]
)
end
テストを実行すると
undefined method recent
と出るので、recentを定義していく。
これまでrecentをメソッドとして定義するというふうに書いていたが、scopeの方が用途に合っているのでscopeで実装する。
scopeはほとんどの場合、classメソッドと同じように扱われる。
長々とテストを書いたが、実装はすぐ終わる。
scope :recent, -> { order(created_at: :desc) }
一行を追加してscopeを定義する。
そして、テストを実行するmodelのテストは全てがグリーンになる。
rspec spec/models/article_spec.rb
しかし、controllerの方はいくつか失敗する。
rspec spec/controllers/articles_controller_spec.rb
それはrecentを定義しただけで、controllerdで使ってないからなので、実際にcontrollerに記述していく。
articles = Article.recent
Article.allをArticle.recentに変更する。
これでもう一度テストを実行する。
rspec spec/controllers/articles_controller_spec.rb
すると、test側でsortできていなくて失敗するので、test側もsortする。
Article.recent.each_with_index do |article, index| # 14行目
このようにarticles.recentではなく、Article.recentと記述したのは、articlesはfactory_botの生成したものなので、実際のArticleのインスタンスではないので、.recentが使えないため。
直接factorybotで作成したものを利用する必要がなくなったので、
articles = create_list :article, 2
この記述は
create_list :article, 2
このようにcreateするだけにしておく。
これでテストを実行すれば成功する。
paginationを次は実装する。
paginationとは一ページあたり幾つの記事というふうに指定できるようにするもの。
まずはいつものようにテストから書いていく。
it 'should paginate results' do
create_list :article, 3
get :index, params: { page: 2, per_page: 1 }
expect(json_data.length).to eq 1
expected_article = Article.recent.second.id.to_s
expect(json_data.first['id']).to eq(expected_article)
end
この行を追加。paramsに新しいparameterを指定している。
pageは何ページ目かを指定していて、per_pageは一ページあたり、幾つのarticleかを指定している。今回の場合は、2ページ目が欲しくて、そのページに記載されているのは1つのarticleということになる。
テストを実行する。
expected: 1
got: 3
一つしか返ってこないことを期待したが、全てのarticleが返ってきてしまった。なので、ここからpaginationを実装していく。
この記事の一番初めにkaminariというgemは既に追加している。kaminariはpaginationを簡単に実現することができるgem。
なのでそのgemを使って実装していく。
articles = Article.recent.
page(params[:page]).
per(params[:per_page])
recentに続けて、追記する。このように、orマッパーのように、.pageや.perを使うことができる。そしてその引数にparamsで送られてきた値を挿入すれば良い。
これで、responseを絞り込むことができ、一度のresponseによって返すデータ量を指定することができる。
これでもう一度テストを実行すれば成功する。
いったんここで予定していたpart1は終わり。indexの実装は完了。
長い記事にお付き合いいただきありがとうございます。お疲れ様でした。