BDD with Rspec and Steak

Behavior Driven Development is a test-first software development methodology. It’s a fairly straightforward process if you’re familiar with other agile development methodologies. Here’s a general outline of how software is developed with BDD:

All of the stakeholders in the project come together and decide what features the project needs. These “stories” are usually written up on index cards and then organized by priority.

Each story is broken up into smaller “features” that represent one aspect of the story.

An acceptance test is written for the feature.

“The Outer Loop”: Run the acceptance test, watch it fail.

Fix any sort of non-logic error that the failing test gives you.

Re-run the test. Repeat until you get a logical error. This transitions you to the “Inner Loop”

Write a unit test that expresses the desired logic.

Run the test. Watch it fail.

Write the simplest code that passes the unit test.

Run the test, see that it passes.

Refactor code as appropriate.

Pop back into the outer loop: Run the acceptance test again. If your acceptance test passes, congrats! Otherwise, drop back into the inner loop to fix the new logical error.

Refactor one last time, run all of your tests, make sure they all pass.

Congrats! Your feature is ready!

This might seem pretty complicated, but it’s actually relatively straightforward. Let’s go through a story that I’m writing for the Hackety Hack website. I’m the only stakeholder, so it’s easy to make executive decisions. Here’s the story:

As a user, I’d like the option to subscribe to forums and threads, and get notifications about updates.

Sounds pretty useful to me! I use email as a to-do list, and I want to make sure I’m kept up-to-date with any discussions. This story can be broken up into a few different features. Here they are:

Scenario: Subscribing to a thread
Given I've commented in a thread
When someone else makes a comment
Then I should receive an email
And it should have a link to that thread
Scenario: Unsubscribing from a thread
Given I've subscribed to a thread
And I'm on the page for that thread
When I click the unsubscribe link
And someone makes a comment
Then I should not receive an email
Scenario: Subscribing to a forum
Given I'm on the index page for a forum
When I click the subscription link
And someone makes a new thread in that forum
Then I should receive an email
And it should have a link to that forum
Scenario: Unsubscribing from a forum
Given I've subscribed to a forum
And I'm on the index page for that forum
When I click the unsubscribe link
And someone makes a new thread in that forum
Then I should not receive an email

Cool! Four scenarios to make up this feature. You’ll notice that I’m using certain language to talk about each scenario: Given, When, and Then. These words, while not mandatory are helpful: Most features involve some sort of initial system setup, an event that kicks something off, and then an effect that you want to see created. Thinking in these terms can be a useful way of getting started with a story or feature.

Okay! So let’s work on the first scenario: subscribing to a thread. There are a few different libraries that can used with Ruby for acceptance testing. Cucumber can actually be used to read these specifications as I’ve written them, but lately, I’ve come around to a simpler tool: Steak. If you’d like to follow along, you can check out a copy of the site like this:

This’ll get your ruby and gemsets set up, check out the revision right before this feature was implemented, and install the gems you need. You might also want to install a copy of MongoDB, you’ll need that, too.

I like to make a separate branch for every feature. This keeps things nice and organized, and if I want to work on multiple features at once, I can make sure they don’t depend on each other in awkward ways. Let’s do that now:

$ git checkout -b subscription_management

Awesome. Time to write an acceptance test! Steak works with RSpec, so the tests are all in the spec/acceptance directory:

$ mvim spec/acceptance/subscription_spec.rb

This’ll open up a blank file. We need to do a little bit of setup, and comment out our English descriptions:

requireFile.dirname(__FILE__)+'/acceptance_helper'feature"Subscriptions"do# Scenario: Subscribing to a thread# Given I've commented in a thread# When someone else makes a comment# Then I should receive an email# And it should have a link to that thread# Scenario: Unsubscribing from a thread# Given I've subscribed to a thread# And I'm on the page for that thread# When I click the unsubscribe link# And someone makes a comment# Then I should not receive an email# Scenario: Subscribing to a forum# Given I'm on the index page for a forum# When I click the subscription link# And someone makes a new thread in that forum# Then I should receive an email# And it should have a link to that forum# Scenario: Unsubscribing from a forum# Given I've subscribed to a forum# And I'm on the index page for that forum# When I click the unsubscribe link# And someone makes a new thread in that forum# Then I should not receive an emailend

Okay, now let’s flesh out our first scenario with some Steak:

scenario"Subscribing to a thread"do#Given I've commented in a threadthread=Factory(:discussion)me=Factory(:hacker)reply=Factory(:reply,:author=>me.username,:author_email=>me.email)thread.replies<<replyPony.should_receive(:deliver)do|mail|mail.to.should==[me.email]mail.body.to_s.should=~/\/forums\/#{thread.forum}\/#{thread.slug}/end#When someone else makes a commentsomebody=Factory(:hacker)log_insomebodyvisit"/forums/#{thread.forum}/#{thread.slug}"click_link"Reply"fill_in"Body",:with=>"Here's my take on things: Dream big!"click_button"Create Reply"# Then I should receive an email# (see pony block above)# And it should have a link to that thread# (see pony block above)end

Okay. So. There’s one thing that’s a little weird about this test: we have to say that we expect Pony to receive an email before we create comment #2. This happens in tests that are similar to this one; we’re really testing a side-effect.

Other notable parts of this test: We’re using Factory Girl to set up the state of the world, and Capybara to interact with our application to make the new comment. We’re also using a log_in helper that I’ve written for some of my other tests.

Whoops! This is both good and bad. Test failure #2 is our logical error: the test ran, but Pony was expecting an email, and it didn’t get one. The first error is something that happens sometimes: we have a more brittle test than we thought. Rather than write a test that actually looks up a username, I hardcoded a username. Whoops! After fixing that, we just get the expectation failure. Time to drop into the inner loop.

Our unit tests should be testing a few things: making a Reply should create a subscription to a thread, and it should also send a mail to everyone who has a subscription. Unit tests are kept in the spec directory. Let’s check out our Reply tests:

$ mvim spec/reply_spec.rb

… and there aren’t any. So let’s set that up:

requireFile.expand_path(__FILE__+"/../spec_helper")describeReplydoit"should do something"end

I currently have ‘rake spec’ set up to run all of my specs, so we also get the failing acceptance test as well as the pending unit test. As your test suite grows, you’ll generally break these out in a more fine-grained way, but the tests only take about two seconds to run, so I haven’t found the need to yet.

Okay, let’s actually write our unit tests:

describe"subscriptions"doit"adds one to its Discussion upon creation"dodiscussion=Factory(:discussion)discussion.subscribed_users.count.should==0discussion.replies<<Factory(:reply)discussion.subscribed_users.count.should==1endit"triggers an email to others upon creation"end

Now that I’m looking at this test, though, it seems a little bit bad. Why am I asking for all of this stuff about a discussion? This is a reply test? And besides, if I write the after_create handler for the reply right now, it’s going to have to mess around with the internals of its Discussion to do its job. That’s a poor separation of concerns. Let’s back up:

$ git checkout models/discussion.rb

And rework our test:

it"adds one to its Discussion upon creation"doemail="someone@example.com"@discussion=Factory(:discussion)@discussion.should_receive(:create_subscription!)@discussion.replies<<Factory(:reply,:author_email=>email)@discussion.saveend

This is much more clear. We tell the discussion that someone would like to subscribe to it. And running our test tells us what is needed next:

2) Reply subscriptions adds one to its Discussion upon creation
Failure/Error: discussion.should_receive(:create_subscription!).with(email)
(Double Discussion).create_subscription!("someone@example.com")
expected: 1 time
received: 0 times

Two things about this: First of all, I’d really like this to be an after_create, not an after_save. It appears as though MongoMapper doesn’t run after_create with embedded documents, though. I’m going to have to look into this a bit further before committing this code. after_save is good enough for now. Secondly, to reach back up out of the embedded document, we need to use the rootdocument accessor. This starts with an _, and so I’m not sure that it’s the best way to accomplish this. _s usually indicate private things that you shouldn’t mess with. So I have two things that are kinda bad, but they’re good enough for now. That’s the whole point of being Timeless, eh?

So it’s time to add this method to Discussion. Let’s pop open the (nonexistant) Discussion tests

$ mvim spec/discussion_spec.rb

and add a test:

describeDiscussiondodescribe"#create_subscription!"doit"adds a new email to the subscription list"dodiscussion=Factory(:discussion)discussion.subscribed_users.count.should==0discussion.create_subscription!"somebody@example.com"discussion.subscribed_users.count.should==1endendend