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:
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
bundle install
, we install Cucumber into the blog project:$ rails generate cucumber:install
features
directory named write_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
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)!
$ rails generate scaffold Post title content:textpost_time:datetime
$ rakedb:migrate $ RAILS_ENV=test rake db:migrate
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
http://localhost:3000/posts
, we can see it works as expected. The following is a screenshot of the blog home page: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
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 a common_steps.rb
under the step_definitions
directory. After that we move the step Given I am on the blog homepage
from write_blog_steps
to common_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
show_blog_list.feature
and watch that it fails. We will see that the two Given
steps have already been implemented within common_steps
: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 of posts-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
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 %>
show_blog_list.feature
. It passed, yeah!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
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
edit_blog.feature
, it should now pass. The following screenshot shows edit_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!
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
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
$ rails generate scaffold Comment post:references name email content
$ rakedb:migrate && RAILS_ENV=test bundle exec rake db:migrate
routes.rb
. We specify comments as nested resources under blogs:resources :blogs do resources :comments end
Comment
belongs to Post
; we need to update Post
to contain many comments as well, in post.rb
:has_many :comments
In CommentsController
, we update the create
action to load the post
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
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>
input_comment.feature
has run successfully: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
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
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 %>
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.
3.12.163.175