测试 One Assertion Per Test & DAMP Not DRY

xdite · 2013年05月20日 · 最后由 bluecoda 回复于 2013年05月29日 · 3894 次阅读
本帖已被设为精华帖!

長久以來大家大家都抱怨我不發測試文。現在發啦....

http://blog.xdite.net/posts/2013/05/19/one-assertion-per-test/

TL; DR : 寫測試兩個鐵律:一次只測一件事情、不要自作聰明幫測試碼 DRY。

最近跟著朋友 Kevin Wang,現 Tealeaf (Ruby on Rails 線上教學公司) 教師,前 Hashrocket 工程師,學習寫正統的測試。

找老師直接學下來,果然比自己抄一抄外面的 code ,寫出湊合測試,果然神速許多。

以往寫測試時最讓人迷惑的就是,如何才能測到恰到好處,一段程式碼幾十行,中間有的動作根本不知道要怎麼測,或者是寫了一大堆測試,還是會在某個執行點壞掉,結果測試碼寫到跟程式碼打架。或者是 case 很多,測了 一 在 二 爆炸,測了二,在三爆炸…寫測試寫到火大。

最近才開始領悟到要同時把「程式碼」和「測試代碼」寫好,其實真的很簡單。只是以前沒有機會「好好學」。

其實總歸來說:寫測試只要抓住兩個原則:

  • 「One assertion per test」
  • 「DAMP not DRY」

就可以解決 80% 的問題。

只是我以前從來就不知道這兩條原則不是寫好玩的(指選擇性遵守),而是寫測試的「鐵律」。

只要你嚴格守住第一條線「One assertion per test」,你的程式碼就會變得非常乾淨。守住第二條線「DAMP not DRY」,你的測試碼就會變得非常好維護。

這兩條用得很熟,你寫測試就再也不會迷惑,到底應該怎樣寫才算「測得對」。

One assertion per test

One assertion per test 講的其實是:一個測試必須只驗證一件事。這是什麼意思呢?

這是指就算是你的程式碼只有下面幾行的話

def show
  @post = Post.find(params[:id])
  @comments = @post.comments 
end

你也必須這樣拆開測

describle "GET show" do 
  let(:post) { Fabricate(:post)} 
  let(:comment) { Fabricate(:comment, :post => post) } 

  it "assgin @post variable" do 
    get :show, :id => post
    assigns(:post).should == post
  end

  it "assigns @comments to @post.comments"  do 
    get :show, :id => post
    assigns(:comments).should == [comment]
  end

  it "render show's view" do 
    get :show, :id => post
    response.should render_tempate :show
  end

end

而不是擠在一起。如同下面這個測試。

describle "GET show" do 
  let(:post) { Fabricate(:post)} 
  let(:comment) { Fabricate(:comment, :post => post) } 

  it "assgin @post variable and assigns @comments to @post.comments and render show's view " do 
    get :show, :id => post
    assigns(:post).should == post
    assigns(:comments).should == [comment]
    response.should render_tempate :show
  end

end

為什麼守著這個原則這麼重要呢?因為當你在寫類似以下程式碼時

def create

  @post = Post.new(params[:post])

  if @post.save
    urls = URI.extract(@post.content)
    urls = urls.uniq 
    urls.each do |url|
      link = @post.links.build(:url => url)
      link.save
    end
    redirect_to posts_path
  else
    render :new
  end

end

就會下意識的改寫成

def create

  @post = Post.new(params[:post])

  if @post.save
    @post.extract_links!
    redirect_to posts_path
  else
    render :new
  end

end

針對 @post.extrat_links! 再寫一個 unit test,然後在 controller test 中 mock 掉。

一旦不這樣拆,你就會發現「非常難遵守」「One assertion per test」這條定律,更不用說也很難測。當一旦習慣寫 code 拆 method 時,你就會發現程式碼其實會一天一天更乾淨....

而且你會猛然發現,以前那些「很難寫測試的code」,都是那些不喜歡拆 method 拆 class 的 code …

「DAMP not DRY」

DAMP not DRY

  • DAMP 是指 Descriptive And Meaningful Phrases
  • DRY 是指 Don't Repeat Yourself

這是什麼意思呢?

我發現大部分的測試很難改是因為,程式設計師寫 code 寫的最後的一個壞習慣 DRY。

等等?DRY 不是一個好原則嗎?

DRY 在寫程式時是一個很重要的好原則沒錯,它的作用是讓程式儘量好讀好(給人)維護。所以程式師設計在經過良好的寫程式訓練之後,下意識習慣性的在寫任何 code 時都給他 DRY 一下。

很可能就會寫出這樣的測試碼:

describe Post do 
  before do 
    alice = Fabricate(:user) 
    bob = Fabricate(:user) 
    post =  Fabricate(:post, :user => alice )
  end

  it "#xxx" do 
    ...
  end

  it "#yyy" do 
    ...
  end

  it "#zzz" do 
    ...
  end

end

這,就,慘,了。

為什麼呢?在剛開始第一次寫這些 test case 的時候,你可能覺得這沒什麼問題,測試都會通過…不過當一個月之後,你的老闆叫你改一些功能的時候,比如說改 #xxx 好了,你可能要換掉 alice 這個 sample。這就慘了,一改下去 #xxx 是綠燈了,#yyy#zzz 卻紅燈了。

這時候你就會很幹....要去修一下 #yyy#zzz 裡的變數,但是改著改著你卻發現要讓 #yyy#zzz綠燈,其實有時候可能要連原先 #yyy#zzz 的測試碼也要重寫…

然後你就會相當抓狂:改兩行,然後卻要修 60 行,越寫覺得寫程式碼和寫測試碼的邊界到底在哪裡?好像只有多做工....

DAMP 的原則是要你,在寫測試時 CASE 寫的越清楚越好,甚至「多行重複」也沒有關係。也就是以上的程式碼我們應該改成:

describe Post do 

  describe "#xxx" do 
    let(:alice) { Fabricate(:user) }
    let(:bob)  { Fabricate(:user) }
    let(:post)  { Fabricate(:post, :user => alice ) } 
    ...
  end

  describe "#yyy" do 
    let(:alice) { Fabricate(:user) }
    let(:bob)  { Fabricate(:user) }
    let(:post)   { Fabricate(:post, :user => alice ) } 
    ...
  end

  describe "#zzz" do 
    let(:alice) { Fabricate(:user) }
    let(:bob)  { Fabricate(:user) }
    let(:post)  { Fabricate(:post, :user => alice ) } 
    ...
  end

end

它的原則是:開發者要儘量讓寫的每一個測試「環境獨立」。不要被其他測試環境變數的改變,也被影響到。

而且用 before,容易隱藏一些該被測試的 host,不容易 debug。這也是另外一個需要小心的地方...


只要這兩條線你守得非常嚴,程式碼和測試碼就會越來越有水準。

至於防守警鐘在哪裡?

  • 只要你在 it "xxxx …. and yyyy" 裡面提到 and 這個字,基本上就表示你在一個 test 裡測兩件事。你應該開個 context 拆開繼續做成兩個 test,或者再拆一個 it 出來再寫一個 test。

  • 只要你想要在 describe 裡面寫 before,可能就要小心你又在不小心 DRY 過頭破壞測試的獨立環境了。

共收到 21 条回复

必看啊。。

对于 DAMP not DRY 这条我不完全赞同,或是说,有些补充。

通常我还是会把相同的东西放在 beforeafter 里。但是用 describecontext 把这些相关的集合在一起。这样在读测试代码时很清楚的就知道这些都是相关的 assertion。

One assertion per test 这条其实也要看具体情况,比如测试 order。

我这几天刚写了份测试正好涵盖了这两条的反例—— https://github.com/fredwu/datamappify/blob/master/spec/repository/callbacks_spec.rb

好棒,最近正在写测试,学习了

@fredwu

  1. 用 describe 和 context 圈地這也是一招, thanks :)
  2. 我覺得 order 那個例子要真算的話,應該也可以只算一件事吧,測「order」 XD

其实我个人超讨厌describe,比较喜欢写这样的:

describe User do

  valid_addresses = %w[user@foo.com The_USER@foo.org firstlast@foo.jp]
  invalid_addresses = ['user@foo,com', 'user_at_foo.org', 'first.last@foo.', '']

  let(:user) { create :user }

  subject { user }

  context 'DB Implementation' do
    let(:user) { create :user }

    it { should have_field(:user_name).of_type(String) }
    it { should have_field(:email).of_type(String)
         .with_default_value_of('') }
    it { should have_field(:encrypted_password)
         .of_type(String).with_default_value_of('') }

    it { should have_many(:notifications) }
    it { should have_many(:photos) }
    it { should have_one(:profile) }

    it { should validate_presence_of(:user_name) }
    it { should validate_presence_of(:email) }
    it { should validate_uniqueness_of(:user_name) }
    it { should validate_uniqueness_of(:email) }
    it { should validate_confirmation_of(:password)}

    valid_addresses.each do |valid_address|
      it { should validate_format_of(:email).to_allow(valid_address) }
    end

    invalid_addresses.each do |invalid_address|
      it { should validate_format_of(:email).not_to_allow(invalid_address) }
    end
  end

  its(:admin?) { should be_false}
  context 'Admin' do
    let(:user) { create :admin }
    its(:admin?) { should be_true }
  end

  describe 'email' do
    context 'when set without token' do
      before { user.email = 'foo@bar.com'; user.save! }
      its(:email) { should_not == 'foo@bar.com' }
    end
  end

end

补充一点点.. 有的矩阵测试 DRY 了会写成这样:

it "..." do
  array.each do |e|
    assert...
  end
end

把循环放外面就更容易看出是哪一步出的错:

array.each do |e|
  it "... #{e}" do
    assert...
  end
end

其实你可以不用介绍 @knwang 的。。 这里大家都认识他。。 哈哈

#2楼 @fredwuletbefore 的 tradeoff 是会把一个完整独立的 spec 割离到几个不同的地方, 尤其是层次 (describe / context ) 很多的时候,在看一个 spec 的时候要上下来回找。 用比较平的 it style 重复会多些,但测试代码和应用代码的功能完全不一样,每个测试更多地是 tell a story, 所以故事的完整性更重要些。

#6楼 @aptx4869 describecontext 是同义词,你反对的应该是过多的层次。

single assertion 的好处是小步前进,每个测试都推进应用代码一小步,而不是一个大的任务需要在脑里解决 - bug 的出现主要是由于复杂应用的开发占脑过多,而这个的解药就是小步前进,分割征服。

#11楼 @knwang 嗯……主要是语义上,用describe总是喜欢写成长长的整句,用context就几个单词搞定……

然后,javascript的测试怎么搞…… Jasmine写起来感觉非常蛋疼,但是不写测试的话……rake stats一看吓一跳,项目中差不多有一半是coffeescript……

#14楼 @aptx4869 如果 js 只是点缀 / 操作 DOM 可以直接用 selenium 或者 headless browser 比如 webkit (capybara-webkit), phantomjs (poltergeist) 带着跑。如果大量的业务逻辑在 js 里面就 jasmine 把

学习了!

#9楼 @knwang 是不是可以这么理解,如果一个describe/context 层次的code可以在一屏内显示,则可以使使用before,如果整个层次一屏不足以显示,就应该分开写在每个it里比较好?

其實很少 test 可以在一屏內顯示的?XDDDDD

#17楼 @loveky 很容易装在脑袋里可以考虑用 省几行代码

文章应该写得很不错,可是看繁体字实在是累

要不英语算了,看着还舒服

single assertion 这个我觉得真的很棒,事实上类的设计也应该这样,方法的设计也应该如此

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册