LoginSignup
2
2

More than 3 years have passed since last update.

RSpecによるTDDでRailsAPIを実装してみた。part1 -認証なしのアクション実装-

Last updated at Posted at 2020-05-19

はじめに

この記事は自分のための記憶を整理したりまとめたいという理由で書く記事です。もちろん読まれても恥ずかしく無いように書くますが、あくまでも自分で整理できればそれで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

開発の準備

terminal
$ rails new api_name -T --api

-T はminitestに関わるファイルを生成しない
--api はこのプロジェクトはapiのためだけに使いますという宣言を前もってすることで、apiのためのファイル構成で始めることができる(ビューファイルが生成されないなど)

gemfile

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にテストコードを記述していく

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が動いていない証拠なので、以下を追加

rails_helper.rb
config.include FactoryBot::Syntax::Methods

その後再びrspecを実行
$ rspec spec/models/article_spec.rb

failure
expected #<Article id: nil, title: "", content: "MyText", slug: "MyString", created_at: nil, updated_at: nil> not to be valid

validationに引っかからずにtitleがnilで保存されてしまっていることがわかる
なので、実際にmodelにvalidationを記述していく

app/models/article.rb
class Article < ApplicationRecord
  validates :title, presence: true
end

再度テストを実行して通る

そしてこれをcontentとslugも同じように記述していく

models/article_spec.rb
    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
app/models/article.rb
  validates :content, presence: true
  validates :slug, presence: true

テストを実行し全て通る

3 examples, 0 failures

そしてもう一つvalidationをかけておきたいのが、slugが一意かどうか

なので、uniqueに関するテストも追加で記述

models/article_spec.rb
    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を使いメモリの消費をできるだけ減らす。

テストを実行しエラーが出るのを確認

failure
expected #<Article id: nil, title: "MyString", content: "MyText", slug: "MyString", created_at: nil, updated_at: nil> not to be valid

そのままuniqueではないのに保存されてしまっているのがわかる
なので、modelを記述していく

app/models/article.rb
validates :slug, uniqueness: true

その後テストは全てクリアする
これでarticleモデルのテストは終わり

articles#index

次はコントローラーを実装していく、まずはルーティングからテストしていく

articles#index routing

spec/routing/を作成
その後spec/routing/articles_spec.rbを作成

中身を記述していく

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のルーティングは存在していないというエラーが出る
なのでルーティングを追加する

routes.rb
resources :articles, only: [:index]

テストを実行するがエラーが出る

failure
 A route matches "/articles", but references missing controller: ArticlesController

これは/articleというルートは存在しているが、articlescontrollerに当てはまるものが存在していないということなので、実際にコントローラーに記述していく

$ rails g controller articles

controllerを作成
中身を記述していく

app/controllers/articles_controller.rb
def index; end

テストがとおる

ついでにshowアクションも実装していく

spec/routing/articles_spec.rb
  it 'should route articles show' do
    expect(get '/articles/1').to route_to('articles#show', id: '1')
  end
routes.rb
resources :articles, only: [:index, :show]
articles_controller.rb
  def show
  end

そしてテストを実行し、通ることを確認する。

articles#index 実装

次に実際にcontrollerの中身を実装していく

まずはテストから書いていく

spec/controllersというファイルを作りその中にarticles_controller_spec.rbというファイルを作っていく

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を編集していく。

app/controllers/articles_controller.rb
  def index
    articles = Article.all
    render json: articles
  end

内容はシンプルで、データベースから全てのarticleを取り出し、それをrenderで返すという処理をしている。
json形式で返すためにjson:という書き方をしている
ちなみに、render articlesとそのまま返しても200が返るのでこのテストは成功する。json形式ではないresponseは好ましいとは言えない。後にserializerを使ってresponseを解析していくが、その時にはやはりjsonに変換しておく必要があるので json:はつけ忘れないようにする。

それではjson形式を確認するtestを書いていく

spec/controllers/articles_controller_spec.rb
    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にする。

spec/factories/articles.rb
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を適応させるために新しく記述する。

config/initializers/active_model_serializers.rb
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を作成する。

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させておく。

spec/rails_helper.rb
config.include JsonApiHelpers

そしてsupportを読み込ませるようにしたの記述のコメントアウトを外しておく。

spec/rails_helper.rb
Dir[Rails.root.join('spec', 'support', '**', '*.rb')].sort.each { |f| require f }

これにより、以前の記述を省略して書ける。

spec/controllers/articles_controller_spec.rb
    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を設定する。

まずは期待するテストから書く。

spec/controllers/articles_controller_spec.rb
    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

failure
expected: "2"
got: "1"

このようなメッセージが出力される。
まだ、sortを何も触っていないので、当たり前だが、最後のarticleは最初に来ている。

sortを実装したいのだが、メソッドとしてmodelに記述したいのでmodelのテストをまず先に書く。
メソッド名は.recentとする。

spec/models/article_spec.rb
  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した時に古い記事が最近に来るかどうかもテストしたい。
なので以下を追記する。

spec/models/article_spec.rb
    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メソッドと同じように扱われる。

長々とテストを書いたが、実装はすぐ終わる。

app/models/article.rb
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に記述していく。

app/controllers/articles_controller.rb
articles = Article.recent

Article.allをArticle.recentに変更する。

これでもう一度テストを実行する。
rspec spec/controllers/articles_controller_spec.rb

すると、test側でsortできていなくて失敗するので、test側もsortする。

spec/controllers/articles_controller_spec.rb
 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とは一ページあたり幾つの記事というふうに指定できるようにするもの。

まずはいつものようにテストから書いていく。

spec/controllers/articles_controller_spec.rb
    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ということになる。

テストを実行する。

failure
expected: 1
got: 3

一つしか返ってこないことを期待したが、全てのarticleが返ってきてしまった。なので、ここからpaginationを実装していく。

この記事の一番初めにkaminariというgemは既に追加している。kaminariはpaginationを簡単に実現することができるgem。

なのでそのgemを使って実装していく。

app/controllers/articles_controller.rb
    articles = Article.recent.
      page(params[:page]).
      per(params[:per_page])

recentに続けて、追記する。このように、orマッパーのように、.pageや.perを使うことができる。そしてその引数にparamsで送られてきた値を挿入すれば良い。

これで、responseを絞り込むことができ、一度のresponseによって返すデータ量を指定することができる。

これでもう一度テストを実行すれば成功する。

いったんここで予定していたpart1は終わり。indexの実装は完了。

長い記事にお付き合いいただきありがとうございます。お疲れ様でした。

pert2投稿しました。

RSpecによるTDDでRailsAPIを実装してみた。part2

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2