iT邦幫忙

2021 iThome 鐵人賽

0
Modern Web

初階 Rails 工程師的養成系列 第 33

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

結論

以上針對某個檔案、某個功能進行測試的方式,我們稱為單元測試。上述的測試需要假資料、測試資料內容,以及回傳測試結果。測試的領域很大,除了上述提到的測試方式以外,還有許多種如整合性測試、測瀏覽器行為等。

測試的部分有很多可以分享,今天先介紹到這裡。

參考文章


上一篇
Day32. 使用Decorator Pattern 實作攤提
下一篇
Day34. 結尾語 & 明年的鐵人賽
系列文
初階 Rails 工程師的養成34

尚未有邦友留言

立即登入留言