今天會針對一個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
以上針對某個檔案、某個功能進行測試的方式,我們稱為單元測試。上述的測試需要假資料、測試資料內容,以及回傳測試結果。測試的領域很大,除了上述提到的測試方式以外,還有許多種如整合性測試、測瀏覽器行為等。
測試的部分有很多可以分享,今天先介紹到這裡。