Day33. 使用RSpec写测试

今天会针对一个services进行单元测试,并详述过程

config

首先先介绍基本的测试所安装的gem

# 测试

group :development, :testt do
  gem 'rspec-rails', '~> 5.0.0'
  gem 'factory_bot'
  gem 'pry'
  gem 'faker'
end

接着产rspec档案

rails generate rspec:install

set_default_options has been deprecated, use set_options
Running via Spring preloader in process 79680
      create  .rspec
      create  spec
      create  spec/spec_helper.rb
      create  spec/rails_helper.rb

由於在我的专案中,test已经被占用,所以用了另外一个名字testt

将产出的 spec/rails_helper.rbtest改成 testt

# This file is copied to spec/ when you run 'rails generate rspec:install'
require 'spec_helper'
# https://github.com/rspec/rspec-rails/issues/70
ENV['RAILS_ENV'] = 'testt'
require File.expand_path('../config/environment', __dir__)
# Prevent database truncation if the environment is production
abort("The Rails environment is running in production mode!") if Rails.env.production?

require 'rspec/rails'
# 引入所有 support 资料夹里面的档案
Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f }

对应的 database.yml

default: &default
  adapter: mysql2
  host: 127.0.0.1
  encoding: utf8
  pool: 5
  timeout: 5000
  username: root
  password: password
  variables:
    sql_mode: TRADITIONAL

development:
  <<: *default
  database: tungrp_dev
  
testt:
  <<: *default
  database: tungrp_test  

设定完资料库设定之後,接着跑下列指令,将测试资料的资料库环境建立起来

rails db:migrate RAILS_ENV=testt

创建档案 ➡️ config/environments/testt.rb,并且将原本在 config/environments/development.rb的内容全部复制过去。

Basic Usage

首先,我们使用 LongLiveRuby 文章进行测试

  • 创建档案 ➡️ touch app/services/person.rb

  • 创建档案 ➡️ touch spec/services/person_spec.rb

  • 跑下列指令

    bundle exec rspec ./spec/services/person_spec.rb
    

    如果只想执行某一行的话可以指定行数

    bundle exec rspec ./spec/services/person_spec.rb:4
    

Implementation

目前要测试的档案为app/services/order/calculate_discount.rb,以下为 app/services/order/calculate_discount.rb 的内容

# 计算刷退信用卡金额、购物金返还
#
# 范例: Order::CalculateDiscount.call(<order instance>, [{ variant_id: 1, quantity: 2 }])
class Order::CalculateDiscount
  include OrderSyncDecorator

  def self.call(order, return_items = nil, return_order = nil, order_price_total = nil, order_rebate_total = nil)
    new(order, return_items, return_order, order_price_total, order_rebate_total).call
  end

  def initialize(order, return_items, return_order = nil, order_price_total = nil, order_rebate_total = nil)
    # ......
  end

  def call
    # ...

    OpenStruct.new({
      return_amount: (return_cash_amount + return_rebate_amount),
      return_cash_amount: return_cash_amount,
      return_rebate_amount: return_rebate_amount
    })
  end

  attr_reader :variants, :order_items,
              :customer, :order, :return_items, :items_total,
              :order_price_total, :order_rebate_total, :order_total

  private

  # ...
end

我们使用OpenStruct 使得回传结果可以使用dot notation。在 Day5 我们提过 Openstruct的概念,读者可以回去复习

接着我们创建档案来测试上面的services ➡️mkdir spec/services/order && touch spec/services/order/calculate_discount.rb

Factory Bot

  • 创建档案 ➡️ mkdir spec/support && touch spec/support/factory_bot.rb

    RSpec.configure do |config|
      # 加这行使得 FactoryBot 正常运行
      FactoryBot.reload
      config.include FactoryBot::Syntax::Methods
    end
    
  • rails_helper.rb 写入require,将factory_bot.rb档案载入。

    require './support/factory_bot.rb'
    
  • 我们要在以下档案新增factory

    spec/factories/*.rb
    

    因此我们先创建商品假资料 ➡️ mkdir spec/factories && touch spec/factories/product.rb

    再来编辑spec/services/order/calculate_discount.rb

    require './app/services/person'
    require 'rails_helper'
    
    RSpec.describe Order::CalculateDiscount do
      let!(:customer) { create(:customer) }
    
      describe '#initialize' do
        context 'test factory_bot' do
          before { customer }
    
          it 'correct customer phone' do
            expect(customer.phone).to eq('0983168969')
          end
    
          it 'incorrect customer phone' do
            expect(customer.phone).not_to eq('0983168968')
          end
        end
      end
    end
    

    接着执行指令

    > bundle exec rspec ./spec/services/order/calculate_discount.rb
    
    Finished in 0.55935 seconds (files took 5.69 seconds to load)
    2 examples, 0 failures
    

let & let!

  • let 只在被呼叫时才会被触发
  • let! 等同於放在 before 内

FactoryBot & Infrastructure

用实例建立测试资料。

create vs build

create      #=> 存取
build       #=> 不存取
create_list #=> 存取多个
build_list  #=> 不存取多个

创建 product

先建立品牌,在建立商品

FactoryBot.define do
  factory :brand do
  end

  trait :kenzo do
    title { "KENZO" }
  end

  trait :agete do
    title { "agete" }
  end
end

若要商品跳过验证,可以这样写

FactoryBot.define do
  factory :product do
    title_zh      { "汉汉特效药水" }
    brand         { create(:brand, :kenzo) }
    collection_id     { 1 }
    sub_collection_id { 1 }
    import_history_id { 1 }
  end
end
require './app/services/person'
require 'rails_helper'

RSpec.describe Order::CalculateDiscount do
  let!(:kenzo) { create(:brand, :kenzo) }
  let!(:customer) { create(:customer) }
  # 创建商品
  let!(:product) do
    p = build(:product)
    p.save(validate: false)
    p
  end

  describe '#initialize' do
    context 'test factory_bot' do
      it 'correct product' do
        expect(product.title_zh).to eq('汉汉特效药水')
      end
    end
  end
end

这样写可以

FactoryBot.define do
  factory :product do
    title_zh      { "汉汉特效药水" }
    brand         { create(:brand, :kenzo) }
    collection_id     { 1 }
    sub_collection_id { 1 }
    import_history_id { 1 }
  end

  trait :product_without_validations do
    to_create { |instance| instance.save(validate: false) }
  end
end
require './app/services/person'
require 'rails_helper'

RSpec.describe Order::CalculateDiscount do
  # 创建商品
  let!(:product) { create(:product, :product_without_validations) }

  describe '#initialize' do
    context 'test factory_bot' do
      it 'correct product' do
        expect(product.title_zh).to eq('汉汉特效药水')
      end
    end
  end
end

甚至这样写也可以

FactoryBot.define do
  factory :product do
    title_zh      { "汉汉特效药水" }
    brand         { create(:brand, :kenzo) }
    collection_id     { 1 }
    sub_collection_id { 1 }
    import_history_id { 1 }

    to_create {|instance| instance.save(validate: false) }
  end
end
require './app/services/person'
require 'rails_helper'

RSpec.describe Order::CalculateDiscount do
  # 创建商品
  let!(:product) { create(:product) }

  describe '#initialize' do
    context 'test factory_bot' do
      it 'correct product' do
        expect(product.title_zh).to eq('汉汉特效药水')
      end
    end
  end
end

创建多笔 variants

利用一个商品对应多个 variant 的概念,创建多个 variant

FactoryBot.define do
  factory :variant do
    sequence :id do |id|
      id
    end
    price { 1600 }
    product { create(:product) }

    to_create {|instance| instance.save(validate: false) }
  end
end
require './app/services/person'
require 'rails_helper'

RSpec.describe Order::CalculateDiscount do
  let!(:customer) { create(:customer) }
  # 创建Variant
  let!(:variants) { create_list(:variant, 25) }

  describe '#initialize' do
    context 'test factory_bot' do
      it 'correct customer phone' do
        expect(customer.phone).to eq('0983168969')
      end

      it 'incorrect customer phone' do
        expect(customer.phone).not_to eq('0983168968')
      end

      it 'correct variant count' do
        expect(variants.count).to eq(25)
      end
    end
  end
end
> bundle exec rspec ./spec/services/order/calculate_discount.rb

Finished in 2.64 seconds (files took 4.01 seconds to load)
3 examples, 0 failures

创建空订单

FactoryBot.define do
  factory :order do
    customer
  end
end
require './app/services/person'
require 'rails_helper'

RSpec.describe Order::CalculateDiscount do
  # 创建订单
  let!(:order) { create(:order) }
end

before hook & instance variable

我们在 before 提到的值必须为实体变数,下面的 it 区块才看得懂。

require './app/services/person'
require 'rails_helper'

RSpec.describe Order::CalculateDiscount do
  # 创建店面
  let!(:store) { create(:store) }
  # 创建Variant
  let!(:variants) { create_list(:variant, 25) }
  # 创建订单
  let!(:order) { create(:order) }
  # 创建Variant
  let!(:variants) { create_list(:variant, 25) }
  # 创建满额赠
  let(:target_price_discount_first) { create(:target_price_discount, :scheme_1) }
  let(:target_price_discount_second) { create(:target_price_discount, :scheme_2) }
  # 创建订单项目
  before do
    @store_with_suffix = [store.title_zh, '_desu'].join
  end


  describe '#initialize' do
    context 'test factory_bot' do
      it 'correct variant count' do
        expect(variants.count).to eq(25)
      end

      it 'correct store name with suffix' do
        expect(@store_with_suffix).to eq('敦南店_desu')
      end
    end
  end
end

新增一笔子订单

require './app/services/person'
require 'rails_helper'

RSpec.describe Order::CalculateDiscount do
  # 创建店面
  let!(:store) { create(:store) }
  # 创建订单
  let!(:order) { create(:order) }
  # 创建25笔 Variant
  let!(:variants) { create_list(:variant, 25) }
  # 创建满额赠
  let(:target_price_discount_first) { create(:target_price_discount, :scheme_1) }
  let(:target_price_discount_second) { create(:target_price_discount, :scheme_2) }
  # 创建订单项目
  before do
    @store_with_suffix = [store.title_zh, '_desu'].join
    # 新增子订单
    order.sub_orders.create(brand: store.brand, store: store)
  end


  describe '#initialize' do
    context 'test factory_bot' do
      it 'correct variant count' do
        expect(variants.count).to eq(25)
      end

      it 'correct store name with suffix' do
        expect(@store_with_suffix).to eq('敦南店_desu')
      end

      it 'correct sub_order count' do
        expect(order.sub_orders.count).to eq(1)
      end
    end
  end
end

警告讯息

DEPRECATION WARNING: Uniqueness validator will no longer enforce case sensitive comparison in Rails 6.1. To continue case sensitive comparison on the :phone attribute in Customer model, pass `case_sensitive: true` option explicitly to the uniqueness validator.

因此在 customer.rb 的某一条验证改为

class Customer < ApplicationRecord
  validates :phone ,  uniqueness: { case_sensitive: false }, presence: true
end

计算退款测试(Final)

require './app/services/person'
require 'rails_helper'

RSpec.describe Order::CalculateDiscount do
  # 创建店面
  let!(:store) { create(:store) }
  # 创建订单
  let!(:order) { create(:order) }
  # 创建25笔 Variant
  let!(:variants) { create_list(:variant, 25) }
  # 创建订单项目
  before do
    Order.class_eval do
      define_method :used_point, -> { used_rebate + used_birth_gift }
    end

    # 新增满额赠
    create(:target_price_discount, :scheme_1)
    create(:target_price_discount, :scheme_2)
    # 新增子订单
    sub_order = order.sub_orders.create(brand: store.brand, store: store)
    # 新增子订单项目

    Rails.logger.debug '========= 创建订单项目 ========='
    @items_number = rand(25)

    order_items = (1..@items_number).map do |num|
                    sub_order.order_items.create! do |item|
                      item.variant = variants[num]
                      item.sold_price = variants[num].price
                      item.price = variants[num].price
                      item.order = order
                      item.quantity = 1
                    end
                  end

    Rails.logger.debug '========= 算点数 ========='

    price_hash = ::Order.count_price(order.order_items, order.used_rebate, order.used_birth_gift)

    order.price = price_hash[:price]

    if price_hash[:target_price_discount_title].present?
      order.target_price_discount_title_zh = price_hash[:target_price_discount_title][:zh]
      order.target_price_discount_title_en = price_hash[:target_price_discount_title][:en]
    end

    order.target_price_discount_value = price_hash[:target_price_discount_value]
    order.original_price = price_hash[:subtotal]
    order.save!

    Rails.logger.debug '========= 退货 ========='
    # 不用写 ...
    Rails.logger.debug '========= 计算退货退款 ========='

    return_items = order_items.map { |item| { variant_id: item.variant_id, quantity: item.quantity } }

    @calculated_result = Order::CalculateDiscount.call(order, return_items)
  end

  describe '#call' do
    context 'test factory_bot' do
      it 'correct variant count' do
        expect(variants.count).to eq(25)
      end

      it 'correct sub_order count' do
        expect(order.sub_orders.count).to eq(1)
      end

      it 'correct order_items count' do
        expect(order.order_items.count).to eq(@items_number)
      end

      # 商品价格=1600,满额赠一定不为0
      it 'target price discount should not be zero' do
        expect(order.target_price_discount_value.zero?).to be_falsey
      end

      it '实际退货金额=订单金额' do
        expect(order.price).to eq(@calculated_result.return_cash_amount)
      end

      it '实际退还购物金=使用点数' do
        expect(order.used_point).to eq(@calculated_result.return_rebate_amount)
      end
    end
  end
end

当我们跑rails console,只要跑 0 failures 就等於测试成功。

> bundle exec rspec ./spec/services/order/calculate_discount.rb

......

Finished in 6.72 seconds (files took 3.92 seconds to load)
6 examples, 0 failures

结论

以上针对某个档案、某个功能进行测试的方式,我们称为单元测试。上述的测试需要假资料、测试资料内容,以及回传测试结果。测试的领域很大,除了上述提到的测试方式以外,还有许多种如整合性测试、测浏览器行为等。

测试的部分有很多可以分享,今天先介绍到这里。

参考文章


<<:  30天学会C语言: Day 17-math.h

>>:  Progressive Web App 闲置中: Idle Detection API 空闲检测入门实做 (20)

没想太多就用了 MongoDB 的结果 (中)

初始设定 一开始我们就在 Azure 租一个 node , 程序後端(以下称API)、Nginx、M...

【Day ?(31)】测试环境无法登入

现在测试环境无法登入罗~ 该如何是好呢? 无法登入 先前可以使用测试环境与测试帐号登入,如Day3的...

Day1 前言

嵌入式系统其实在我们的生活中无所不在,嵌入式系统是嵌入式计算机系统的简称,也就是说嵌入式系统与普通计...

关於AsyncTask

AsyncTask非同步任务,或称异步任务,是一个相当常用的类别,是专门用来处理背景任务与UI的类别...

Day 3 ( 入门 ) 摇骰子

摇骰子 教学原文参考:摇骰子 这篇文章会介绍如何使用「当晃动发生」搭配「显示数字」与「随机取数」积木...