I implemented Rails API with TDD by RSpec. part1-Action implementation without authentication-

Introduction

This article is written because I want to organize and organize my memories for myself. Of course, I will write it so that it will not be embarrassing to read it, but if I can organize it myself, it is an OK article, so I think that there may be a lack of explanation, but I'm sorry.

Article content

――This article will be developed in the form of TDD. The test framework uses RSpec. ――We will create an article management system using a simple Article and User model. --Authentication uses OAuth authentication using github API. --database uses the default sqlite3 to focus on the api --Part1 will proceed with the goal of implementing the index action ――This article will be long.

# 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

Preparing for development

terminal


$ rails new api_name -T --api

-T does not generate files related to minitest --api can start with a file structure for the api by pre-declaring that this project will only be used for the api (eg no view file is generated)

gemfile



gem 'rspec-rails'              #rspec gem
gem 'factry_bot_rails'         #A gem that contains test data
gem 'active_model_serializers' #Gem for serialize
gem 'kaminari'                 #Gem for pagenate
gem 'octokit', "~> 4.0"        #Gem for working with github users

Insert the above gem into development

bundle install

This completes the preparation for development

Let's develop

First, if you trace the overall flow, Implementation of index action ↓ Authentication related implementation (user model implementation) ↓ Implementation of create action ↓ Implementation of update action

I will proceed with the flow

article Create model

$ rails g model title:string content:text slug:string

Create a model with this configuration

Then write the test code in the models / article_spec.rb created earlier.

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

The content of this test is that if the title is empty, an error message will be issued, and the other is that it will be caught in validation.

$ rspec spec/models/article_spec.rb

Run and run the test

Then check that you get an error In this case

undefined method build

I get an error like this, this is proof that factory_bot is not working, so add the following

ruby:rails_helper.rb:42


config.include FactoryBot::Syntax::Methods

Then run rspec again $ 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

You can see that the title has been saved as nil without getting caught in validation So, actually describe validation in model

app/models/article.rb


class Article < ApplicationRecord
  validates :title, presence: true
end

Run the test again and pass

And describe this in the same way for content and 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

Run the test and pass everything

3 examples, 0 failures

And another thing I want to apply validation is whether the slug is unique

So, add a test about 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

Create an article once with create and create an article again with build. And since the slug of the first article is specified for the second article, the slugs of the two articles are the same. Test with this.

By the way, the difference between create and build depends on whether or not it is saved in the database, and the usage changes depending on it. Also, if you use create for everything, it will be heavy, so if you do not need to save it in the database (determined by expect directly after creation), use build to reduce memory consumption as much as possible.

Run the test and see the error

failure


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

You can see that it has been saved even though it is not unique as it is So, I will describe the model

app/models/article.rb


validates :slug, uniqueness: true

Then clear all tests This completes the article model test

articles#index

Next, we will implement the controller, first we will test from the routing

articles#index routing

Create spec / routing / Then create spec / routing / articles_spec.rb

Describe the contents

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

This is a test to make sure the route is working properly

I get an error when I run it

No route matches "/articles"

I get an error that the routing for / articles does not exist So add the routing

routes.rb


resources :articles, only: [:index]

Run the test but get an error

failure


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

This means that there is a route called / article, but there is nothing that applies to the articles controller, so we will actually write it in the controller.

$ rails g controller articles

Create controller Describe the contents

app/controllers/articles_controller.rb


def index; end

The test goes through

I will also implement the show action

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

Then run the test and make sure it passes.

articles # index implementation

Next, we will actually implement the contents of the controller

First of all, I will write from the test

Create a file called spec / controllers and create a file called ʻarticles_controller_spec.rb` in it.

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

This test simply sends a get: index request and expects a 200 number to be returned. : ok has the same meaning as 200.

Run the test as it is.

expected the response to have status code :ok (200) but it was :no_content (204)

Message appears and the test fails. This message means that I was expecting 200 but 204 was returned. Since 204 did not return anything, it is mostly used in responses such as delete and update. But this time I want 200 to be returned, so I will edit the controller.

app/controllers/articles_controller.rb


  def index
    articles = Article.all
    render json: articles
  end

The content is simple, all articles are fetched from the database and returned by render. It is written as json: to return in json format By the way, even if you return render articles as it is, 200 will be returned, so this test will succeed. A response that is not in json format is not preferable. Later, I will analyze the response using serializer, but at that time it is still necessary to convert it to json, so do not forget to add json :.

Let's write a test to check the json format

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

Run this test once. I will write the explanation when necessary.

Validation failed: Slug has already been taken

First I get this error. If slug does not appear unique, it will be validated. So edit the factory bot to make each 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

By using sequence in this way, the name of each article can be made unique.

Let's run it.

Failure/Error: json_data = json[:data]
no implicit conversion of Symbol into Integer

I get a message like this and it fails. This is an error due to the absence of [: data], and since this [: data] is when the format is converted using serializer, the format of json is converted using serializer.

First in advance Since the gem is included in the description gem'active_model_serializers', it will be described using it.

First, create a file for serializer.

$ rails g serializer article title content slug

This will create ʻapp / serializers / article_serializer.rb`.

And write a new one to adapt the newly introduced active_model_serializer.

config/initializers/active_model_serializers.rb


ActiveModelSerializers.config.adapter = :json_api

Create this file and add the description to adapt the newly introduced serializer.

This allows you to change what was used in default and convert it to a format that can be retrieved with [: data].

Let's look at the difference between each response.

Before introducing 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"}]

After introducing 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"}}]}

You can see that the structure inside has changed. Also, the created_at and updated_at attributes that are not specified in serializer are truncated.

Now run the test again and it will succeed.

There are many duplicate expressions of success, so I will refactor it a little.

get: index, but since this is often sent multiple times, define it all at once.

describe '#index' do
  subject { get :index }

By describing in this way, it can be described collectively. To use that definition, just type subject below the defined location. If it is defined again in the hierarchy below it, the one defined after that will be used.

You can use it to replace subject in two places.

    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

And this part can be combined with duplicate expressions by using each_with_index.

And even more

json = JSON.parse(response.body)
json_data = json['data']

Since these two descriptions are often used repeatedly, they are defined in the helper method.

Create spec / support / and create json_api_helpers.rb under it.

spec/support/json_api_helpers.rb


module JsonApiHelper
  def json
    JSON.parse(response.body)
  end

  def json_data
    json["data"]
  end
end

Include it in spec_helper.rb so that it can be handled by all files.

spec/rails_helper.rb


config.include JsonApiHelpers

And uncomment the description that made support read.

spec/rails_helper.rb


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

This allows you to omit the previous description.

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

The index is almost complete. However, the latest article order is the last. Set sort so that the latest comes first.

First, write the expected test.

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

Add the above description. Create an article twice, one old and one new. Then, it is determined whether the latest is coming first by the number of json_data. The to_s method is used because all of the values are converted to strings when serializer is used, so the data generated by factorybot using the to_s method must be converted to strings. Note that id is also converted to a character string.

Now run the test. rspec spec/controllers/articles_controller_spec.rb

failure


expected: "2"
got: "1"

Such a message is output. I haven't touched sort yet, so it's natural, but the last article is coming first.

I want to implement sort, but I want to describe it in model as a method, so write the model test first. The method name is .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

It does something similar to testing a controller, except that it's separate from the controller and focuses only on the process of calling the method. In short, this test is all you need to do.

described_class.method_name This allows you to call class methods. In this case, described_class is Article, but what is included in it depends on the file to be described.

I also want to test if old articles come recently when I update. So add the following.

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

When you run the test

Since ʻundefined method recent` appears, we will define recent.

So far, I wrote that recent is defined as a method, but since scope is more suitable for the purpose, I will implement it with scope. In most cases, scope is treated the same as the class method.

I wrote a long test, but the implementation is over soon.

app/models/article.rb


scope :recent, -> { order(created_at: :desc) }

Add a line to define the scope.

And all the tests of the model that executes the test turn green. rspec spec/models/article_spec.rb

However, the controller fails a few times. rspec spec/controllers/articles_controller_spec.rb That's because I just defined recent and not used it in controllerd, so I will actually write it in controller.

app/controllers/articles_controller.rb


articles = Article.recent

Change Article.all to Article.recent.

Now run the test again. rspec spec/controllers/articles_controller_spec.rb

Then, it fails because the test side cannot sort, so the test side also sorts.

spec/controllers/articles_controller_spec.rb


 Article.recent.each_with_index do |article, index| #14th line

I wrote Article.recent instead of articles.recent because articles are generated by factory_bot and not an actual instance of Article, so .recent cannot be used.

Since it is no longer necessary to use what was created directly with factorybot, articles = create_list :article, 2 This description is create_list :article, 2 Just create it like this.

Now run the test and it will succeed.

Next, implement pagination. Pagination allows you to specify how many articles per page.

First, write from the test as usual.

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

Add this line. A new parameter is specified in params. page specifies the number of pages, and per_page specifies how many articles per page. In this case, I want a second page, and that page has only one article.

Run the test.

failure


expected: 1
got: 3

I expected that only one would be returned, but all the articles were returned. So, we will implement pagination from here.

I have already added a gem called kaminari at the beginning of this article. kaminari is a gem that can easily realize pagination.

So I will implement it using that gem.

app/controllers/articles_controller.rb


    articles = Article.recent.
      page(params[:page]).
      per(params[:per_page])

Following recent, I will add it. In this way, you can use .page and .per like or mapper. Then insert the value sent by params into that argument.

With this, the response can be narrowed down, and the amount of data returned by one response can be specified.

Now run the test again and it will succeed.

Part 1 that was once planned here is over. The implementation of index is completed.

Thank you for staying with us for a long article. Thank you for your hard work.

I posted pert2.

I implemented Rails API with TDD by RSpec. part2

Recommended Posts

I implemented Rails API with TDD by RSpec. part1-Action implementation without authentication-
I implemented Rails API with TDD by RSpec. part3-Action implementation with authentication-
I implemented Rails API with TDD by RSpec. part2 -user authentication-
# 8 seed implementation to build bulletin board API with authentication authorization in Rails 6
I rewrote the Rails tutorial test with RSpec
# 6 show, create implementation to build bulletin board API with authentication authorization in Rails 6
Build a bulletin board API with authentication authorization in Rails 6 # 5 controller, routes implementation
# 7 update, destroy implementation to build bulletin board API with authentication authorization in Rails 6
Using PAY.JP API with Rails ~ Implementation Preparation ~ (payjp.js v2)
[Rails] Test with RSpec
Building a bulletin board API with authentication authorization with Rails 6 Validation and test implementation of # 4 post
Build a bulletin board API with authentication and authorization with Rails # 18 ・ Implementation of final user controller
Build a bulletin board API with authentication authorization with Rails 6 # 3 RSpec, FactoryBot introduced and post model
[Rails] I implemented the validation error message by asynchronous communication!
[Ruby on Rails] Implement login function by add_token_to_users with API
API creation with Rails + GraphQL
Login function implementation with rails
[Rails 6] Implementation of new registration function by SNS authentication (Facebook, Google)
What I was addicted to when implementing google authentication with rails