TDD 是一種以寫測試為驅動的開發模式,也就是先把規格及測試寫好、再開發需要的程式撰寫。對剛學習程式語言的人來說,或許有點難想像要如何測試尚未存在的程式。在寫此文章的當下,其實我也未開發標籤功能在專案上,希望藉由體驗 test-driven development 來完成此功能並記錄遇到的問題。
本範例使用以下 gem
在此次的實作,標籤將會是以字串的型態來描述任務內容,進而讓其他使用者可以進行搜尋。
目前可以安裝 acts-as-taggable-on 來幫助我們實現標籤的功能,但維護更新的次數似乎是一年ㄧ次,所以嘗試從無到有來建立標籤功能吧!
先設定好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
會得到以下錯誤訊息
從錯誤訊息得知 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 就會過了
建立另一個測試
it '標籤名字不能是空白', tags: true do
tag = FactoryBot.build(:tag, name: '')
expect {
expect(tag).to be_valid
}.to raise_exception(/Name 不能為空白/)
end
會得到以下錯誤訊息
需要在 Tag model 裡設定驗證方法: app > models > tag.rb
class Tag < ApplicationRecord
validates :name, presence: true
end
再跑一次 rspec --tag tags 就會過了
接下來也是相同的流程:寫測試碼
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
判讀錯誤訊息:嘗試寫更多程式碼,解決錯誤訊息
class Tag < ApplicationRecord
validates :name, presence: true
validates :name, uniqueness: true
end
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
class Tag < ApplicationRecord
validates :name, presence: true, length: {minimum: 3, maximum: 25}
validates :name, uniqueness: true
end
建立標籤的功能測試 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
得到錯誤訊息如下:
因為還沒有做出標籤欄位,所以找不到其實是正確的錯誤訊息。
接著來到新增任務的頁面,加上標籤欄位。
再跑一次 rspec --tag tags_feature 會看到另一個錯誤訊息
從錯誤訊息得知:由於在 Task 裡沒有 all_tags 這個方法或是屬性,所以需要定義 all_tags 才行
當使用者輸入:‘ 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
建立好 Virtual attribute 再跑一次 rspec --tag tags_feature 會看到另一個錯誤訊息
由於 Tag model 和 Task model 沒有建立關聯,所以錯誤訊息指出找不到 tags 的變數或是方法。
首先我們來探討一下 Tag 與 Task 的關係應該是什麼呢?任務可以有很多標籤,而標籤也可以有很多任務,所以這是的多對多的關係。
新增 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 得到不同的錯誤訊息
看得出來任務有建立成功,但是沒有找到預設的測試標籤 “coding”,因為還沒有讓標籤顯示在任務首頁上
這個錯誤訊息不難解決,在 index.html.erb 加入以下程式碼就可以了
<%= raw task.tags.map(&:name).join(‘, ‘) %>
再跑一次 rspec --tag tags_feature 就可以過了!
所呈現的頁面會如同下圖
在 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 後.會得到以下錯誤訊息
到不到 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 實戰聖經
index action 要判別當標籤以 params[:tag] 傳進來時,需要在 Task 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 的錯誤訊息。
接著需要撰寫一個方法來把之前在任務首頁上的標籤文字要改為超連結。
也可以複製bootstrap 4內標籤的範例,美化標籤的樣式
再跑一次 rspec --tag tags_feature 就可以過了!
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