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.
――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
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
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
$ 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.
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 implemented Rails API with TDD by RSpec. part2
Recommended Posts