about 8 years ago

我開公眾號了。歡迎掃碼關注!(或微信搜尋 xdite,有貓頭鷹那個 )

這個公眾號會專注更新幾個領域的內容:

  • 認知心理學
  • 學習
  • 自我成長
  • 編程學習理論

歡迎舊雨新知,掃碼追蹤。並分享給朋友訂閱,大家一起升級!

 
about 8 years ago

過去一年,莫名其妙成了全職的程式教練。大概是天注定,唉。最常遇到的新手問題就是,請問如何入門 XXX 技術。當然,對我來說,寫 Rails 都快十年了。這這個領域東西還真難不倒我,抄了傢伙就幹已經是我這幾年的風格。

不過我一向蠻有實驗精神的。為了要能夠回答這個問題,我特地去重學了新的程式語言( Ruby Motion ),來近距離觀察重新拆解我十年以來的學習反射性動作到底是什麼,來寫一份給新手的參考指南。

Step 1 : 建造時光機

我在學習新技術時,會用到兩個東西。第一個是 Git,第二個是 Redmine。

Git

git 是新手的時光機。我認為如果一般人學習任何程式語言,甚至寫任何筆記,都應該上個 git 版本控制。起碼看你上一次寫了什麼東西。其實 git 一開始也不用學太多指令,練習以下幾個就夠:

  • git init (初始一個 Repo)
  • git add [檔案名稱] (將某某檔案加入版本控制)
  • git commit -m "儲存訊息" (將這次要加入版本控制的檔案,寫入歷史紀錄)
  • git checkout -b "新分支名稱" ( 如果要實作一個蠻巨大的實驗性功能,我通常會開一個 branch)
  • git checkout "分支名稱" ( 切換不同分支 )
  • git push (推送變更到遠端做一次備份,通常是 Github)
  • git pull 拉下遠端的變更

主要是將做過的東西,「每一個 interaction 都做一次備份」,讓自己知道當初為什麼做了這些變動。

Redmine

Redmine 是一套專案管理系統。不過在這裡我是利用它的「樹狀 ticket 系統」去規劃我的練習。

我運用的方法如下:

  • 大致切出第一層,我覺得我想要練習的主題
  • 然後中間要是有遇到難題,大概 30 分鐘解不開,我就會「放棄」,然後開另外一張票,隔天心情比較好再回來學
  • 中間我要是覺得「有個功能實在太棒了」,我應該可以來做。忍住,開出另外一張票,下週再來做。
  • 每一張 Ticket 我拿來記幾個東西:
    • 我這次找到了哪些 link(幾乎是一 google 到一個疑似可以用的資源,就 copy 一份)
    • 這次這個功能寫了哪些 code。(是的,我不止 git 記了一份,redmine 上還複製了一份)
    • 這次我做了哪些改動
    • 我之前的「錯誤做法」,為什麼錯了。bug 的原因是?
    • 為了解 bug 所找到的 stackoverflow 資源

我的 redmine ticket 記這些東西,每張非常的詳盡。(不是指筆記做得好,而是指這當中的過程,我把每一步幾乎都錄下來)

這樣做的好處是:

  • 我不會分心,專注在我當初想練的主題上
  • 我不會被鬼打牆的 bug 打擊到自信心全無
  • 我不會被自己一時的成就產生的「傲慢感」牽走
  • 把每一步包括 bug 都錄下來。bug 的產生以及解法,其實是「重要的知識」。因為 git 「往往只會保留正確的結果」,而不會保留你 debug 的結果。然後下次自己還是會掉進同樣的坑裡面。

Step 2:挑選合適的主題,熟悉基本工具

在無數篇自我的學習部落格我都曾經提到過,在自學過程中保持一定的「成就感」是很重要的。最近,我把我多年來練習題目做了一個總結,找到了一個模式。

超級新手:

  • 一個「單一功能」,CRUD 的練習。
  • 先做 R 再做 C 再做 D 再做 U。

完整做完一輪,搞懂怎麼樣讓這個專案會動的基本因素與語法。

(注意,這個系統內只有「自己」這個用戶)

新手:

以下按照順序

  • 除了 CRUD 外的三個功能
  • 這個系統內只能有 1 個角色,通稱「使用者」。
  • 登入系統
  • 套版
  • 加上一個外掛功能
  • 部署

(這個最實際的例子就是 TODO + 使用者註冊 + 套版 + deploy)。這一系列做出來,起碼可以讓一個人至少可以熟練這個系統的最基本工具,而不太容易絆倒。

中手:

  • 第 2 個角色
  • 開發者認為的 10 個重要核心功能
  • 至少加入 3 個外掛
  • 權限
  • 介接一個第三方 (學會讀文件)

之所以會建議這樣做的原因。是我發現每當建議新手自己找題目練習後,他們自己想的題目反而變成了災難。

說災難的原因是因為他們挑選的題目帶給了他們濃厚的挫折感。而這當中最核心的原因在於失控的 scope。

而 scope 的最主要的控制變因在於「這個系統裡面有幾個?操作角色」。很多人會忽略掉一個重要的事實,開發系統裡面多「引入一個使用者角色」,這個系統的複雜度就會成「等比級數上升」。

舉個例子來說好了:

  • 一個匿名論壇,大家可以上去發表文章。
  • 一個實名論壇,大家必須要登入才能發表文章。
  • 一個實名論壇,大家必須要登入才能發表文章,「並且針對它人的文章留言」。
  • 一個有管理員的實名論壇,管理者可以任意刪除大家的文章以及留言。發文者也可以砍掉自己文章底下的留言。

這四個例子的功能數量是「等比級數的上升」。而一旦新手挑的題目,系統內角色多於 1 人,基本上就注定「打挑戰級難度被王打死」。

而我一向的學習方式,都是會儘量讓難度可以控制在自己「開開心心學習」的程度上(每次逐步加重,而不是一開始就被滅好玩)。我知道唯有己有成就感地學習,學一門技術才不容易中途而廢。

Step 3:將 Redmine 的筆記整理成技術文章

在學完這整套技術後,我會在適當時機,把過去的筆記寫成一篇技術文件。視情節發布給同事或給部落格讀者。

  • 比如說這個專案如果是跟同事協作的,我會在拉 pull request 時,附上快速的一篇 getting started 。
  • 如果是這個技術難度比較高,用一篇 getting started 的方式很難讓對方快速掌握,我會至少做一份 newbie guide ,讓想學的人,透過 guide 帶練至少一次快速衝到新手等級。

因為 redmine 上當初的筆記非常得詳細,在看這些筆記與 git 的時候,我當時的記憶就會被喚醒。甚至上面有現成的 code example 可以直接拿來改編。

而把這些筆記整理成技術文件與指南非常有幫助,因為「寫作」這件事可以幫助我從此把這門新技術「想通」,而且烙印到大腦裡面。

總結

以上的步驟,最後可以總結成三個重點:

  • 建造時光機,與錄下自己學習的過程
  • 做有成就感的題目,透過控制「角色」去控制複雜度,在頭兩個循環就掌握到基本工具,而且做出有成就感的東西。
  • 重新複習,寫成文章,內化成自己的架構。

分享給大家。

 
over 8 years ago

Mocking

在這個例子裡面,我們用了 mock 手法,確認 show 這個 action,真的有去呼叫 Suggestion.find

  • allow(Suggestion).to receive(:find).and_return(suggestion)
  • expect(Suggestion).to receive(:find)
  describe "GET show" do

    it "assigns @course and render show" do
      suggestion = double(:to_param => "123" )
      allow(Suggestion).to receive(:find).and_return(suggestion)
      expect(Suggestion).to receive(:find)

      get :show, :id => suggestion.to_param

      expect(response).to render_template :show
      expect(assigns[:suggestion]).to eq(suggestion)

    end

  end
class SuggestionController
  def show
    @suggestion = Suggestion.find(params[:id])
  end
end

劣勢:順序「反直覺」

但是其實這樣的寫法,對於我們之前提到的 Four-Phase Test:

  • Setup (準備測試資料)
  • Exercie (實際執行測試)
  • Verification (驗證測試結果)
  • Teardown (拆解測試)

有點相違背了。

我們先做了 Verification (expect)再做 Exercise ( get :show) 。

劣勢:容易失敗

而且在拆解階段,我們會將這段測試重寫成這樣。將 expect 寫在 before 階段,但這樣寫的缺點是,expect 如果不如預期,後面測試會因為 except 全死。

  describe "GET show" do

    before do 
      @suggestion = double(:to_param => "123" )
      allow(Suggestion).to receive(:find).and_return(@suggestion)
      expect(Suggestion).to receive(:find)
      get :show, :id => @suggestion.to_param
    end

    it { expect(response).to render_template :show }
    it { expect(assigns[:suggestion]).to eq(@suggestion)}
  end

Spying

與其使用 Mocking 手法,我們可以改用 Spy 手法,將測試改成這樣。(RSpec 使用 have_received 去驗證)

  describe "GET show" do

    before do 
      @suggestion = double(:to_param => "123" )
      allow(Suggestion).to receive(:find).with(@suggestion.to_param).and_return(@suggestion)
      get :show, :id => @suggestion.to_param
    end

    it { expect(response).to render_template :show }
    it "should find suggestion and assign suggestion" do 
       expect(Suggestion).to have_received(:find).with(@suggestion.to_param)
       expect(assigns[:suggestion]).to eq(@suggestion)
    end

  end

Four Phase 順序不但正確,而且是在 Exercise 後,才做 Verification。

 
over 8 years ago

double 可以讓我們輕易地閃掉準備「協作者」的痛苦。但是有時候,我們還是可能被「協作者」本身的內部邏輯改變整到。比如說,這裡是一個「建議」Suggestion 表單:

require 'rails_helper'

RSpec.describe SuggestionsController, type: :controller do

  describe "POST create" do 
    context "when suggestion is invalid" do 
      it "render new" do 
        post :create, :suggestion => { :title => "", :description => "" }

        expect(response).to render_template :new
      end
    end
  end
end

class Suggestion < ActiveRecord::Base
   validates :title, presence: true
end

class SuggestionsController 
  def create
    @suggestion = Suggestion.new(suggestion_params)
    if @suggestion.save
      redirect_to root_path
    else
      render :new
    end
  end

  protected

  def suggestion_params
    params.require(:suggestion).permit(:title, :description)
  end

end

如果物件非法的話,要 render new 這個 action。正常來說,這樣的 controller 測試,應該會通過。

但是有時候 controller 程式碼沒有動,但 model 程式碼內部動了,比如說在這個例子,可能多加了新的欄位,或者是新增了其他的驗證方式,導致這個 controller 測試要因為 suggestion 內部本身的邏輯要修改。

這樣真的很討厭。

重寫測試

其實這裡我們根本不應該塞「例子」進去,這樣 suggestion 內部邏輯一旦改變,我們的 controller test 就要改個沒完。我們真正應該要做的是:

  • 準備一個「一定儲存失敗的替身」
  • 然後在 controller 讓 @suggestion.save 鐵定失敗
  • 因為我們要驗證的是 「render :new」

所以我們可以把測試改寫成這樣

require 'rails_helper'

RSpec.describe SuggestionsController, type: :controller do

  describe "POST create" do 
    context "when suggestion is invalid" do 
      it "render new" do 
        invalid_suggestion = double(:save => false) 
        allow(Suggestion).to receive(:new).and_return(invalid_suggestion)

        post :create, :suggestion => { :xxx => "yyy" }

        expect(response).to render_template :new
      end
    end
  end
end

這樣 suggestion 內部驗證再怎麼樣變,都不會影響到這個 controller test。

 
over 8 years ago

Four-Phase Test 是一種常見的測試 Pattern。幾乎也是一般人在寫測試的手法,依序為:

  • Setup (準備測試資料)
  • Exercie (實際執行測試)
  • Verification (驗證測試結果)
  • Teardown (拆解測試)

但是通常我們在一般寫測試或者是 TDD 時,常常會遇到一些狀況,導致 Setup 階段時,資料「難以被準備」。通常是有以下幾種狀況:

對象物件當中的「協作者」還沒被誕生

比如說都還沒寫到那個 class 或者是 method

對象物件當中的「協作者」是「系統外部物件」

如: API 回傳內容

要準備的資料、相依性過於複雜

要生超多物件才能測試

執行這個測試,呼叫到「吃效能很兇」的 method

導致測試運行緩慢

使用替身

但我們的首要要務,其實是要測我們內部的程式的邏輯,不是要測「外部邏輯」的狀況。所以我們可以使用 mock objects,在 RSpec 裡面叫做 double(替身)。

在 Setup 階段使用「替身」的資料,直接進行測試。

舉例來說,我這裡有一個學生計算器

class StudentsCalculator

  def initialize(course)
    @course = course
  end

  def students_count
    @course.students_count
  end
end

我還沒想好 course.students_count 要怎樣設計(這在 TDD 中很常見),但是我知道在 StudentsCalculator 中的邏輯,吃的就是將來 course 提供的 students_count,而且「我現在就想要驗證這件事」。所以我可以假造一個 course 的替身,讓這段測試可以通過。


require 'rails_helper'

RSpec.describe StudentsCalculator do
  describe "#students_count" do 
    it "returns students count" do 
      course = double(:students_count => 5 )
      student_calculator = StudentsCalculator.new(course)
      expect(student_calculator.students_count).to eq(5)
    end
  end
end

 
over 8 years ago

以這個程式來說,我們要把「課程上架」,課程上架以後,系統會送一封 onboarding 信給開課教師。

class Course
  belongs_to :user

  def published?
    published_at.present?
  end

  def publish!
    user.send_welcome_email!(self)
    update_column(:published_at, Time.now)
  end

end
class User < ActiveRecord::Base

  has_many :courses    

  def send_welcome_email!(course)
    UserMailer.send_course_welcome(course)
  end
end

但是下面這個測試,我們在這裡測試的狀況,我們根本不在乎「信是否有沒有被送給開課教師」,我們在乎的是:呼叫 publish! 後,是否課程真的已經會被上架。

所以我們就會用 allow(course.user).to receive(:send_welcome_email!)send_welcome_email! 這件事 stub 掉,讓它 return nil,這樣就不會呼叫 UserMailer 了。

何況,我們可能也還沒時間開發 UserMailer 內的東西。

  describe "#publish!" do 

    let(:user) { FactoryGirl.create(:user) }
    let(:course) { FactoryGirl.create(:course, :user => user ) }

    it "will be publsihed" do 
      allow(course.user).to receive(:send_welcome_email!)
      course.publish!
      expect(course).to be_published
    end
  end
  • 備註: 在 RSpec 2.x 版,你可以直接這樣寫 course.stub(:send_welcome_email!),但這寫法造成一些敘述語法問題,所以 RSpec 3 改成 allow + receive
  • 備註: 在 RSpec 2.x 的 stubmock 語法造成一些很大的語法與觀念問題。RSpec 3 改的清楚不少...
 
over 8 years ago

Stub

一般來說,驗證「回傳值」或驗證「狀態改變時」,經常會使用 Stub 手法。
因為在這兩種狀況中,即便這個 method 有可能去呼叫「外部物件」,坦白來說,我們「不在乎」。


比如說這段程式碼

class Post
  def visit!
    update_visit_cache_to_memory!
    self.increment_counter(:visit_count, 1)
  end

  def current_visit_count
    update_visit_cache_to_memory!
    return visit_count
  end
end

因為我們只在乎「當下這個物件」。所以寫測試時,我們會 stub 掉 update_visit_cache_to_memory!「呼叫 外部 method」這件事。以取得我們要的結果。

Mock

「模擬」與一個「協作者」的互動,設立一個「會收到指定訊息」的期望,去驗證互動是否真的有發生。

 
over 8 years ago

要開始講解 Stub 與 Mock 這兩個概念之前。我想要先來介紹單元測試的種類,這樣才講得清楚後續要繼續寫下去的主題。

一般來說,單元測試分成三種狀況。

1. 驗證回傳值是否符合期待

查詢:

  • 回傳某些東西
  • 不改變狀態

2. 驗證物件狀態的改變是否符合期待

命令:

  • 改變內部狀態

3. 驗證是否有去執行與外部的呼動

呼叫:

  • 是否有去執行與外部的互動

 
over 8 years ago

很多新手在經歷一兩年的開發經驗後,開始覺得寫測試很重要。想要入門學習測試。但是看到這些名詞:

  • stub
  • mock
  • double
  • allow
  • receive
  • expect

就覺得很頭痛。特別是 RSpec 2.1 與 RSpec 3 一些語法升級,又造成了更大的混淆。所以我希望寫一系列的文章,解釋清楚這些名詞。想辦法把測試這個主題講清楚。

這一個系列,我會嘗試以以下的順序講解以下主題:

 
over 8 years ago

有同學最近在問 RSpec 的問題,因為會回答很多次,乾脆寫「一個經典例子」,來介紹 RSpec 的常見 syntax,來介紹一串重要的手法。

例子:Course#publish?

publish? 的判斷方式是 published_at 這個欄位存在與否。

class Course < ActiveRecord::Base
    
  def published?
    published_at.present?
  end
end

觀念 1: 測試要寫 正 / 負場景

在這個例子裡面,因為是測 boolean 值,比較好的方式,是要寫正反例子。

RSpec.describe Course, type: :model do
   describe "#publish?" do 
    it "return true when published_at present?" do 
      course = FactoryGirl.create(:course, published_at: Time.now)
      expect(course.published?).to be_truthy
    end

    it "return false when published_at blank?" do 
      course = FactoryGirl.create(:course)
      expect(course.published?).to be_falsey
    end
  end
  
end

觀念 2 :當 when 與重複語句出現,應抽出變成 context

  • when published_at present?
  • when published_at blank?

這兩句是場景,所以接下來可以改寫成

RSpec.describe Course, type: :model do
  describe "#publish?" do 

    context "when published_at present?" do 
      it "return true" do 
        course = FactoryGirl.create(:course, published_at: Time.now)
        expect(course.published?).to be_truthy
      end
    end

    context "when published_at blank?" do 
      it "return false" do 
        course = FactoryGirl.create(:course)
        expect(course.published?).to be_falsey
      end
    end

  end
end

觀念 3 : 用 let 抽出 course,it 內只測 expect

將物件抽到 let。

RSpec.describe Course, type: :model do
describe "#publish?" do 

    context "when published_at present?" do 
      let(:course) { FactoryGirl.create(:course, published_at: Time.now) }
      it "return true" do 
        expect(course.published?).to be_truthy
      end
    end

    context "when published_at blank?" do 
      let(:course) { FactoryGirl.create(:course) }
      it "return false" do 
        expect(course.published?).to be_falsey
      end
    end

  end
end

觀念 4 : Minimal Valid Object 與「測試真正的變因」

但是在步驟 3 中,這樣的測試手法,是生成了「兩個不同的物件」去測。但我們寫這個測試的目的是要「測試一個物件,在published_at值不同」的情況下的回傳狀況。

真正的「宿主」是 Coruse 本身。所以我們改用 subject。傳 nil 與 現在的時間,去測試真正要測的行為。

RSpec.describe Course, type: :model do
  describe "#publish?" do 

    subject(:course) { described_class.new :published_at => published_at } 
    context "when published_at present?" do 
      let(:published_at) { Time.now }
      it "return true" do 
        expect(course.published?).to be_truthy
      end
    end
    context "when published_at blank?" do 
      let(:published_at) { nil }
      it "return false" do 
        expect(course.published?).to be_falsey
      end
    end

  end
  
end

觀念 5 : xxxx? 可以使用 be_xxxxx 去測試

  describe "#publish?" do 

    subject(:course) { described_class.new :published_at => published_at } 
    context "when published_at present?" do 
      let(:published_at) { Time.now }
      it "return true" do 
        expect(course).to be_published
      end
    end
    context "when published_at blank?" do 
      let(:published_at) { nil }
      it "return false" do 
        expect(course).not_to be_published
      end
    end

  end

觀念 6 :is_expected

測 return true/ false 看起來像是超多餘的。而且我們是對 subject 測,所以又可以改成 is_expected

RSpec.describe Course, type: :model do
  describe "#publish?" do 

    subject(:course) { described_class.new :published_at => published_at } 

    context "when published_at present?" do 
      let(:published_at) { Time.now }
      it { is_expected.to be_published }
    end
    context "when published_at blank?" do 
      let(:published_at) { nil }
      it { is_expected.not_to be_published }
    end
  end
  
end

最後看起來是不是變得很好維護呢?