iT邦幫忙

第 12 屆 iT 邦幫忙鐵人賽

DAY 24
0
自我挑戰組

Metaprogramming Ruby and Rails系列 第 24

Day 24 -- Rails 利用 TDD 實作簡單的 tag 功能

TDD(Test-Driven Development)是什麼呢?

TDD 是一種以寫測試為驅動的開發模式,也就是先把規格及測試寫好、再開發需要的程式撰寫。對剛學習程式語言的人來說,或許有點難想像要如何測試尚未存在的程式。在寫此文章的當下,其實我也未開發標籤功能在專案上,希望藉由體驗 test-driven development 來完成此功能並記錄遇到的問題。

事前準備

本範例使用以下 gem

  • rspec-rails
  • faker
  • 任務基本CRUD (基本就不特別說明了)

標籤功能

在此次的實作,標籤將會是以字串的型態來描述任務內容,進而讓其他使用者可以進行搜尋。
目前可以安裝 acts-as-taggable-on 來幫助我們實現標籤的功能,但維護更新的次數似乎是一年ㄧ次,所以嘗試從無到有來建立標籤功能吧!

規格

  • 如果標籤建立成功時…欄位正確填寫完成
  • 標籤名字不能是空白
  • 標籤名字必須是獨一無二的
  • 標籤名字不能太長,也不能太短
  • 新增標籤與建立任務成功
  • 能夠以標籤進行任務搜尋

1. 欄位正確填寫完成

先設定好FactoryBot所需的測試樣板 spec > factories > tags.rb

FactoryBot.define do
  factory :tag do
    name { Faker::Name.name }
  end
end

建立標籤的單元測試 spec > models > tag_spec.rb

require 'rails_helper'
RSpec.describe Tag, type: :model do
  describe 'Model spec/單元測試' do
    context '如果標籤建立成功時...' do
      it '欄位正確填寫完成', tags: true do
        tag = FactoryBot.build(:tag)
        expect(tag).to be_valid
      end
    end
  end
end

會得到以下錯誤訊息
https://ithelp.ithome.com.tw/upload/images/20201009/201208680KhcFz7tfD.png

從錯誤訊息得知 Tag 還沒有初始化,因此需要建立 Tag model

rails generate model tag name:string

在確認 migration file 沒有問題後,在終端機輸入rails db:migrate

class CreateTags < ActiveRecord::Migration[6.0]
  def change
    create_table :tags do |t|
      t.string :name
      
      t.timestamps
    end
  end
end

再跑一次 rspec --tag tags 就會過了
https://ithelp.ithome.com.tw/upload/images/20201009/20120868md1TeQPAYY.png

2. 標籤名字不能是空白

建立另一個測試

it '標籤名字不能是空白', tags: true do
  tag = FactoryBot.build(:tag, name: '')
  expect { 
           expect(tag).to be_valid 
         }.to raise_exception(/Name 不能為空白/)
end

會得到以下錯誤訊息
https://ithelp.ithome.com.tw/upload/images/20201009/20120868uHPFKf2H2n.png
需要在 Tag model 裡設定驗證方法: app > models > tag.rb

class Tag < ApplicationRecord
  validates :name, presence: true
end

再跑一次 rspec --tag tags 就會過了
https://ithelp.ithome.com.tw/upload/images/20201009/20120868G8xxYjiXUV.png

3. 標籤名字必須是獨一無二的

接下來也是相同的流程:寫測試碼

it '標籤名字必須是獨一無二的', tags: true do
  tag1 = FactoryBot.create(:tag, name: 'shopping')
  tag2 = FactoryBot.build:tag, name: 'shopping')
  expect { 
           expect(tag2).to be_valid 
         }.to raise_exception(/Name 已經被使用/)
end

判讀錯誤訊息:嘗試寫更多程式碼,解決錯誤訊息
https://ithelp.ithome.com.tw/upload/images/20201009/20120868VQizyJNUTV.png

class Tag < ApplicationRecord
  validates :name, presence: true
  validates :name, uniqueness: true
end

https://ithelp.ithome.com.tw/upload/images/20201009/201208684SG0EzHQdi.png

4. 標籤名字不能太長

it '標籤名字不能太長', tags: true do
  tag = FactoryBot.build(:tag, name: 'k'*26)
  expect(tag).not_to be_valid
end
it '標籤名字不能太短', tags: true do
  tag = FactoryBot.build(:tag, name: 'k'*2)
  expect(tag).not_to be_valid
end

https://ithelp.ithome.com.tw/upload/images/20201009/20120868M1JhB9QWah.png

class Tag < ApplicationRecord
  validates :name, presence: true, length: {minimum: 3, maximum: 25}   
  validates :name, uniqueness: true
end

https://ithelp.ithome.com.tw/upload/images/20201009/20120868M57UNfW7B3.png

5. 新增標籤與建立任務成功

建立標籤的功能測試 spec > features> task_spec.rb

require 'rails_helper'
RSpec.feature 'Task', type: :feature do
  describe 'Feature spec/功能測試' do
    scenario '新增標籤與建立任務成功', tags_feature: true do
      login_user
      click_button '新增任務'
      fill_in_task_info
      fill_in 'task_all_tags', with: 'coding, rails'
      click_button '送出'
      within 'tr:nth-child(2)' do
        expect(page).to have_content('coding')
        expect(page).to have_content('rails')
      end
      expect(page).to have_text('建立成功')
    end
    
private
# 登入時填入使用者名稱及密碼
def login_user
  visit login_path
  fill_in('session[email]', with: user.email)
  fill_in('session[password]', with: user.password)
  click_button '送出'
end

# 建立任務所需要填入的資訊
def fill_in_task_info
  fill_in 'task_title', with: 'shopping'
  fill_in 'task_content', with: 'buy milk'
  select '2018', from: 'task_task_begin_1i'
  select '2019', from: 'task_task_end_1i'
  select '還好', from: 'task_priority'
  select '未進行', from: 'task_status'
end

得到錯誤訊息如下:
https://ithelp.ithome.com.tw/upload/images/20201009/20120868aCqUm96RM9.png
因為還沒有做出標籤欄位,所以找不到其實是正確的錯誤訊息。
接著來到新增任務的頁面,加上標籤欄位。
https://ithelp.ithome.com.tw/upload/images/20201009/20120868PyMasaoIyU.png
再跑一次 rspec --tag tags_feature 會看到另一個錯誤訊息
https://ithelp.ithome.com.tw/upload/images/20201009/20120868FCbnoByKyi.png
從錯誤訊息得知:由於在 Task 裡沒有 all_tags 這個方法或是屬性,所以需要定義 all_tags 才行

Virtual attribute

當使用者輸入:‘ coding, rails ’ 是需要先被拆解為單一個字,然後才能被存入進資料庫裡。我們需要的 all_tags 是虛擬屬性( Virtual attribute ),與現有的資料庫欄位不相對應。
因此,在 Task model 裡,我們必須建立 Getter 和 Setter 來處理標籤存取資料庫的對應問題。
在 app > models > task.rb

# Getter  
def all_tags    
  tags.map{|t| t.name}.join(',')    
end
  
# Setter  
def all_tags=(names)    
  self.tags = names.split(',').map do |name|      
              Tag.where(name: name.strip).first_or_create    
              end  
end

RailsGuide: first_or_create

建立好 Virtual attribute 再跑一次 rspec --tag tags_feature 會看到另一個錯誤訊息
https://ithelp.ithome.com.tw/upload/images/20201009/20120868EJbGFnzOAI.png
由於 Tag model 和 Task model 沒有建立關聯,所以錯誤訊息指出找不到 tags 的變數或是方法。
首先我們來探討一下 Tag 與 Task 的關係應該是什麼呢?任務可以有很多標籤,而標籤也可以有很多任務,所以這是的多對多的關係。
https://ithelp.ithome.com.tw/upload/images/20201009/20120868X84VfGxdKE.png
新增 tagging model 以及在 tag model 和 task model 設定好多對多的關係

rails generate model tagging tag:belongs_to task:belongs_to

在確認 migration file 沒有問題後,在終端機輸入rails db:migrate

class CreateTaggings < ActiveRecord::Migration[6.0]
  def change
    create_table :taggings do |t|
      t.belongs_to :tag, null: false, foreign_key: true
      t.belongs_to :task, null: false, foreign_key: true
      t.timestamps
    end
  end
end

在 app > models > tag.rb 新增以下設定

class Tag < ApplicationRecord
  has_many :taggings
  has_many :tasks, through: :taggings
end

在 app > models > task.rb 新增以下設定

class Task < ApplicationRecord
  has_many :taggings
  has_many :tags, through: :taggings, dependent: :delete_all
end

在 task controller 修改 task_params 讓 all_tags 能被清洗過後才能存入資料庫
app > controllers > tasks_controller.rb

def task_params
  params.require(:task).permit(:title, 
                               :content, 
                               :task_begin,    
                               :task_end,
                               :priority,
                               :status,
                               :all_tags)
end

再跑一次 rspec --tag tags_feature 得到不同的錯誤訊息
https://ithelp.ithome.com.tw/upload/images/20201009/20120868XuwHAmAIos.png
看得出來任務有建立成功,但是沒有找到預設的測試標籤 “coding”,因為還沒有讓標籤顯示在任務首頁上
這個錯誤訊息不難解決,在 index.html.erb 加入以下程式碼就可以了

<%= raw task.tags.map(&:name).join(‘, ‘) %>

再跑一次 rspec --tag tags_feature 就可以過了!
https://ithelp.ithome.com.tw/upload/images/20201009/20120868jY2ZRqKhrL.png
所呈現的頁面會如同下圖
https://ithelp.ithome.com.tw/upload/images/20201009/20120868oPkcqoBXNi.png
https://ithelp.ithome.com.tw/upload/images/20201009/20120868V3j16CBriK.png

6. 能夠以標籤進行任務搜尋

在 rspec 裡,分別建立(標籤ruby)在第一個任務上及(標籤 coding 及 標籤 ruby)在第二個任務上,藉由點擊標籤連結在做到任務搜尋的功能。

scenario '從任務首頁進行標籤搜尋任務', tags_feature: true do
  login_user
  # 建立第一個任務,標籤註記為 'ruby'
  click_button '新增任務'
  fill_in_task_info
  fill_in 'task_all_tags', with: 'ruby'
  click_button '送出'
  
  # 建立第二個任務,標籤註記為 'coding' 和 'ruby'
  click_button '新增任務'
  fill_in_task_info
  fill_in 'task_all_tags', with: 'coding, ruby' 
  click_button '送出'
  within 'tr:nth-child(2)' do
    click_link 'ruby'
  end
  
  expect(page).to have_content('ruby')
  expect(page).to have_content('ruby coding')
  within 'tr:nth-child(3)' do
    click_link 'coding'
  end
  
  expect(page).to have_content('ruby coding')
end

跑 rspec 後.會得到以下錯誤訊息
https://ithelp.ithome.com.tw/upload/images/20201009/201208688JsBUkYPhL.png

Routes

到不到 ruby 標籤連結是正確的錯誤訊息,因為目前為止標籤的呈現都是僅僅是文字而已。我設定搜尋後呈現的路徑大概會類似 tags/tag_name,因此在 routes.rb 需要新增一條路徑,把標籤的名字當作參數 params[:tag] 的方式傳給 controller 的 index action。
config > routes.rb

Rails.application.routes.draw do
  # TAG
  get 'tags/:tag', to: 'tasks#index', as: :tag
end

“ 其中 :as 的部份就會產生一個 tags_path 和 tags_url 的 Helpers,_path 和 _url的差別在於前者是相對路徑,後者是絕對路徑。一般來說比較常用_path方法,除非像是在 Email 信件中,才必須用 _url 提供包含 Domain 的完整網址。 ” 參考:Ruby on Rails 實戰聖經

Controller

index action 要判別當標籤以 params[:tag] 傳進來時,需要在 Task Model 裡撰寫一個方法來找相同標籤的任務
https://ithelp.ithome.com.tw/upload/images/20201009/20120868KzWQMUu5z8.png

Model

app > models >task.rb

class Task < ApplicationRecord
  def self.tagged_with(name)
    Tag.find_by!(name: name).tasks
  end
end

補充:find_by! 方法基本上與 find_by一樣,唯一不同的是:當找不到物件時會爆出 ActiveRecord::RecordNotFound 的錯誤訊息。

View

接著需要撰寫一個方法來把之前在任務首頁上的標籤文字要改為超連結。
https://ithelp.ithome.com.tw/upload/images/20201009/20120868B1JGv5aR1S.png
也可以複製bootstrap 4內標籤的範例,美化標籤的樣式
https://ithelp.ithome.com.tw/upload/images/20201009/20120868fEzKhhenOP.png
https://ithelp.ithome.com.tw/upload/images/20201009/20120868wiSTsH7BL4.png
再跑一次 rspec --tag tags_feature 就可以過了!
https://ithelp.ithome.com.tw/upload/images/20201009/20120868boOKt9ew3a.png

參考資料:

Active Record Query Interface
使用 Rails + Select 2 實作一個簡單的 tag 功能
Ruby on Rails - 虛擬屬性Virtual Attribute
Add a filtering, multiple tag system with autocomplete to your Rails model in Rails 5


上一篇
Day 23 -- Rails 實作簡易後台系統
下一篇
Day 25 -- Rails 實作開發模式 Action Mailer 寄信功能 with Sidekiq
系列文
Metaprogramming Ruby and Rails33

尚未有邦友留言

立即登入留言