- Practice test-driven development
- Gain experience with rails console
- Practice branching and merging with git
- Practice generating code snippets with ChatGPT
-
Use git to clone the base project set up on github and review the contents before proceeding. Look at the ERD that is in the
doc/
directory. Basically, you have a project that has been scaffolded in accordance with the ERD, but there is essentially no model code apart from what comes with ApplicationRecord. In the first part we will be using Test Driven Development (TDD) to build out these models and verify that it is working. In the second part of the lab we will do some clean up of the views to take advantage of some of the model code we've written-
Run the bundle command to install the new gems used in this project:
bundle install
-
Install the
validates_timeliness
support files using the rails generator:rails generate validates_timeliness:install
Commit these changes to git.
- Run
git branch
to see that you are on the main branch. Run therails db:migrate
command to generate the development database. Then runrails db:test:prepare
to generate the test database. Once this is done, switch to a different branch calledmodels
.
-
-
Now we are going to test the Child model by writing some unit tests. Open the
test/test_helper.rb
file and set the use of transactional fixtures to false by commenting out the linefixtures :all
. At the very top of file, add in the support for simple_cov (a gem which will give us basic test coverage statistics) by adding the following lines:require 'simplecov' SimpleCov.start 'rails'
If you have forgotten to do so by this point, then make sure you are committing your work to git. Hopefully it is becoming second nature now. It is a great idea to be regularly saving your code to git or some other form of source code control and to take advantage of branching when appropriate. (Your discretion today when to do these things...)
-
Getting back to the code, within the
test/
directory, there is a file calledfactories.rb
. In that file, we need to complete the Child factory. Set the first name of the child by default to 'Alex' and the last name to 'Heimann' by default. (Note: if you have Copilot running, you are going to get suggestions for this as you start typing.) Look at the other factories provided to understand the syntax. For a list of fields on the Child model, look at thedb/schema.rb
file (ignore created_at and update_at fields). Have a TA verify that the factories are correct before proceeding. -
Now we are going to practice test driven development by writing out our tests in these first few steps, then later writing our model code to pass these tests. We are ready to create unit tests for the Child model. Open the
child_test.rb
file within thetest/models/
directory. In the first section, we will add some shoulda matchers, beginning with relationship matchers:should have_many(:chores) should have_many(:tasks).through(:chores)
We will write more tests in just a moment, but for now let's try running these tests for the Child model in the command line. Rails makes it easy to do this, simply use the command
rails test test/models/child_test.rb
. This will perform all our unit tests contained within just thechild_test.rb
file. Of course, we fail these tests, since we haven't written relationship code for the Child model yet. In this way, you can test a single model on its own, or you can run your full unit test suite by executingrails test test/models
on the command line, which will just execute all tests contained in thetest/models
directory. -
Back to writing tests for Child, we next add in validation matching:
should validate_presence_of(:first_name) should validate_presence_of(:last_name)
Again test these by running
rails test test/models/child_test.rb
on the command line and watch them fail. -
Before we continue we should set up contexts for more complex unit tests. Essentially a context is a controlled environment where the same database setup is replicated before each test within the context to test the same data on various scenarios. For example, we can create a context where there are 3 children in the system, 2 of which are active. We then can use knowledge about these children within the context to set up testing scenarios.
To save you time and typing, we've provided in the
contexts.rb
file a method calledcreate_children
which adds a diverse set of objects to the test database anddestroy_children
which wipes them out. If you call these methods in the the setup and teardown method inChildTest
we can create a clean testing environment for each test so we know what the tests should return if the methods being tested are working properly. To verify it is working add this code to the bottom of yourchild_test.rb
file:context "Creating a child context" do setup do create_children end teardown do destroy_children end should "have name methods that list first_ and last_names combined" do assert_equal "Alex Heimann", @alex.name assert_equal "Mark Heimann", @mark.name assert_equal "Rachel Heimann", @rachel.name end should "have a scope to alphabetize children" do assert_equal ["Alex", "Mark", "Rachel"], Child.alphabetical.map{|c| c.first_name} end should "have a scope to select only active children" do assert_equal ["Alex", "Mark"], Child.active.alphabetical.map{|c| c.first_name} end end
Review this code so you understand how it works and ask a TA for help if you are unclear about any aspect of it.
-
Looking at the first test, we need to test the
alphabetize
scope which essentially alphabetizes the children by name. We know what the result should be for these 3 children and can compare that with what the method returned. Observe this testing scenario in the following line copied from above:assert_equal ["Alex", "Mark", "Rachel"], Child.alphabetical.map{|c| c.first_name}
-
Looking at the second test, we need to test the
active
scope which lists out all of the active children. We know what the result should include the 2 active children, but we don't know in which order they will appear so we call the alphabetical scope once again and make sure that our array includes the names of Alex and Mark in alphabetical order too. Again, observe the following line copied from above:assert_equal ["Alex", "Mark"], Child.active.alphabetical.map{|c| c.first_name}
If you run the unit tests at any point in these first few steps you will see lots of failing tests since we don't have any corresponding code in the model. But that's okay, writing thorough tests now will be helpful as we write out our model.
STOP: Show a TA that you have the basic tests written for the Child model and that you have properly saved the code to git.
Start by checking on the output of these tests once more with rails test test/models/child_test.rb
.
You should see failures and errors! Don't panic - we are going to fix them now.
-
We need relationships to
chores
andtasks
, so open up theChild
model as well as theChore
model and add the appropriate relationships. Rerun the tests and verify that the test for these relationships pass. -
Add
validates_presence_of
validators forfirst_name
andlast_name
and rerun your tests. You should have two more passing tests.Commit these changes to your repository.
-
Create a new method in your
Child
model calledname
that returns "First Last":def name # ... end
Run the tests again and see that another test passes.
-
Add a scope to the
Child
model to alphabetize by last_name, first_name:scope :alphabetical, -> { order(...) }
Run the tests again and see that another test passes. In case you don't believe the test is actually passing, try removing an element from the intended array specified in the child tests file and watch the test fail until you add that item back in.
-
Add a scope to the
Child
model to only return active children:scope :active, -> { where(...) }
Re-run the tests and you should and they should all pass + 1 error (about missing the Chore model).
Commit these changes to git.
-
Now that you have a complete set of passing tests for the Child model, switch back to main and merge the
models
branch into main.
STOP: Show a TA that you have the Rails app set up, the first set of unit tests passing, and have branched as instructed, properly saved the code to git, and merged back to main.
-
Switch back to the
models
branch. -
We need to create a test for the Task model. Open ChatGPT and use the following prompt:
I have a Ruby on Rails app that tracks children performing chores. A chore is a task assigned to a child. The Task schema is: "name" (string), "points" (integer), "active" (boolean). I need a test suite for Task using Minitest and Shoulda matchers that: 1. tests that a Task has_many :chores and has_many :children, through: :chores 2. validates the presence of name 3. requires points to be a positive integer I also need the test suite to have a test for a scope that orders tasks alphabetically by name. I also need a test for a scope that only selects active tasks.
In some cases, ChatGPT will also give you code for the Task model (I did this three times; twice it did not, once it did). In any case, take the contents ChatGPT gave you and paste it into
test/models/task_test.rb
and then open theTask
model and write the code to pass these tests.Note that your testing suite might have the following issues (in three tries, these errors all occured at least twice):
- The test may fail because it is matching an array with an
ActiveRecord_Relation
. You can solve this by simply addingto_a
to either/bothTask.active
andTask.alphabetical
. - The test for
active
may fail because the two tasks returned are not in the expected order. In some cases, you can easily solve this by appending.sort_by(&:name)
to the end ofTask.active
. - The order of the expected objects ChatGPT gives you in
alphabetical
may not really be in alphabetical order.
Also note that this isn't an elegant solution because it creates the context in the test file rather than as a context method I can call in many files. For a tiny project like this, it's not a problem to have to re-copy these contexts in other models, but for something larger, it could be a problem. We'll discuss it more when we get to technical debt in a few weeks. (Of course, we could copy these context objects into our conext file later to solve this now if we didn't already have a context.)
Note also that it is not using Factories (of course, we didn't tell ChatGPT we had factories or using FactoryBot). Again for a simple model like
Task
with few fields, we can create it all from scratch every time, but as the model gets larger, it'd be nice to have default values to work with and make the process of stamping out new objects easier and faster.In any case, once the tests for Tast all pass and are committed, switch back and merge with
main
. - The test may fail because it is matching an array with an
-
Switch back to the
models
branch. This time before doing any testing ofChore
we are going use the Rails Console for testing. Start by accessing it in the command line with the command:rails console
-
Type the following command to use FactoryBot:
require 'factory_bot_rails'
-
We need to add the context to the development database. To do that, we first must require the context file:
require './test/contexts'
-
Next we need to include this module so we can call on the functions that build and destroy our testing objects. To do that, use the command:
include Contexts
-
Build part of the testing context by running the following two commands:
create_children create_tasks
-
Type
Child.active
and see that you get back records for Alex and Mark (but not Rachel). -
Type
@alex.name
and see that you get 'Alex Heimann'. -
Type
b = Child.new
to get an empty child object. -
Now type
b.first_name = 'Becca'
and thenb.save!
and see this fails because no last name is specified.
Add a last name and see the child object does indeed save.
Rails Console is a great way to test your models informally or to debug issues that are happening on the back end.
Note: this is not a substitute for unit testing, but it is a great way to quickly and informally figure out problems with your code and better understand the output of Rails methods.
- There are some great resources online regarding the Rails Console - below are two articles that you might want to check out later:
- Below is the entire testing file you need to copy into
chore_test.rb
. It is the most complex of the three models, so read through the file and once you understand it, run the tests to see the failures and then start writing methods to correct these errors. Note how we are using the timeliness gem to test the input value for the due_on field - you will also need this in the coming phase.
-
The chore test file:
require 'test_helper' class ChoreTest < ActiveSupport::TestCase should belong_to(:child) should belong_to(:task) should allow_value(1.day.from_now.to_date).for(:due_on) should allow_value(1.day.ago.to_date).for(:due_on) should allow_value(Date.today).for(:due_on) should_not allow_value("bad").for(:due_on) should_not allow_value(3.14159).for(:due_on) context "Creating a set of chores" do setup do create_children create_tasks create_chores end teardown do destroy_children destroy_tasks destroy_chores end should "has a scope to order alphabetically by task name" do assert_equal ["Shovel driveway","Sweep floor","Sweep floor","Sweep floor", "Wash dishes","Wash dishes","Wash dishes"], Chore.by_task.map{|c| c.task.name} end should "has a scope to order chronologically by due_on date" do assert_equal [@ac3, @ac4, @mc3, @ac1, @mc1, @ac2, @mc2], Chore.chronological.to_a end should "has a scope for pending chores" do assert_equal [@ac1, @mc1, @ac2, @mc2], Chore.pending.to_a end should "has a scope for done chores" do assert_equal [@ac3, @ac4, @mc3], Chore.done.to_a end should "has a scope for upcoming chores" do assert_equal [@ac1, @mc1, @ac2, @mc2, @ac4, @mc3], Chore.upcoming.to_a end should "has a scope for past chores" do assert_equal [@ac3], Chore.past.to_a end should "display the status of shoveling [@ac3] as 'Completed'" do assert_equal "Completed", @ac3.status end should "display the status of sweeping [@mc1] as 'Pending'" do assert_equal "Pending", @mc1.status end end end
Hint: The scope :by_task is different than other scopes you've seen and you will need to use a join because
name
is inTask
and notChore
. If you're stuck on this, ask a TA for help.Once these tests all pass, merge the code back into the
main
branch.
-
Now go back to the Child model and create a new method called
points_earned
that returns the points a child has earned for completed chores. Below is the test this method should pass and note that we didn't create tasks and chores in setup (all tests don't need them), we have to do so for this one test. (Be sure to add this totest/models/child_test.rb)
should "have a method to add points earned by a person" do create_tasks create_chores assert_equal 4, @alex.points_earned destroy_chores destroy_tasks end
Now try to use ChatGPT to generate this function for you with the following prompt:
I have a Ruby on Rails app that tracks children performing chores and awards points for completed tasks. In this app, a Child has_many :chores and has_many :tasks, through: :chores. Each Task has a name and points associated with it. When a chore is finished, the completed field in Chore is marked true. I need a Ruby method called `points_earned` that returns the points a child has earned for completed chores.
Compare the result you get with the code below. Each has strengths and weaknesses and you should take the time to see how they differ and in what ways each might be thought of as a better solution.
def points_earned self.chores.done.inject(0){|sum,chore| sum += chore.task.points} end
Run all three model tests by executing
rails test test/models
on the command line and make sure everything is passing. -
Before we go, let's check the testing coverage. To do this, go to the coverage directory of the project, open the file
index.html
in your browser, click on the models tab and view the coverage for your models. Standard procedure is to ensure 100% test coverage for all of our lines of code; when you finish, this is what you should see:
Then merge back to main and get the lab checked-off.
STOP: Show a TA that you have the unit tests for all three models passing, and have properly saved the code to git and merged back to main.
This last section can be skipped for now if you are running out of time in lab, but a good idea to go back to later in prep for phase 3.
-
Now that we have a working Chore model, go to rails console and run the
create_chores
method to populate the system with some chores. [Note: If you didn't have rails console running on a separate tab and are restarting it, you will need to (a) runChild.destroy_all
andTask.destroy_all
to clear the old records, then (b) requirefactory_girl_rails
and the context again (see above), and then (c) run all three create_ methods (children, tasks, chores) to populate the database.] -
We can now test the basic application in the browser by starting up the server with
rails server
and going to http://localhost:3000 in the browser. If there are problems, see a TA for help. -
Let's do a little clean-up of the views, starting with our default page. First, go to the
chores_controller.rb
and set@chores = Chore.chronological.by_task.all
in the index action. -
Now go to the 'chores#index' view and replace the two ids with names, format the date with
strftime('%b %d')
and replace completed with status. View the results in the browser and see that the page is a little more readable, although certainly additional edits can also be made if you desire (and have the time). -
Go into the
children_controller.rb
and list these children alphabetically. In the index view, replace the 'active' field with<%= child.active ? "Yes" : "No" %>
so we get something a little less like geekspeak. Additional edits are up to you as time allows. Make similar changes to the index view of tasks. -
Click on the
Chores
link and then click on the "New chore" link at the bottom of the page. Looking at the form we see it completely unacceptable. Most importantly, we need to replace the number boxes for ids with select menus listing active children or tasks. In the_form.html.erb
partial for chores, replace thenumber_field
forchild_id
with the following:<%= f.collection_select :child_id, Child.alphabetical.active.all, :id, :name, :prompt => "Select child..." %>
Do something similar for tasks and verify in the browser that you have menus showing the correct data. After that, add
:order => [:month, :day, :year]
to the due_on field so the order is more natural for the user. Verify that you can add a chore to the system. Looking at the show page, we see we need to correct it by (1) getting rid of the redundant notice at the top, (2) replacing ids with names, (3) make the date more user-friendly as we did in step 10, and replace the completed method with the status method. Do that and reload the page to see the changes.
STOP: Show a TA that you have the Rails app set up, it is populated with test data that you got in part from the console exercise and that the views look as they should.
I know you are working on Phase 2 feverishly, but this week also take a little time to go to RubyMonk's free Ruby Primer and complete the following brief exercises:
- Lambdas in Ruby
- Blocks in Ruby
These exercises teach powerful techniques that will help you on future phases and exams, and are applicable to practically all modern programming languages (not just Ruby).
Shoulda::ActiveRecord::Matchers [items in square brackets are options; almost all matchers have option .with_message('msg')]
- should belong_to(:model)[.class_name("ModelClassName")][.dependent(:destroy)]
- should have_one(:model)[.class_name("ModelClassName")][.dependent(:destroy)]
- should have_many(:models)[.class_name("ModelClassName")][.dependent(:destroy)]
- should have_many(:models).through(:reference)[.class_name("ModelClassName")][.dependent(:destroy)]
- should allow_value("var").for(:field)[.on(:context)]
- should_not allow_value("var").for(:field)[.on(:context)]
- should allow_mass_assignment_of(:field)[.as(:role)]
- should_not allow_mass_assignment_of(:field)[.as(:role)]
- should validate_presence_of(:field)
- should validate_absence_of(:name)
- should validate_numericality_of(:field)[.only_integer][.is_greater_than][.is_less_than]
- should validate_uniqueness_of(:field)[.case_insensitive]
- should validate_acceptance_of(:field)
- should ensure_length_of(:field)[is_at_least(num)][.is_at_most(num)][.is_equal_to(num)]
- should ensure_inclusion_of(:field)[.in_range(n..m)][.in_array(ary)]
- should ensure_exclusion_of(:field)[.in_range(n..m)][.in_array(ary)]
- should have_db_column
- should_not have_db_column
- should have_db_index
- should accept_nested_attributes_for(:model)[.allow_destroy(true/false)][.update_only(true/false)][.limit(num)]
- should have_secure_password