Objective: Use TDD in Rails to create an inventory management application. Your goal is to write code to pass some existing tests, then write and pass tests for other features.
- Clone this repo into your WDI class folder on your local machine.
- Run
bundle installto install gems. - Run
rake db:create db:migrateto create and migrate the database. - Start your Rails server.
- The primary gem you'll use for testing this app is
rspec-rails. You'll also usefactory_girl_railsto set up and tear down test data andffakerto create realistic fake data. Examine yourGemfileto make sure these gems are included. - Run
rspecin the Terminal. You should see0 examples, 0 failures. - Create a new
my-items-introbranch to start working on, and switch to it.
- Gems
rspec-rails,factory_girl_rails, andffaker(and a few others) are installed in the Gemfile for thedevelopmentandtestgroups. - Terminal command
rails generate rspec:installhas been run, creating the.rspecfile and thespecdirectory and its contents.
-
Use Rails to generate an rspec controller test file for the items controller:
rails g rspec:controller items. Read the Terminal output, and open the new file that was created. -
This spec file references the
ItemsController, which isn't set up yet. Runrspecnow to see the erroruninitialized constant ItemsController (NameError). -
Use Rails to generate an items controller. Read the Terminal output - note that Rails attempted to create the spec file again because
rspec-railsis included in the project. -
Run
rspecto confirm your tests are no longer throwing errors.
-
Use Rails to generate rspec model test files for the items model:
rails g rspec:model item. Note that this command creates two new files. One is a factory, and the other is theItemmodel spec. -
Run
rspecto see the erroruninitialized constant Item (NameError). -
Use Rails to generate an item model. Items will have three attributes:
color,size, andstatus. Read the Terminal output to see which files are created. Chooseyto overwrite the factory file if there is a conflict. -
Run
rspecto confirm the tests are working. You will see "pending" tests.
-
Add a RESTful route to
config/routes.rbthat will trigger the items controller'sshowaction. To follow Rails conventions, make it a named route with the name or "prefix"item. Runrake routes.click to see route syntax
`get '/items/:id' => 'items#show', as: :item` -
In your items controller spec file, add this section to test the
items#showcontroller action:# spec/controllers/items_controller_spec.rb RSpec.describe ItemsController, type: :controller do describe "#show" do it "renders the :show view" do item = Item.create({size:'s', color:'blue', status:'unsold'}) get :show, id: item.id expect(response).to render_template(:show) end end end
-
Discuss with a partner what each line in the test above does. Refer to the
rspec-railscontroller spec docs.click for solution
```ruby # spec/controllers/items_controller_spec.rbRSpec.describe ItemsController, type: :controller do
# set up tests for the show action, specifically describe "#show" do
# say how to test one goal of the show action it "renders the :show view" do
item = Item.create({size:'s', color:'blue', status:'unsold'}) # create test item get :show, id: item.id # make a get request to /items/:id expect(response).to render_template(:show) # test that response renders show view end end end</details> -
This test will require a
showmethod in the items controller, and ashowview for items. Add an emptyshowmethod to the items controller, and create anapp/views/items/show.html.erbfile if you don't have one yet. -
Run
rspec spec/controllersand verify that your test passes. -
The
showmethod should look up the item to display and assign an@iteminstance variable to be used in the view. Add a second test inside thedescribe #showblock:it "assigns @item" do item = Item.create({size:'s', color:'blue', status:'unsold'}) get :show, id: item.id expect(assigns(:item)).to eq(item) end
-
This isn't looking very DRY! Use the
rspec-railslethelper method to assign theitemat the beginning of thedescribe #showblock:describe "#show" do let(:item) { Item.create({size:'s', color:'blue', status:'unsold'}) } it "renders the :show view" do get :show, id: item.id expect(response).to render_template(:show) end it "assigns @item" do get :show, id: item.id expect(assigns(:item)).to eq(item) end end
-
To DRY up the tests further, use the
rspec-railsbefore(:each)hook (method) to make a get request before each of the#showtests are run:describe "#show" do let(:item) { Item.create({size:'s', color:'blue', status:'unsold'}) } before(:each) do # same as `before do` get :show, id: item.id end it "renders the :show view" do expect(response).to render_template(:show) end it "assigns @item" do expect(assigns(:item)).to eq(item) end end
-
Run
rspec spec/controllers, and write code to pass your tests.
-
Make the route in
config/routes.rbthat will route to theitems#createaction. Also make a route for theitems#newaction, since that's the action that will eventually serve a form and lead to thecreateroute.click to see routes after this step
```ruby get '/items/new' => 'items#new', as: :new_item post '/items' => 'items#create' get '/items/:id' => 'items#show', as: :item ``` -
Create skeleton (empty) methods in the items controller for the
newandcreateactions. -
Make a new block in the items controller spec file (
spec/controllers/items_controller_spec.rb) todescribethe#createaction. -
For this action, you'll test two different contexts: successful creates and validation failures. Add two
contextblocks inside thedescribe #createblock.click to see what the `describe #create` block should look like now
```ruby # spec/controllers/items_controller_spec.rb # after the `describe #show block` describe "#create" do context "success" do endcontext "failed validations" do end end
</details> -
Inside the "success" context, use
letto set up a hash of valid item data. Then usebefore(:each)to make apostrequest to thecreateaction with item dataitem_hash:context "success" do let(:item_hash) { { size: "XL", color: "heather", status: "sold" } } before(:each) do post :create, item: item_hash end end
-
If the create is successful, the
createaction should redirect to the show page for the new item (redirects have an HTTP status of 302). Add the following test after thebefore(:each)block ends:it "redirects to 'item_path'" do expect(response.status).to be(302) expect(response.location).to match(/\/items\/\d+/) end
-
The
createmethod should add the new item to the database. Add another test to the success context that checks whether the number of items in the database increases when you give thecreatecontroller action the valid data.- Hint: Compare the
Item.countbefore and after the request is made to create the new item. - Hint: Use
rspec-railsequality matchers to check whether expected and actual values are the same.
click for solution
context "success" do let(:item_hash) { { size: "XL", color: "heather", status: "sold" } } let(:items_count) { Item.count } before(:each) do post :create, item: item_hash end # ... it "adds an item to the database" do expect(Item.count).to eq(items_count + 1) end end
- Hint: Compare the
-
Your item model will eventually require that each item have a
status. One way thecreatemethod could fail is to if someone tried to create an item with anilstatus. Inside the "failed validations" context block, useletto set up an item data hash with anilstatus. -
Also in the failed validations context, use
before(:each)(or justbefore) to make a post request to the create action with your invalid item hash as data. -
If the item fails validations, the controller should redirect back to the form (on the new item path). Inside the "failed validations" context, add a test to check that it
"redirects to 'new_item_path'"with a response of 302.click to see what the failed validations context should look like after the last 3 steps
context "failed validations" do # set up item data without a status to cause validation failure let(:item_hash) { { size: "S", color: "sage", status: nil } } before do post :create, item: item_hash end it "redirects to 'new_item_path'" do expect(response.status).to be(302) expect(response).to redirect_to(new_item_path) end end
Details
-
If the item fails validations, the controller should add an error message to the flash hash. Add the following test:
context "failed validations" do # ... it "adds a flash error message" do expect(flash[:error]).to be_present end end
- Run
rspec spec/controllers, and fill in theshowandcreateactions pass your tests. (You'll need to add a validation to the item model to ensure thestatusis present.)
-
Take a closer look at the item factory file that Rails generated for you when you generated the model test:
spec/factories/items.rb. Factory Girl is a gem that will set up and tear down instances of test data for your app. The current code sets up a factory to create and destroyiteminstances. It should look like this:# spec/factories/items.rb FactoryGirl.define do factory :item do color "MyString" size "MyString" status "MyString" end end
-
Every time you use the factory above to create an item, you'll get an item with color, size, and status all equal to
"MyString". Replace these values with different ones to change the items Factory Girl will create; for example:# spec/factories/items.rb FactoryGirl.define do factory :item do color "cerulean" size "M" status "sold" end end
-
It can be helpful to test with data that is more realistic. Sometimes people use randomized data as well. The benefit is you might find edge cases in random data that you forgot initially. However, be extremely careful with randomized data, as this can introduce hard to track-down intermittent test failures.
-
Use Factory Girl's "lazy attributes" and the Ruby array method
sampleto make the factory randomly assign either"sold"or"unsold"as the status of each item it creates.click for solution
`status { ["sold", "unsold"].sample }` -
Use Factory Girl's lazy attributes and FFaker's
Colormodule to make the factory assign a random color to each item.click for solution
`color { FFaker::Color.name }`
Feel free to check your work against the solution-items-intro branch.
A product represents a kind of item sold by this app. Each of this app's products will store a name, a description, a category, a sku number (which may contain numbers and letters), and wholesale and retail prices. Both prices will be decimals, because Ruby's BigDecimal is more precise than a float!
- Commit your work on your branch.
- Check out the
products-startbranch. - Create and switch to a new
my-workbranch.
Reference the solution-products branch for guidance if you get stuck during this part of the lab.
- The failing specs are for a
ProductsController. Implement the functionality for theProductsControllerto pass the tests. Some tips:- Read the errors carefully. They will guide you as to what to do next.
- Once you've gotten past the initial setup errors, and you have failing specs printing out in the Terminal, it may help to only run specific specs by name using
rspec spec -e '#index'
- You DON'T need to implement fully-functioning views.
- To pass some of these tests, you'll have to add model validations to check that fields are present.
- Remember to use strong parameters in your controller.
-
Once you have all the specs passing for the
ProductsController, it's time to implement unit tests for a product model. -
Generate an rspec model test for the product model by running
rails g rspec:model product. Read the log messages carefully and find the file(s) Rails expects you to use for testing. One of these files isspec/factories/products.rb. Do not overwrite this file! You'll use the factory in this file, with the gems Factory Girl and FFaker, to create data for testing. -
The other new file generated for your model tests is in
spec/models. In this file, write tests for a product model instance method calledmargin. The#marginmethod should calculate and return the retail margin of the product instance. The retail margin is the retail price minus the wholesale price, divided by the retail price and expressed as a percentage.-
What product to test with?
You can use Factory Girl to `create` a sample product in the test code. (See the controller code for an example.) Also calculate the product's profit margin (by hand) so you know what you expect the `margin` method to return.
-
-
Write a test to ensure that the
#marginmethod returns aBigDecimalvalue. -
Write a test to endure that the
#marginmethod returns a correct value for some example product. -
Run
rspec spec/models, and read the output carefully. Fix any errors that are preventing your tests from running. -
Once you have your model tests running, write code to pass them!
Now, you'll practice TDD more independently.
A product represents a type of product the site sells. (You can think of products as tshirts, for example.) The site allows customization of the the color and size of products, and it would be good to know the status of each particular item in the warehouse (sold/unsold). For this reason, products should have many items. Use TDD to guide your implementation of CRUD for items. That means write tests first.
Your items should be set up to have a minimum of three attributes: size, color, and status. The status will usually be "sold" or "unsold".
Note: Items routes should be nested under products routes. See the Rails docs for nested resources.
Reference the solution-nested-items branch for guidance if you get stuck during this part of the lab.
-
Generate test and factory files for the item model, if you don't have them yet. Generate the item model, if you don't have one yet.
-
Take advantage of the
factory_girl_railsandffakergems to define anitemfactory to use in your model tests. -
Use Factory Girl's associations to add a product to your item factory, and refactor your controller code.
-
If you don't have one yet, use Rails to generate an
rspectest file for the item controller. Runrspec spec/controllers, and debug any issues that prevent your item controller tests from running (you'll still see your product controller tests passing). -
Follow the examples in
spec/controllers/products_controller_spec.rbas a guide while you write tests for yourItemsController. -
Your
ItemsControllerdoesn't need an#indexmethod, since your app will display all of a product's items on theproducts#showpage. However, it should have the other six methods for RESTful routes (#new,#create,#show,#edit,#update, and#destroy). -
Your tests should check that the appropriate controller actions display flash error messages when the model fails to validate the
presenceof thestatusattribute. -
As you go, continue to debug any errors that prevent
rspecfrom running your tests. Read log and error messages carefully. -
Implement item controller code to pass the tests you wrote.
-
Making a change while doing TDD for an app? Better write tests first!
-
Your goal is to add an instance method to the products model called
sell_through. The#sell_throughmethod should calculate and return a decimal value: the overall sell-through rate for this product (items sold / total items). Write the spec for#sell_through. -
Once you have the spec written, write code in your product model to pass the test(s) you wrote.
Reference the solution-nested-items branch for guidance if you get stuck during this part of the lab.
####Goal: Strong params for security and prosperity!
-
If you haven't yet, use Rails strong parameters for your items and products controllers.
-
In your products controller, define a private
product_paramsmethod that implements strong parameters (look this up if you need to)! Refactor your controller actions to use the newproduct_paramsmethod. -
In your items controller, define a private
item_paramsmethod that implements strong parameters (look this up if you need to)! Refactor your controller actions to use the newitem_paramsmethod.
####Goal: DRYify item and product lookup.
-
Many routes in the products controller look up a product by id. Define a private
set_productmethod in the products controller that assigns the@productvariable based on the id parameter. -
Refactor your controller actions to use the
set_productmethod before the other methods that find the product. Hint: look upbefore_filter. -
Similarly, many routes in the items controller look up a product and/or item. Create
set_productandset_itemmethods in your items controller, and usebefore_filters to apply them to the appropriate actions.
