In this recipe we are going to build a real web application with a BDD style and process. You will learn how to work with behavior-driven software development using Cucumber.
Assume we are in a web development team and are cooperating with a product manager. Our goal is to develop a simple web blog system, and we have already had a meeting and summarized several user stories as follows:
Story #1: As a blog owner, I can write new blog posts.
Story #2: As a blog visitor, I can see a list of posted blogs.
Story #3: As a blog owner, I can edit my blog posts.
Story #4: As a blog visitor, I can input comments onto the blog.
Story #5: As a blog owner, I can delete comments.
Out first step is to let Rails generate a new application called
blog
:$ rails new blog --skip-test-unit
And we need the following Ruby gems in Gemfile:
group :test do gem 'rspec-rails' gem 'cucumber-rails' gem 'capybara' gem 'launchy' gem 'database_cleaner' end
After
bundle install
, we install Cucumber into the blog project:$ rails generate cucumber:install
We wait until Rails has finished generating Cucumber files. Then we can start writing the first Cucumber scenario for this story. We add a feature file under the
features
directory namedwrite_post.feature
:Feature: Write blog As a blog owner I can write new blog post Scenario: Write blog Given I am on the blog homepage When I click "New Post" link And I fill "My first blog" as Title And I fill "Test content" as content And I click "Post" button Then I should see the blog I just posted
Let's run the
write_post.feature
and watch it fail:cucumber features/write_post.feature:
Yes it fails, which is good and as expected; now we have work to do, that is to implement this feature (also a real story)!
So we go to our favorite terminal and have Rails help us generate a Post scaffold:
$ rails generate scaffold Post title content:textpost_time:datetime
We perform a database migration for both development and test environments:
$ rakedb:migrate $ RAILS_ENV=test rake db:migrate
We now start implementing the Cucumber step for
write_post
. A little noticeable point is using@title
to record the entered title for future expected use. The code is shown as follows:Given /^I am on the blog homepage$/ do visit("/posts") end When /^I click "New Post" link$/ do click_on "New Post" end When /^I fill "(.*?)" as Title$/ do |title| @title = title fill_in "Title", :with => title end When /^I fill "(.*?)" as content$/ do |content| fill_in "Content", :with => content end When /^I click "(.*?)" button$/ do |btn| click_button btn end Then /^I should see the blog I just posted$/ do page.should have_content(@title) end
Ok, now we rerun the test and it should pass:
And if we open the browser to do a manual test on
http://localhost:3000/posts
, we can see it works as expected. The following is a screenshot of the blog home page:The screenshot of the write blog page is as follows:
We create a new feature named
show_blog_list.feature
, and we assume there already exists four blog posts:Feature: Show blog list As a blog visitor I can see list of posted blogs Scenario: Show blog list Given there are already 4 posts And I am on the blog homepage Then I can see list of 4 posted blogs
The
Given
step is exactly the same with the "write blog" feature. We definitely shouldn't repeat ourselves. The "posts preparation" step seems very common, so we can create acommon_steps.rb
under thestep_definitions
directory. After that we move the stepGiven I am on the blog homepage
fromwrite_blog_steps
tocommon_steps
and create a shared step for preparing blog posts:Given /^I am on the blog homepage$/ do visit("/posts") end And /^there are already (\d) posts$/ do |count| count.to_i.times do |n| Post.create!({ :title => "Title #{n}", :content => "Content #{n}", :post_time => Time.now }) end end
Ok, now we run
show_blog_list.feature
and watch that it fails. We will see that the twoGiven
steps have already been implemented withincommon_steps
:Following the guide we create
show_blog_list_steps.rb
. In the step we expect there to be a list of blogs wrapped within an HTML table with an ID ofposts-list
, and since we prepared four blog posts we expect there to be five rows in the table, so we firstly write our testing code as follows:Then /^I can see list of (\d) posted blogs$/ do |count|page.should have_selector("table#posts-list>tr:eq(#{count})")end
Now it is time to write real code which is driven by the preceding described behavior code. We modify
app/views/posts/index.html.erb
as follows:<h1>Listing posts</h1> <table id="posts-list"> <tr> <th>Title</th> <th>Content</th> <th>Post time</th> <th></th> <th></th> <th></th> </tr> <% @posts.each do |post| %> <tr> <td><%= post.title %></td> <td><%= post.content %></td> <td><%= post.post_time %></td> <td><%= link_to 'Show', post %></td> <td><%= link_to 'Edit', edit_post_path(post) %></td> <td><%= link_to 'Destroy', post, method: :delete, data: { confirm: 'Are you sure?' } %></td> </tr> <% end %> </table> <br /> <%= link_to 'New Post', new_post_path %>
Now we rerun
show_blog_list.feature
. It passed, yeah!
We add a new feature named
edit_blog.feature
:Feature: Edit blog As a blog owner I can edit my blog posts Scenario: Edit blog Given there is a post with title "Dummy post" and content "Dummy content" And I am on the blog homepage When I edit this post And I update title to "Updated title" and content to "Updated content" Then I can see it has been updated
As usual, we first run it and watch it fail, and then create
edit_blog_steps.rb
with test code as follows:Given /^there is a post with title "(.*?)" and content "(.*?)"$/ do |title, content| @post = Post.create!({ :title => title, :content => content, :post_time => Time.now }) end When /^I edit this post$/ do visit(edit_post_url @post) end When /^I update title to "(.*?)" and content to "(.*?)"$/ do |title, content| @updated_title = title @updated_content = content @post.update_attributes!({ :title => @updated_title, :content => @updated_content }) end Then /^I can see it has been updated$/ do step %{I am on the blog homepage} find("table#posts-list>tr:eq(2) >td:eq(1)").should have_content(@updated_title) find("table#posts-list>tr:eq(2) >td:eq(2)").should have_content(@updated_content) end
When we go back to the terminal and rerun the
edit_blog.feature
, it should now pass. The following screenshot showsedit_blog.feature
has run successfully:
Hooray! We've finished three stories so far. They are all around posts creating, editing, and viewing. There are two more stories related with comments. Let's starting developing them with the behavior-driven development style!
We first create an
input_comment.feature
for this story:Feature: Input comment As a blog visitor I can input comment onto blog Scenario: Input comment Given there is a post titled with "Dummy post" and content with "Dummy content" And I am on the post page When I add a comment with the following information | Name | Email | Content | | Wayne | [email protected] | Test comment | Then I can see the comment has been added onto the post
We run the feature and watch it fail:
We create
input_comment_steps.rb
and write the test code as follows:Given /^I am on the post page$/ do visit(post_path @post) end When /^I add a comment with the following information$/ do |table| # table is a Cucumber::Ast::Table table.hashes.each do |comment_data| @commenter = comment_data[:name] @email = comment_data[:email] @content = comment_data[:content] @post.comments.create!({ :name => @commenter, :email => @email, :content => @content }) end end Then /^I can see the comment has been added onto the post$/ do comments_list = find("div#comments-list") comments_list.should have_content(@commenter) comments_list.should have_content(@email) comments_list.should have_content(@content) end
To make our test pass, we let Rails help us to generate a Comment scaffold:
$ rails generate scaffold Comment post:references name email content
Database migration is as follows:
$ rakedb:migrate && RAILS_ENV=test bundle exec rake db:migrate
After the migration is done, we update several places in the Rails-generated code. First is
routes.rb
. We specify comments as nested resources under blogs:resources :blogs do resources :comments end
We've specified that
Comment
belongs toPost
; we need to updatePost
to contain many comments as well, inpost.rb
:has_many :comments
In
CommentsController
, we update thecreate
action to load thepost
object that the created comment belongs to:def create @comment = Comment.new(params[:comment]) @comment.post = Post.find_by_id(params[:post_id]) respond_to do |format| if @comment.save format.html { redirect_to @comment.post, notice: 'Comment was successfully created.' }else format.html { render action: "new" } format.json { render json: @comment.errors, status: :unprocessable_entity } end end end
And finally we update the view template
app/views/posts/show.html.erb
for showing a post:<h2>↓Comments↓</h2> <div id="comments-list"> <% @post.comments.each_with_index do |c,idx| %> <p><span>#<%= idx + 1 %>: <%= c.name %></span>: <%= c.content %></p> <% end %> <hr /> <%= form_for([@post, @post.comments.build]) do |f| %> <div class="field"> <%= f.label :name %><br /> <%= f.text_field :name %> </div> <div class="field"> <%= f.label :email %><br /> <%= f.text_field :email %> </div> <div class="field"> <%= f.label :content %><br /> <%= f.text_area :content %> </div> <div class="actions"> <%= f.submit %> </div> <% end %> </div>
At this time, we should be able to pass the input comment feature. The following screenshot shows that
input_comment.feature
has run successfully:
As usual we create a
delete_comment.feature
:Feature: Delete comment As a blog owner I can delete comment Scenario: Delete comment Given there is a post titled with "Dummy post" and content with "Dummy content" And there is a comment on this post When I am on the post page And I click "Delete Comment" Then the comment should be deleted
Run it and watch it fail, and then implement the steps inside
delete_comment_steps.rb
as follows:Given /^there is a comment on this post$/ do @post.comments.create!({ :name => "Wayne", :email => "[email protected]", :content => "Test deleting comment" }) end When /^I click "Delete Comment"$/ do click_on "Delete Comment" end Then /^the comment should be deleted$/ do find("#comments-list").should have_no_content("Wayne") end
To make the test pass, we need to update
show.html.erb
to add the Delete Comment link to each comment:<% @post.comments.each_with_index do |c,idx| %> <p> <span>#<%= idx + 1 %>: <%= c.name %></span>: <%= c.content %> <%= link_to "Delete Comment", post_comment_path(@post, c), :method => :delete, :confirm => "Are you sure you want to delete this comment?" %> </p> <% end %>
Now we rerun
delete_comment.feature
. It passed successfully:
In this recipe we developed a very simple blog application with BDD using Cucumber. We split the requirement into five user stories, and then transformed them into Cucumber features. Next we implemented each story one by one, strictly following the BDD process.
Most of the code was generated by Rails, so we actually wrote very few lines of product code, because our goal is to learn how to use Cucumber to drive a real user story development, so that we are getting used to BDD with one story by another, and one iteration by another, eventually delivering the software.
The essence of BDD using Cucumber is that it describes a feature and its expected behavior. So that drives the development under the definiteness (ideally no misunderstanding); on the other hand, each Cucumber feature is just like an acceptance test case, which can be easily integrated with continuous integration.