假如遇到一段想重構的代碼,但不幸的是:
- 看不懂在幹嘛
- 沒有被測試給覆蓋
重構自己不理解的程式碼是有巨大的風險的,而且問題來了,如果不知道它是做什麼的,那我要怎麼幫這段代碼寫測試呢?
有一個有趣的東西叫做:表徵測試 ( Characterization Test ),之前讀文章的時候看到的,它讓解決難題變得直接和簡單,等等會用一些簡單的範例做示範!
假設情境
假設這是一段舊專案的程式碼。
下面的兩個方法 services_with_info
& products_with_info
有幾個問題。
一個是他們具有很大的重複性,還有這兩個方法其實沒什麼用,應該可以放到其他地方去,讓 Appointment 專注當一個 Appointment 的 Model,不必把 appointment_services
& appointment_products
序列化的過程放到 Model 裏面。
class Appointment < ActiveRecord::Base
def services_with_info
services.collect { |item|
item.serializable_hash.merge(
"price": number_with_precision(item.price, precision: 2),
"label": item.service.name,
"item_id": item.service.id,
"type": "service"
)
}
end
def products_with_info
products.collect { |item|
item.serializable_hash.merge(
"price": number_with_precision(item.price, precision: 2),
"label": item.product.name,
"item_id": item.product.id,
"type": "product"
)
}
end
end
重構 services_with_info
這兩個方法中,我們先把目光放在 services_with_info
,目標是抽離這個方法,創立一個抽象的概念,然後把主體移過去那邊。
重構時可能可以第一次就想到一個很棒的抽象概念,也可能不行。我們可以先試試看一個抽象概念叫做 Appointment::ServiceCollection
的新類別。
下面是這個類別,目前做的就只是把 services_with_info
的內容複製貼上過來而已。
module Appointment
class ServiceCollection
def initialize(services)
@services = services
end
def to_h
@services.to_h { |item|
item.serializable_hash.merge(
"price": number_with_precision(item.price, precision: 2),
"label": item.service.name,
"item_id": item.service.id,
"type": "service"
)
}
end
end
end
簡單的實體化物件
當開始為這個新類別寫測試時,盡可能投入較少的經歷,先問自己一個問題,這個 ServiceCollection
能夠實體化物件嗎?
RSpec.describe Appointment::ServiceCollection do
describe '#to_h' do
it 'works' do
appointment_service = create(:appointment_service)
collection = Appointment::ServiceCollection.new([appointment_service])
end
end
end
被測試的方法能夠回傳值嗎?
剛剛的測試通過後,下一步將是期待一個看起來有點蠢的東西
RSpec.describe Appointment::ServiceCollection do
describe '#to_h' do
it 'works' do
appointment_service = create(:appointment_service)
collection = Appointment::ServiceCollection.new([appointment_service])
expect(collection.to_h).to eq('asdf')
end
end
end
我當然知道 collection.to_h
不會等於 'asdf'
,但我不知道他等於什麼,所以任何的回傳值都會是錯誤。完全沒必要花心思去猜他會回傳什麼,反正測試結果會告訴我!
F
Failures:
1) Appointment::ServiceCollection#to_h works
Failure/Error: expect(collection.to_h).to eq('asdf')
TypeError:
wrong element type AppointmentService at 0 (expected array)
# ./app/models/appointment/service_collection.rb:7:in `to_h'
# ./app/models/appointment/service_collection.rb:7:in `to_h'
# ./spec/models/appointment/service_collection_spec.rb:8:in `block (3 levels) in <top (required)>'
Finished in 0.50943 seconds (files took 3.44 seconds to load)
1 example, 1 failure
因為是重構一個不知道會回傳什麼的方法,所以錯誤很正常,我們可以嘗試把程式碼註解掉。
module Appointment
class ServiceCollection
def initialize(services)
@services = services
end
def to_h
@services.to_h { |item|
#item.serializable_hash.merge(
#"price": number_with_precision(item.price, precision: 2),
#"label": item.service.name,
#"item_id": item.service.id,
#"type": "service"
#)
}
end
end
end
結果還是錯誤,那就註解掉更多的程式碼看看
module Appointment
class ServiceCollection
def initialize(services)
@services = services
end
def to_h
#@services.to_h { |item|
#item.serializable_hash.merge(
#"price": number_with_precision(item.price, precision: 2),
#"label": item.service.name,
#"item_id": item.service.id,
#"type": "service"
#)
#}
end
end
end
沒有太意外的還是錯誤,但結果改變了!
F
Failures:
1) Appointment::ServiceCollection#to_h works
Failure/Error: expect(collection.to_h).to eq('asdf')
expected: "asdf"
got: nil
(compared using ==)
# ./spec/models/appointment/service_collection_spec.rb:8:in `block (3 levels) in <top (required)>'
Finished in 0.49608 seconds (files took 3.41 seconds to load)
1 example, 1 failure
很明顯,問題出在 #@appointment_services.to_h { |item|
這行。問題大概是出在 to_h 身上,那我試著用 map 代替呢?
module Appointment
class ServiceCollection
def initialize(services)
@services = services
end
def to_h
@services.map { |item|
#item.serializable_hash.merge(
#"price": number_with_precision(item.price, precision: 2),
#"label": item.service.name,
#"item_id": item.service.id,
#"type": "service"
#)
}
end
end
end
成功了,這樣沒有得到像之前 wrong element type
的錯誤,而是不一樣的,更符合我們想看到的東西。
F
Failures:
1) Appointment::ServiceCollection#to_h works
Failure/Error: expect(collection.to_h).to eq('asdf')
expected: "asdf"
got: [nil]
(compared using ==)
Diff:
@@ -1,2 +1,2 @@
-"asdf"
+[nil]
# ./spec/models/appointment/service_collection_spec.rb:8:in `block (3 levels) in <top (required)>'
Finished in 0.48778 seconds (files took 3.5 seconds to load)
1 example, 1 failure
現在讓我把之前的註解都全部拔掉看看會怎麼樣:
module Appointment
class ServiceCollection
def initialize(services)
@services = services
end
def to_h
@services.map { |item|
item.serializable_hash.merge(
"price": number_with_precision(item.price, precision: 2),
"label": item.service.name,
"item_id": item.service.id,
"type": "service"
)
}
end
end
end
出現另一個不同的錯誤,說找不到 number_with_precision
這個方法
F
Failures:
1) Appointment::ServiceCollection#to_h works
Failure/Error: expect(collection.to_h).to eq('asdf')
NoMethodError:
undefined method `number_with_precision' for #<Appointment::ServiceCollection:0x007ff60274e640>
# ./app/models/appointment/service_collection.rb:9:in `block in to_h'
# ./app/models/appointment/service_collection.rb:7:in `map'
# ./app/models/appointment/service_collection.rb:7:in `to_h'
# ./spec/models/appointment/service_collection_spec.rb:8:in `block (3 levels) in <top (required)>'
Finished in 0.52793 seconds (files took 3.32 seconds to load)
1 example, 1 failure
剛好我以透過 Rails API 知道這個方法是放在哪裡,如果要在獨立的類別中使用,需要 include ActionView::Helpers::NumberHelper
進來用。
module Appointment
class ServiceCollection
include ActionView::Helpers::NumberHelper
def initialize(services)
@services = services
end
def to_h
@services.map { |item|
item.serializable_hash.merge(
"price": number_with_precision(item.price, precision: 2),
"label": item.service.name,
"item_id": item.service.id,
"type": "service"
)
}
end
end
end
回傳的 value
處理完這個問題後,終於可以看到回傳的結果是什麼:
F
Failures:
1) Appointment::ServiceCollection#to_h works
Failure/Error: expect(collection.to_h).to eq('asdf')
expected: "asdf"
got: [{"id"=>1, "appointment_id"=>1, "service_id"=>1, "created_at"=>Sun, 24 Feb 2019 16:45:16 EST -05:00, "updated_at"=>Sun, 24 Feb 2019 16:45:16 EST -05:00, "length"=>nil, "stylist_id"=>2, "price"=>"0.00", "label"=>"ve0xttqqfl", "item_id"=>1, "type"=>"service"}]
(compared using ==)
Diff:
@@ -1,2 +1,12 @@
-"asdf"
+[{"id"=>1,
+ "appointment_id"=>1,
+ "service_id"=>1,
+ "created_at"=>Sun, 24 Feb 2019 16:45:16 EST -05:00,
+ "updated_at"=>Sun, 24 Feb 2019 16:45:16 EST -05:00,
+ "length"=>nil,
+ "stylist_id"=>2,
+ "price"=>"0.00",
+ "label"=>"ve0xttqqfl",
+ "item_id"=>1,
+ "type"=>"service"}]
# ./spec/models/appointment/service_collection_spec.rb:8:in `block (3 levels) in <top (required)>'
Finished in 0.72959 seconds (files took 3.24 seconds to load)
1 example, 1 failure
看到回傳的結果真的很有趣,因為這是在重構一個舊專案,看到回傳值的時候就覺得好像要解開秘密了!
使用更直接的 Expectation
我們要用更現實的數值來進行測試。我知道測試會失敗,所以數值是隨意捏造的。
RSpec.describe Appointment::ServiceCollection do
describe '#to_h' do
it 'works' do
appointment_service = create(:appointment_service)
collection = Appointment::ServiceCollection.new([appointment_service])
item = collection.to_h.first
expect(item['price']).to eq('30.00')
expect(item['label']).to eq('Robert Chang')
expect(item['item_id']).to eq(appointment_service.item.id)
expect(item['type']).to eq('service')
end
end
end
這是為了讓我的測試設置更符合我們期待的值!
RSpec.describe Appointment::ServiceCollection do
describe '#to_h' do
it 'works' do
appointment_service = create(
:appointment_service,
price: 30,
service: create(:service, name: 'Robert Chang'))
collection = Appointment::ServiceCollection.new([appointment_service])
item = collection.to_h.first
expect(item['price']).to eq('30.00')
expect(item['label']).to eq('Robert Chang')
expect(item['item_id']).to eq(appointment_service.item.id)
expect(item['type']).to eq('service')
end
end
end
測試通過後,我要做什麼?
現在測試通過了,到目前為止,我們盡可能減少接觸原本程式碼的機會,因為我想最大限度的保留原本功能。現在我有了測試,我的測試可以保有原本的功能,而且我還可以自由地重構這段程式碼!
module Appointment
class ServiceCollection
include ActionView::Helpers::NumberHelper
ITEM_TYPE = 'service'
def initialize(services)
@services = services
end
def to_h
@services.map do |service|
service.serializable_hash.merge(extra_attributes(service))
end
end
private
def extra_attributes(service)
{
'price' => number_with_precision(service.price, precision: 2),
'label' => service.service.name,
'item_id' => service.service.id,
'type' => ITEM_TYPE
}
end
end
end
順便來整理一下測試的代碼:
RSpec.describe Appointment::ServiceCollection do
describe '#to_h' do
let(:appointment_service) do
create(
:appointment_service,
price: 30,
service: create(:service, name: 'Robert Chang')
)
end
let(:item) { Appointment::ServiceCollection.new([appointment_service]).to_h.first }
it 'adds the right attributes' do
expect(item['price']).to eq('30.00')
expect(item['label']).to eq('Robert Chang')
expect(item['item_id']).to eq(appointment_service.service.id)
expect(item['type']).to eq('service')
end
end
end
最後,我們就可以用我們新做的類別來取代原先的程式碼:
# 原本的
def services_with_info
services.collect { |item|
item.serializable_hash.merge(
"price": number_with_precision(item.price, precision: 2),
"label": item.service.name,
"item_id": item.service.id,
"type": "service"
)
}
end
# 現在的
def services_with_info
Appointment::ServiceCollection.new(services).to_h
end
結語
這在重構的路上可以說是杯水車薪,但現在我們已經減少了很多的程式碼,因為其實 Rails 的 MVC 也沒有那麼夠用,並不是真的讓 Model 肥就是好,像這種取得資訊的方法,我們說不定可以把它全部拉出來整理,放到 Presenter 的層級。
但至少我們今天也知道了,如果一段程式碼沒有測試,而且還不知道它會回傳什麼的時候,嘗試看看這個表徵測試的方式,一步一步的找到能夠整理的邏輯,並且同時補上測試!