require "rails_helper"
RSpec.describe Post, :type => :model do
context "with 2 or more comments" do
it "orders them in reverse chronologically" do
post = Post.create!
comment1 = post.comments.create!(:body => "first comment")
comment2 = post.comments.create!(:body => "second comment")
expect(post.reload.comments).to eq([comment2, comment1])
end
end
end
測試的基本流程:write a simple test, make it pass, then refactor
商業邏輯是複雜的而且在一開始要怎麼實做都還沒完全定案。應該要著重在流程,並將功能切成更小的步驟。
sliming:用簡單的方式先通過測試,例如:什麼都不做只回傳一個數值,等之後的階段在來改。這麼做的好處在於在一開始可以不用太著重在實做的細節。
一個model spec的範例:
spec/models/project_spec.rbrequire "rails_helper"
RSpec.describe Project do
describe "initialize" do
it "should have a default name" do
expect(Project.new).to eq("New Project")
end
end
describe ".total_size" do
let(:project) { create(:project) }
it "should return the total task size" do
task_size = rand(10) + 1
create(:task, project: project, size: task_size)
expect(project.total_size).to eq(task_size)
end
it "should return the total task size if multiple tasks are given" do
task_size1 = rand(10) + 1
task_size2 = rand(10) + 1
create(:task, project: project, size: task_size1)
create(:task, project: project, size: task_size2)
expect(project.total_size).to eq(task_size1 + task_size2)
end
it "should return 0 if no task in the project" do
expect(project.total_size).to eq(0)
end
it "should return nil if there is a nil size task in the project" do
create(:task, project: project, size: nil)
expect(project.total_size).to be_nil
end
end
end
許多的設計會在refactor的時候發生,通常就是cleanup,也就是把寫的很糟或是架構不好的程式碼重新安排。請不要跳過refactor的步驟,它是一個用來思考與設計怎麼寫程式的方法,而且保留糟糕的程式碼只會讓之後refactor越來越痛苦。
將長的method或code切成多個小的method,好處是因為程式區塊放在小method中,而method命名可以為程式帶來可讀性。
通常有幾個地方是可以考慮切成小method:
duplication有三種:
就是Magic number
validates :size, numericality: {less_than_or_equal_to: 5}
def
possible_sizes (1 .. 5)
end
validates :size, numericality: {less_than_or_equal_to: MAX_POINT_COUNT}
def possible_sizes
(1 .. MAX_POINT_COUNT)
end
VALID_POINT_RANGE = 1 .. 5
validates :size, inclusion: {in: VALID_POINT_RANGE}
也可以選擇將數值放在一個method中,這樣做的好處是有時候instance的method會比class的variable來的方便使用,另一方面也保留未來需要變成變數的可能性。
def max_point_count
5
end
「複雜的boolean運算式」或是「散佈在很多地方簡單但相同的計算邏輯」。
class User
def maximum_posts
if status == :trusted then 10 else 5 end
end
def urls_in_replies
if status == :trusted then 3 else 0 end
end
end
class User
def maximum_posts
if trusted? then 10 else 5 end
end
def urls_in_replies
if trusted? then 3 else 0 end
end
def trusted?
status == :trusted
end
end
要注意的是,不是所有看起來像的程式碼都要切出來放在一起。
Duplication of structure表示有missing abstraction,這意味著需要把程式碼搬到另一個class。
一個明顯的例子是「重複出現的一整組method」或是「一組method有相同的prefix或是suffix,例如:logger_init、logger_print、logger_read」。
class User < ActiveRecord::Base
# ...
def full_name
"#{first_name} #{last_name}"
end
def sort_name
"#{first_name}, #{last_name}"
end
# ...
end
class Name
attr_reader :first_name, :last_name
def initialize(first_name, last_name)
@first_name, @last_name = first_name, last_name
end
def full_name
"#{first_name} #{last_name}"
end
def sort_name
"#{first_name}, #{last_name}"
end
end
class User < ActiveRecord::Base
delegate :full_name, :sort_name, to: :name
def name
Name.new(first_name, last_name)
end
end
切出Name class的好處:
問題:用concern可不可以?
module Nameable
extend ActiveSupport::Concern
def full_name
"#{first_name} #{last_name}"
end
def sort_name
"#{first_name}, #{last_name}"
end
end
class User < ActiveRecord::Base
include Nameable
end
在一般的情況下,在refactor程式時不應該去改測試,因為測試應該要針對「程式的功能行為」去寫而不是針對實作去寫。有一個例外就是如果程式碼被搬到另一個class,那就要改測試了。
另一個可以抽象化的地方就是在流程上重複出現的判斷。
def total_time
if status == :completed
calculate_completed_time
else
calculate_incompleted_time
end
end
def total_size
if status == :completed
calculate_completed_size
else
calculate_incompleted_size
end
end
def calculator
if complete?
CompleteTaskCalculator.new(self)
else
IncompleteTaskCalculator.new(self)
end
end
def total_time
calculator.calculate_time
end
def total_size
calculator.calculate_size
end
「一個測試案例放一個判斷」v.s. 「一個測試案例放多個判斷」
it "marks a task complete" do
task = tasks(:incomplete)
task.mark_complete
expect(task).to be_complete
expect(task).to be_blocked
expect(task.end_date).to eq(Date.today.to_s(:db)) expect(task.most_recent_log.end_state).to eq("completed")
end
describe "task completion" do
let(:task) {tasks(:incomplete)}
before(:example) { task.mark_complete }
specify { expect(task).to be_complete }
specify { expect(task).to be_blocked }
specify { expect(task.end_date).to eq(Date.today.to_s(:db)) }
specify { expect(task.most_recent_log.end_state).to eq("completed") }
end
一個測試案例放一個判斷:
一個測試案例放多個判斷:
作者的建議:
class Task < ActiveRecord::Base
def self.completed
where(status: :completed)
end
def self.large
where("size > 3")
end
def self.most_recent
order("completed_at DESC")
end
def self.recent_done_and_large
completed.large.most_recent.limit(5)
end
end
問題:用scope可不可以?
class Task < ActiveRecord::Base
scope :completed, -> { where(status: :completed) }
scope :large, -> { where("size > 3") }
scope :most_recent, -> { order("completed_at DESC") }
scope :recent_done_and_large, -> { completed.large.most_recent.limit(5) }
end
it "finds completed tasks" do
complete = Task.create(completed_at: 1.day.ago, title: "Completed")
incomplete = Task.create(completed_at: nil, title: "Not Completed")
expect(Task.complete.map(&:title)).to eq(["Completed"])
end
多個model可能有類似的功能或行為,我們可以用module或是concern的方式讓model共用程式碼,但測試這種共用的行為是一種挑戰。這時候就是shared example出場的時候啦:
spec/support/size_group.rbRSpec.shared_examples "sizeable" do
let(:instance) { described_class.new }
it "knows a one-point story is small" do
allow(instance).to receive(:size).and_return(1)
expect(instance).to be_small
end
it "knows a five-point story is epic" do
allow(instance).to receive(:size).and_return(5)
expect(instance).to be_epic
end
end
spec/models/task_spec.rb RSpec.describe Task do
it_should_behave_like "sizeable"
# ...
end
app/models/task.rbclass Task
# ...
def epic?
size >= 5
end
def small?
size <= 1
end
# ...
end
RSpec還有其它方式可以使用shared example:`include_example`、`it_behaves_like`或是metadata等,請參考Ch15。
可以用let當傳送門,將變數傳給shared example。
require "set"
RSpec.shared_examples "a collection object" do
describe "<<" do
it "adds objects to the end of the collection" do
collection << 1
collection << 2
expect(collection.to_a).to match_array([1, 2])
end
end
end
RSpec.describe Array do
it_behaves_like "a collection object" do
let(:collection) { Array.new }
end
end
RSpec.describe Set do
it_behaves_like "a collection object" do
let(:collection) { Set.new }
end
end
更多有關shared example的運用請參考: https://www.relishapp.com/rspec/rspec-core/docs/example-groups/shared-examples
在RSpec中可以客製化自己的matcher。範例:將`expect(project.size).to eq(5)`變成`expect(project).to be_of_size(5)`。
spec/support/size_matcher.rbRSpec::Matchers.define :be_of_size do |expected|
match do |actual|
actual.total_size == expected
end
end
客製化matcher的使用方式與一般的matcher一樣
spec/models/project_spec.rb# ...
it "can calculate total size" do
expect(project).to be_of_size(10)
expect(project).not_to be_of_size(5)
end
# ...
更多matcher的客製功能
spec/support/size_matcher.rbRSpec::Matchers.define :be_of_size do |expected|
match do |actual|
actual.total_size == expected
end
description do
"have tasks totaling #{expected} points"
end
failure_message do |actual|
"expected project #{actual.name} to have size #{expected}"
end
failure_message_when_negated do |actual|
"expected project #{actual.name} not to have size #{expected}"
end
end
chain:讓你的matcher有option可以使用
spec/support/size_matcher.rbRSpec::Matchers.define :be_of_size do |expected|
match do |actual|
size_to_check = @incomplete ? actual.remaining_size : actual.total_size
size_to_check == expected
end
# ...
chain :for_incomplete_tasks_only do
@incomplete = true
end
end
spec/models/project_spec.rb# ...
it "can calculate total size" do
expect(project).to be_of_size(10)
expect(project).to be_of_size(5).for_incomplete_tasks_only
end
# ...
更多有關custom matcher的資料請參考: http://www.relishapp.com/rspec/rspec-expectations/v/3-4/docs/custom-matchers