rspec 中 let 和 subject 的区别是什么?有哪为大牛帮助解释解释。谢谢
好吧,我先说有关提问的两点:
最后我还是回答好了,我不是大牛,我适合回答这样的问题。
let 以及 subject 是类似于 Minitest 中的 before 的简写形式
subject { ... } 定义了一个叫做 subject 的方法,它返回了代码块内定义的那个对象. let(:a) { ... } 定义了一个叫做 a 的方法,他也是返回了代码块内定义的那个对象。
let
和 subject
很像,同出一源,都是通过委托来定义一个消息的接收方,这句话的意思可以理解成:为某个方法调用(此调用的结果是一个对象,这一点毋庸置疑,因为一切都是对象)绑定一个“名字”(一般用 symbol
),于是在后面的测试样例中,我们可以用这个名字来指代它。最直接的好处就是可以让代码更精炼,提高可读性,减少重复。
说它们同出一源,可以通过源码获知:
def let(name, &block)
::RSpec::Core::MemoizedHelpers.module_for(self).define_method(name, &block)
define_method(name) do
__memoized.fetch(name) { |k| __memoized[k] = super() }
end
end
简单地说,我们传递了 name
和 &block
给 let
,于是返回给我们一个 defined method;再看 subject
:
def subject(name=nil, &block)
let(:subject, &block)
alias_method name, :subject if name
end
see? subject
本身就是 let
,只不过如果我们给了 name
的话,最后会把这个 name
作为最终结果的 alias_method
为什么要这么做呢?再来看一处源码便知:
def should(matcher=nil, message=nil)
RSpec::Expectations::PositiveExpectationHandler.handle_matcher(subject, matcher, message)
end
原来,subject
是用来配合 should
进行隐式调用的,在这里何为隐式调用?举个例子:
# 不用 subject
describe "Checking Account initialization" do
it "should have balance with $50" do
account = CheckingAccount.new(Money.new(50, :USD))
account.should have_a_balance_of(Money.new(50, :USD)) # should_have_a_balance 是自定义 matcher
end
end
# 使用 subject
describe CheckingAccount, "with $50" do
# 直接用的 Class Name,若此时没有显式定义 subject,那么默认的 subject 就是 CheckingAccount.new,可通过在代码中输出 subject 获知
subject { CheckingAccount.new(Money.new(50, :USD)) }
it { should have_a_balance_of(Money.new(50, :USD)) }
end
如果你要使用主动式的 expectation,那么可以给 subject
起名字(非隐式调用):
describe "Checking Account initialization" do
subject (:account) { CheckingAccount.new(Money.new(50, :USD)) }
it "has $50 balance" do
expect(account).to have_a_balance_of(Money.new(50, :USD))
end
it "has a balance attribute which equals the starting balance" do
expect(account.balance).to eq(Money.new(50, :USD))
end
end
上面的例子里,Money.new(50, :USD)
明显重复了很多次,但它又不是我们要测试的主题(subject 就是主题的意思),此时就是应该使用 let
的时候了:
# 重构上面的例子
describe "Checking Account initialization" do
let(:starting_balance) { Money.new(50, :USD) }
subject(:account) { CheckingAccount.new(starting_balance) }
it "has $50 balance" do
expect(account).to have_a_balance_of(starting_balance)
end
it "has a balance attribute which equals the starting balance" do
expect(account.balance).to eq(starting_balance)
end
end
怎么样?感觉清晰多了吧?
#3 楼 @nightire 神回复,收藏!两个方法被你挖掘到如此深度,且读罢让人豁然开朗!赞一个!
#1 楼 @zhulinpinyu 非常能理解你第一次见到 let
和 subject
的时候会困惑,我第一次也弄不明白,如果不是自己写亲自动手多写一些这样的测试代码,仅仅通过文档阅读和看其他人的代码,是很难掌握这两个方法的神髓的,所以建议你照着 rspec 文档 1 文档 2 上的例子亲自敲一遍。这里要再一次赞一下 @nightire 深挖源码的做法,真的是一个好习惯,能帮你深入理解 DSL 语法糖这一表层抽象之下的细节是什么,上帝隐藏在细节之中。
遇到问题其实不用 AT 大牛(我必须承认你 AT 的几个人中我不是大牛)。如果大家都用 AT 大牛的方式,那么没有被你 AT 的人怎么会乐意帮助你,回答你的问题呢?更不会出现三楼这种神回复了。
探索精神赞一个~~
不过,是不是有点把简单问题复杂化了?是不是站在测试的角度来分析这两个东西,更清晰? 我觉得似乎我的答案还是好懂点?? 呵呵。
有几个问题:
# 使用 subject
describe CheckingAccount, "with $50" do
# 直接用的 Class Name,若此时没有显式定义 subject,那么默认的 subject 就是 CheckingAccount,可通过在代码中输出 subject 获知
subject { CheckingAccount.new(Money.new(50, :USD)) }
it { should have_a_balance_of(Money.new(50, :USD)) }
end
你注释是什么意思?如果没有定义 subject { ... }, 默认就是 CheckingAccount, 这默认怎么知道如何去初始化对象,难道会解析你那个 "with $50" ??
# 重构上面的例子
describe "Checking Account initialization" do
let(:starting_balance) { Money.new(50, :USD) }
subject(:account) { CheckingAccount.new(Money.new(50, :USD)) }
it "has $50 balance" do
expect(account).to have_a_balance_of(starting_balance)
end
it "has a balance attribute which equals the starting balance" do
expect(account.balance).to eq(starting_balance)
end
end
我就最后这个看得最清晰了,不过,我没看出来 这个里面 let 和 subject 有啥区别? 这种情况为什么不用两个 let ?
第一个问题其实 @nightire 解释的非常准确,且给出的例子中的注释也没有问题,不过为了帮助你理解,我给你贴一个更加直观的代码。
describe Post do
# 这里没有定义 subject,所以默认就是 Post, 而且不需要去初始化对象,因为测试的是 Post 本身而不是它的实例。
# 出处: https://github.com/thoughtbot/shoulda
it { should belong_to(:user) }
it { should validate_presence_of(:title) }
end
第二个问题,不能仅仅按照学究的方式从语言和功能定义角度去理解,而是要从 DSL 帮助增强代码的表达能力角度来理解。这里的 subject
和 let
都是通过尽量用英文的自然语法来加强代码的语义表达能力,subject
就是英文语法中的主语成分, it
在这里所指代的正是主语,当 it
和 block 中的 subject
同时出现的时候,目的是为了增强代码的语义可读性。let
是定义 helper 方法的另外一种表达方式,并且有 lazy-evaluated
的优点:
# 重构上面的例子
describe "Checking Account initialization" do
# let 可以改写成
# def sharting_balance; Money.new(50, :USD); end
let(:starting_balance) { Money.new(50, :USD) }
subject { CheckingAccount.new(Money.new(50, :USD)) }
it "has $50 balance" do
expect(subject).to have_a_balance_of(starting_balance)
end
it "has a balance attribute which equals the starting balance" do
expect(subject.balance).to eq(starting_balance)
end
end
#12 楼 @zw963 没错,我的解释其实就是把简单的问题复杂化,因为我的目的不是为了简单地回答“let 和 subject 不一样”。
在一开始我就说了,let 和 subject 系出同源,从语言的角度来看它们都是 delegation,还真没什么不一样的。但是从语法,或者说从语义的角度上来讲就有区别了,这一点正如 @lgn21st 所补充的那样。
要理解这一点,我们可以问自己一个问题,为什么 RSpec 会受欢迎?众多原因当中,DSL 是一个非常重要的因素。DSL 本身只是对既有代码的一层封装,目的(之一)就是为了增加“可读性”。当我们写一段测试的时候,我们头脑里应该很清楚的知道谁是“主角”,谁是“配角”,尽管两者都是“角色”(所以你把 let 和 subject 再抽象一层就都是 delegator),但是分出主次就会让你的测试用例变得非常清楚,非常明确。
很多开发者(特别是英文不好,或是对语义无所谓的开发者)写测试仅仅是为了给自己加一层保护伞,只要目的达到了,写成什么样都无所谓;殊不知,测试本身也是对应用程序的一种注解,撰写良好的测试代码可以让其他人仅看测试就可以复刻你的 implementation,要达到这个层级,那就要求你对 DSL 有良好的理解和使用习惯。
对于最后一个例子让你产生的疑惑,我表示歉意,最后一个例子的确没能深刻表现出 let 和 subject 的一个微妙的区别,我再换用一个例子加以说明:
describe Array do
context 'when first created' do # 使用 context 的好处:语法上清除作用域;语义上体现语境变化
# 主角刚诞生,两手空空...
it { should have(0).items } # => pass
its(:size) { should == 0 } # => pass
end
context 'update subject' do
subject { Array.new(5) } # => 主角升级了,HP +5... >_<
it { should have(5).items } # => pass
its(:size) { should == 5 } # => pass
end
context 'wrong subject' do
subject { String.new('5') } # => 另外一部戏的主角走错了戏场... -_-!
it { should have(5).items } # => fail... (导演)“你哪儿来的?”
end
context 'let will not work' do
let(:new_array) { Array.new(5) } # => 配角登场,妄图抢夺主角的地位
it { should have(5).items } # => 结果... 悲催了 T_T
its(:size) { should == 5 } # => fail
# 因为切换了语境,subject 又变成了初始值
it { should have(0).items } # => 主角的地位还是很稳固的 ^_^
its(:size) { should == 0 } # => pass
end
context 'let becomes subject' do
let(:new_array) { Array.new(5) } # => 配角再度登场,并且贿赂了一下导演(我...)
subject { new_array } # => 于是导演让配角试一下主角的戏份
it { should have(5).items } # => 嗯... 表现不错!
its(:size) { should == 5 } # => pass
end
end
之所以前文的例子没能这样清晰的表现,是因为我使用了 expect 语法(主动式的预期);新版本的 RSpec 推荐开发者使用 expect 替代过去的 should,这种语法貌似更受欢迎(驱使开发者用祈使句来撰写测试说明),唯一的缺憾就是丧失了上例中使用 should 带来的简洁性。因为 expect 语法要求显式指明 subject,所以隐式调用的特点就没能在前文的例子中表现的很透彻。
此外,例子中也体现了 context 和 its 的用法,RSpec 是很灵活的,可以说没有它做不到,只有你想不到,初学者不妨多用用,细细体会如何写出简明却极富表达力的测试用例吧。
多插几句,关于 should 和 expect 的区别。很多开发者不喜欢 expect,因为他们用 should 可以写出更简短的测试用例,那么为什么 RSpec 要选择 expect 呢?
问题就出在 should 虽然可以让测试用例很短,但是它隐藏了太多的语境信息,对于撰写测试的人来说还算 OK,但是让读测试的人很难跟得上语境的变化。
使用 should 写测试,理解测试的时候往往得把自己代入到语境之中,把“我”想象成“subject”。因为它读起来就好像:
“我(subject) 应当(should) 怎样怎样(to do/be something)”。
如果语境没有变化或者变化很小,那么读起来还不算太难懂,但如果在一个测试中有多个对象交互存在的话,要去分析和理解 subject 就会给阅读者带来很大的负担。
使用 expect 语法,要求显式的指明 subject 是谁,而“我”则不变——始终是开发者自己,这样一来就会从主观陈述句式变成客观祈使句式,读起来就好像:
“我(读代码的人)期望(expect)你(subject)怎样怎样(to do/be something)”。
由于“我”是恒定不变的,且 subject 必须显式指明,所以即使语境的变化非常频繁,也不会给阅读者带来额外的负担。
特别是在 RSpec 2.12.0 之后,由于和 Capybara 开发小组联手增加了 feature specs,expect 语法就越发重要了。我们知道,feature specs 相当于验收测试(acceptance test),它有一个很重要的功能就是能生成给不会写代码的人也能看懂的说明文档,should 虽然能减少一点代码量,但是在描述变化万千的用户界面交互时,一堆的 it should ... 只会让旁观者莫名其妙,因为他们不了解应用程序的内部构成,很难迅速分辨出 it 究竟是哪个 subject。换成 expect 之后,就变成了 expect(subject).to ... ,这样就很清楚了。
另外,should 的隐式调用能让开发者省去写 description 的工作,但是输出的文档却还是存在上述问题;expect 无法隐式调用 subject 了,于是 it 之后一定要写 description。初学者应当注意:既然你选择了使用 expect,那就不要再写 it 'should do something' ...
这样的 description 了,简单一点直接写 it 'do something' ...
即可。
一个良好的结构能够让任何人(包括不懂代码的人)都看得懂你要做什么:
feature "XX功能" do
background { condition } # 前置条件... 等同于 before
context "在某种情况下..." do
given(:object) { Object.new } # 给定某个参与者... 等同于 let
scenario "做某件事情..." do # 等同于 it,意思是“场景”
...
expect(subject).to do/be something # subject 是谁,你说了算
end
scenario "做另一件事情..." do
...
expect(subject).to do/be something
end
end
context "在另一种情况下..." do
given(:object) { Object.new }
scenario "做某件事情..." do
...
expect(subject).to do/be something
end
end
end
基本上看明白了你讲的语义,不过我觉得 #13 楼 @lgn21st 讲的更简单明了一些?
不过我也看出来一点:不是你们把简单问题复杂化,要怪只能怪 RSpec 做的太臃肿了。 这个观点不是我的,而是 MiniTest 的作者的。我只是转述一下。(貌似他是 Rspec 前作者之一)
下面是这两个方法在 MiniTest 的源码,通过这个源码就不难理解,为什么我的想法和你不太一样了。
##
# Essentially, define an accessor for +name+ with +block+.
#
# Why use let instead of def? I honestly don't know.
def self.let name, &block
define_method name do
@_memoized ||= {}
@_memoized.fetch(name) { |k| @_memoized[k] = instance_eval(&block) }
end
end
##
# Another lazy man's accessor generator. Made even more lazy by
# setting the name for you to +subject+.
def self.subject &block
let :subject, &block
end
MiniTest 的约定很简单:
主题
.#13 楼 @lgn21st 明白了默认 subject 的含义... (不过,那个例子中应该是 belongs_to ? )
另外,从源码中来看,有关 lazy-evaluated, let 和 subject 没有什么区别的。他们都是 lazy 的. 或者说:他们都是通过 block 方式实现了 lazy_evaluated. 代码中体现出来的只是,对象是通过一个上下文中的全局 Hash 被 Cached, 当复用时,无需反复创建对象。
我觉得 RSpec 大家都用的原因是:UnitTest 太慢了... 不过,现在的 MiniTest 已经比 RSpec 快很多了,现在已经 Ruby 2.0 & Rails 4 时代了,给个建议,大家是否可以考虑了解下 MiniTest ??
@nightire 连续好几个神回复…+1024
describe Array do
context 'when first created' do # 使用 context 的好处:语法上清除作用域;语义上体现语境变化
# 主角刚诞生,两手空空...
it { should have(0).items } # => pass
its(:size) { should == 0 } # => pass
end
……
这时候的 subject 应该是 Array,而不是 Array 的实例呀,Array 怎么会有 items、size ?
#16 楼 @zw963 不是你的想法和我的不太一样,而是这两个框架不一样,你可能只熟悉 MiniTest,我则因为两者都用,所以对语义在 RSpec 中的作用更熟悉些。比较一下二者,最贴切的一句话就是:RSpec 是 DSL,MiniTest 是 Ruby;对于 Ruby 开发者来说,MiniTest 足够了,但是 RSpec 可以写出即使不懂 Ruby 语言也能看得懂的 specs(the new feature specs for example, which is my favorite improvement)。
这其实跟什么时代没关系,如果我在写一个小型项目,参与者都是 Ruby 程序员的时候,我们就用 MiniTest,因为在此种情形下,subject 和 let 有没有区别不重要,大家心里都清楚。而在这个帖子里,我写得复杂不是为了给某个人看的,而是给所有初学者解释 RSpec 使用 DSL 对我们意味着什么,能让我们获得什么。
#17 楼 @keating described 是 class 会 new 个出来做 implicit subject
https://github.com/rspec/rspec-core/blob/master/lib/rspec/core/memoized_helpers.rb#L145
#16 楼 @zw963 为什么是 Rspec 而不是 MiniTest,选择的理由是看性能?还是代码是否臃肿?这些其实都是表象。对我来说,一个简单工具和一个复杂工具放到一起的时候,不一定是越简单就一定越好,要看你要解决的问题的复杂度(其实更多的时候是你对你要解决的问题的复杂度理解的维度),MiniTest 能给你的东西从他的名字就能看出来 —— Mini。当你要测试的目标以及环境复杂多变的时候,RSpec 给了你足够的支撑(单独写的话,会是一篇长文,试着用一小段概括一下)。
RSpec 的背后除了 subject
, let
之外东西还有很多,不过对我来说最有价值的是开发者对测试方法学层面的不断探索,挖掘,以及将成果沉淀这个框架工具中。翻出 2008 年我刚刚接触 RSpec 的时候的文章,哪个时候我从 UnitTest 转向 RSpec,最困扰我的并不是语法,而是 BDD 这个概念,当接受了 BDD 思想之后,你会发现你开始不愿意回去 UnitTest 了。虽然现在的 MiniTest 通过语法扩充,也能写出 RSpec 的 describe … it ….
这样形似的代码,但问题是,MiniTest 作为 Ruby 的标准库也就止步于此了。两者一起用下来,体验根本不是那么回事。RSpec 并未止步于一个 BDD 框架,它还提供极为优秀的 Mocks 套件,灵活的扩展机制让你方便的编写扩展插件,比如 Shuda ,推动 Acceptance Test Driven Planning 并衍生出 Cucumber 去实现 Full Stack Agile Methodology 等等...
关于 belong_to
的问题,是 Shuda 提供的。
describe Post do
# 这里没有定义 subject,所以默认就是 Post, 而且不需要去初始化对象,因为测试的是 Post 本身而不是它的实例。
# 出处: https://github.com/thoughtbot/shoulda
it { should belong_to(:user) }
it { should validate_presence_of(:title) }
end
那么这个呢?到底是 Post.new 还是 Post ?
MiniTest 当中,是一个对象的 eigenclass 作为类的实例。完全与 subject 无关。必须自己手动将 subject 代入才行.. RSpec 我不知道呀。也没装~
你和 @lgn21st 两个人说了两个样,RSpec 也太绕了。