该文章同步发布於:我的部落格
假如我遇到一段想重构的代码,但不幸的是:
重构自己不理解的程序码是有巨大的风险的,而且问题来了,如果我不知道他是做什麽的,那我要怎麽帮这段代码写测试呢?
有一个有趣的东西叫做:表徵测试,我是之前读文章的时候看到的,他让解决难题变得直接和简单,等等会用一些简单的范例做示范!
假设这是一段旧专案的程序码。
下面的两个方法 services_with_info
& products_with_info
有几个问题。一个是他们具有很大的重复性,还有这两个方法其实并没有真正的帮助,这两个方法应该可以放到其他地方去,让 Appointment 专注当一个 Appointment 的 Model,不必把 appointment_services
& appointment_products
序列a化的过程放进来。
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
,我的目标是抽离这个方法,创立一个抽象的概念,然後把主体移过去那边。
重构时可能可以第一次就想到一个很棒的抽象概念,也可能不不行。我们可以先试试看一个抽象概念叫做 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
处理完这个问题後,我们终於可以看到回传的结果是什麽了
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
看到回传的结果真的很有趣,因为这是在重构一个旧专案,所以看到回传值的时候就觉得好像要解开秘密了~
我们要用更现实的数值来进行测试。我知道测试会失败,因为数值是随意捏造的。
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 Change'))
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 肥就是好,像这种取得资讯的方法,我们说不定可以把它全部拉出来整理!
但至少我们今天也知道了,如果一段程序码他没有测试,而且你还不知道他会回传什麽的时候,尝试看看这个表徵测试的方法,一步一步的找到能够整理的逻辑,并且同时补上测试!
>>: Day 29 | 状态管理-从官方范例来看如何使用BLoC (2)
今天也要来练习APCS的题目啦,今天的题目是105年10月29日的实作题第一题,三角形辨别,那我们开...
天亮了 昨晚是平安夜 关於迷雾森林故事 化装舞会 阿虎简单布置了一个入口指引 招集迷雾森林里的动物们...
Set介面 在Set中的元素不能重复出现,由於JAVA中的Set是一个介面,它是Collection...
第三步 Training : 训练并验证,找出最佳结果 挑选[学习演算法] 什麽是演算法(Algor...
渐渐的掌握专案开发的技术以及与厂商洽谈的诀窍後,接着迎接了一个新的挑战是先前没有遇过的,那就是协助单...