Day 28 如何撰写表徵测试

该文章同步发布於:我的部落格

什麽是表徵测试以及解决的问题是什麽?

假如我遇到一段想重构的代码,但不幸的是:

  1. 我甚至看不懂他在干嘛
  2. 他也没有被测试给覆盖

重构自己不理解的程序码是有巨大的风险的,而且问题来了,如果我不知道他是做什麽的,那我要怎麽帮这段代码写测试呢?

有一个有趣的东西叫做:表徵测试,我是之前读文章的时候看到的,他让解决难题变得直接和简单,等等会用一些简单的范例做示范!

假设情境

假设这是一段旧专案的程序码。

下面的两个方法 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

这两个方法中,我们先把目光放在 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 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 肥就是好,像这种取得资讯的方法,我们说不定可以把它全部拉出来整理!

但至少我们今天也知道了,如果一段程序码他没有测试,而且你还不知道他会回传什麽的时候,尝试看看这个表徵测试的方法,一步一步的找到能够整理的逻辑,并且同时补上测试!


<<:  Day26:优化修正

>>:  Day 29 | 状态管理-从官方范例来看如何使用BLoC (2)

Python 练习

今天也要来练习APCS的题目啦,今天的题目是105年10月29日的实作题第一题,三角形辨别,那我们开...

[第三只羊] 迷雾森林建筑工事 II vite好吃吗

天亮了 昨晚是平安夜 关於迷雾森林故事 化装舞会 阿虎简单布置了一个入口指引 招集迷雾森林里的动物们...

DAY30-JAVA的Set、List、Map介面

Set介面 在Set中的元素不能重复出现,由於JAVA中的Set是一个介面,它是Collection...

Day 8 - 目前(传统)的机器学习三步骤(3)-训练

第三步 Training : 训练并验证,找出最佳结果 挑选[学习演算法] 什麽是演算法(Algor...

Day 16 - 研习计画起源与菜鸟业师面试学生篇

渐渐的掌握专案开发的技术以及与厂商洽谈的诀窍後,接着迎接了一个新的挑战是先前没有遇过的,那就是协助单...