今天會針對一個services進行單元測試,並詳述過程
首先先介紹基本的測試所安裝的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.rb 的test改成 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 的內容全部複製過去。
首先,我們使用 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
目前要測試的檔案為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 
創建檔案 ➡️ 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! 等同於放在 before 內用實例建立測試資料。
create      #=> 存取
build       #=> 不存取
create_list #=> 存取多個
build_list  #=> 不存取多個
先建立品牌,在建立商品
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
利用一個商品對應多個 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 提到的值必須為實體變數,下面的 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
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
以上針對某個檔案、某個功能進行測試的方式,我們稱為單元測試。上述的測試需要假資料、測試資料內容,以及回傳測試結果。測試的領域很大,除了上述提到的測試方式以外,還有許多種如整合性測試、測瀏覽器行為等。
測試的部分有很多可以分享,今天先介紹到這裡。