Cards Against Isolation (Creating A Game)
I’m quite excited about this post. Today we’ll be installing ViewComponent and creating our first components. ViewComponent is still reasonably new and so I haven’t had an opportunity to try it yet.
The real value that I see in a ViewComponent compared to using Rails partials for reusability is the testability of component. Testing your views can be a slow process. We have created some feature specs to ensure Devise is working but that requires the test suite to load a page and then parse it looking for particular elements and strings of content. While feature tests are a powerful way to test how various pieces of your application interact, they are unquestionably slower and you want to only produce the minimum number needed to provide broad confidence. On the other hand, components support unit tests and so you can create lots of small tests of isolated features to ensure your components work in a range of scenarios without too much concern for test speed. At least, that’s the theory.
The tasks for today will be to create:
- a link on the dashboard for creating a new game;
- a new game form; and
- the beginnings of a game page.
Testing#
Before we get to play, we need to put the work in and create some tests. We may not have a game model yet but that doesn’t mean we can’t document that we expect a game to be created. The tests will fail initially but they will be our guide as we build.
Start by creating a new spec file, spec/features/create_a_game_spec.rb
:
# frozen_string_literal: true
describe "Create a game", type: :feature do
end
There are three stories I want to test:
- when a player clicks on a link on the dashboard, they’re taken to the new game form;
- if they attempt to create a game with a name that already exists, an error is thrown; and
- when they submit the new game form:
- a game is created;
- the player is the game owner;
- the player has joined the game; and
- the player was redirected to the game page.
That third story has a lot more going on. One of the downsides to feature tests is, because they make a page request, they are slow and so you want to pack the related expectations together. It’s a bit of a balancing act because, while you could test all stories together, they are independent and so a failure could exist in many, unrelated places. When we do more unit testing, you’ll notice that there is a desire to make many more tests for each expectation. Unit tests should be really fast and so you can afford to make lots of tests that stress small sections of your app.
describe "Create a game", type: :feature do
context "when the new game link on the dashboard is pressed" do
it "redirects to the new game page"
end
context "when the new game form is submitted correctly" do
it "creates the game and redirects to the game page"
end
context "when the new game name matches an existing game" do
it "shows an error message"
end
end
You could decide to either fill those tests out one at a time, knowing that Rspec will remind you that you have pending tests, or you could fill them all out now. I’m not sure it makes a lot of difference. If you find writing the tests hard, you might appreciate jumping between tests and application code. In this particular case, I’d rather get the tests out of the way first and then move onto features. Just know that, if my way wouldn’t work for you, that’s completely fine.
First up, we are going to need a player who is logged in:
describe "Create a game", type: :feature do
let(:player) { create(:player) }
before { sign_in player }
sign_in
is a helper method that does what it says on the tin. Rather than
going to the sign-in form, filling in the player details, submitting the form,
and then going to the page you want, Devise can just log the player in for you.
Anything placed inside the before
block will be run before every test so a
player will be logged in before each test.
context "when the new game link on the dashboard is pressed" do
it "redirects to the new game page" do
dashboard = Pages::Dashboards::Index.new
dashboard.load
dashboard.new_game_link.click
new_game_page = Pages::Games::New.new
expect(new_game_page).to be_displayed
end
end
Pages::Dashboards::Index
is the class we created in a previous post. Because
we are using SitePrism, we don’t need to think about the details of loading that
page anymore, we know that is correct since our other tests are passing. We
will, however, need to tell the class about the new_game_link
but that
doesn’t happen until our test complains about it.
Pages::Games::New
is a class we haven’t yet created but the name follows the
convention we have been using.
context "when the new game form is submitted correctly" do
it "creates the game and redirects to the game page" do
new_game_page = Pages::Games::New.new
new_game_page.load
new_game_page.name_field.set("Isogame")
new_game_page.create_button.click
new_game = Game.find_by(name: "Isogame")
expect(new_game).to have_attributes(
name: "Isogame",
owner_id: player.id
)
expect(new_game.players).to include player
show_game_page = Pages::Games::Show.new
expect(show_game_page).to be_displayed(id: new_game.id)
expect(show_game_page).to have_content("Isogame")
end
end
Okay, this is the big one, so let’s break it down.
new_game_page = Pages::Games::New.new
new_game_page.load
new_game_page.name_field.set("Isogame")
new_game_page.create_button.click
While the new game form doesn’t exist yet, we know that there can be multiple games being played and so there will need to be some way to tell them apart, a name. Since it is a form, it is obvious that we will need a button to submit the form.
new_game = Game.find_by(name: "Isogame")
expect(new_game).to have_attributes(
name: "Isogame",
owner_id: player.id
)
expect(new_game.players).to include player
Once again, we don’t have a game model yet but there will need to be one, it’s the only way for it to be stored in the database.
owner_id
is referring to an association. I am expecting that I will want
to create a relationship between the game and the person who created it. My
thought is that it will be up to the owner to start the game or maybe they can
stop the game early.
Similar to how owner_id
is an association to one player, players
will be an
association to many players who are participating in the game. It wouldn’t be
strictly necessary to add the owner to the player list since you could assume
that the owner is in the game. I’ve historically found, however, that it is a
lot nicer to simply call players
to get all players than to do something
wacky like:
players = [new_game.owner]
players.concat(new_game.players)
Finally:
context "when the new game name matches an existing game" do
before do
Game.create!(
name: "Isogame",
owner: player
)
end
it "shows an error message" do
new_game_page = Pages::Games::New.new
new_game_page.load
new_game_page.name_field.set("Isogame")
new_game_page.create_button.click
expect(new_game_page).to have_error_message
end
end
Since this test checks what happens if you try to make a duplicate game, the
first thing we need to do is create the game that will be duplicated. I do this
in a before
block because, for me, it’s some logical state that needs to exist
before the test can be run. There will be others who might want all the setup to
be in each test so it’s obvious what state belongs to what test. I can
appreciate that argument, but I find the test too crowded that way. I do,
however, sympathise with the idea that setting your state at the top of a large
test file means it’s hard to jump between the test and the setup. Here we are
creating the game right next to the test that is using it and so everything is
close together but my brain prefers the logical groupings.
To be perfectly honest with you, it has been so long since I’ve built a Rails form that I forgot how errors are shown. After staring at the screen for a moment, I realised they must be posted to the flash messages we’ve look at in the Devise testing. I obviously didn’t need to tell you that, but I want to keep banging the it-doesn’t-need-to-be-completely-correct-initially drum. I didn’t need to know how the error message was going to be shown to write a test that says there will be one. Given we’ve tested flash messages in another spec, it’s also possible that this code will change; it doesn’t matter, I’m just planning out my expectations.
Setting up the test helpers#
As we’ve seen in the past, we progress by running the test suite and fixing the errors one at a time.
rspec spec/features/create_a_game_spec.rb
Test error:
Failure/Error: before { sign_in player }
NoMethodError:
undefined method `sign_in'
Earlier I mentioned that sign_in
is a helper method supplied by Devise (and I
wasn’t lying). The only thing is that it isn’t available by default, we need
to include the appropriate helper file into Rspec as explained by the
Devise documentation.
This is actually quite simple and extremely similar to how we added the helpers
for FactoryBot. Create a new file, spec/support/devise.rb
:
# frozen_string_literal: true
RSpec.configure do |config|
config.include Devise::Test::IntegrationHelpers, type: :feature
end
Run your tests again to make sure you get a different error and then commit this change by itself:
rubocop
git add --intent-to-add spec/support/devise.rb
git add --patch
git commit
Include Devise test helpers in feature specs
Add a button to the dashboard#
After running the test file, you should now have the error:
Failure/Error: dashboard.new_game_link.click
NoMethodError:
undefined method `new_game_link'
# ./spec/features/create_a_game_spec.rb:13
The important takeaway from this error is it is saying that there is no method
called new_game_link
and it shows that this happened when calling
dashboard.new_game_link.click
. So, first we answer the question, “what is
dashboard
?”
The last line of the error tells us where to look:
13 dashboard.new_game_link.click
Our first test creates a new instance of Pages::Dashboards::Index.new
and
assigns it to the variable, dashboard
. Therefore, the error tells us that
Pages::Dashboards::Index
does not have something called new_game_link
.
Modify spec/pages/dashboards/index.rb
:
module Pages
module Dashboards
class Index < SitePrism::Page
set_url "/dashboards"
element :new_game_link, "a.new_game"
end
end
end
So, I’ve decided that the new game link will have a class of “new_game”. Links don’t automatically get a class, this is something we’ll add to make it easier to find in our tests.
Test error:
Failure/Error: dashboard.new_game_link.click
Capybara::ElementNotFound:
Unable to find css "a.new_game"
We’ve told the test suite that were will be a link but we haven’t actually
created it yet. We are keeping our Pages classes naming conventions in line with
Rails naming conventions so the view for Pages::Dashboards::Index
is
app/views/dashboards/index.html.erb
.
We left that file in a super boring state of affairs—nothing more than the word “Dashboard”. We used that text just so we could be certain we were looking at the dashboard and not a white (or broken) screen.
We’ll leave the “Dashboard” label there for now and add the new game link:
Dashboard
<%= link_to "Create a game", new_game_path, class: "new-game" %>
Test error:
Failure/Error: <%= link_to "Create a game", new_game_path, class: "new-game" %>
ActionView::Template::Error:
undefined local variable or method `new_game_path'
Hopefully you recall that those [something]_path
links come from a Rails
helper that hooks into the routes. We haven’t actually created a route for games
so Rails can’t find it. It’s a shame that Rails doesn’t see the _path
suffix
and give you an error like “it looks like you are trying to get the path for
a route that doesn’t exist, please define the route in config/routes.rb”.
Anyway, that’s what we’re going to do:
resources :dashboards, only: :index
resources :games, only: :new
We are also going to need routes for create
and show
but, you know the
rules by now, we only do as much as we need to for the test to pass. If more
needs to be done, we do it when the test tells us to. This ensures we haven’t
missed a test. If you can’t write code until the test tells you, the code must
be tested.
Test error:
Failure/Error: dashboard.new_game_link.click
ActionController::RoutingError:
uninitialized constant GamesController
Each route maps to a controller in app/controllers
so we need to create a new
controller at app/controllers/games_controller.rb
:
# frozen_string_literal: true
class GamesController < ApplicationController
end
Test error:
Failure/Error: dashboard.new_game_link.click
AbstractController::ActionNotFound:
The action 'new' could not be found for GamesController
Every action in your routes needs to have a method in the controller. In this
case, we want the new
action for games, so we need a new
method in the
GamesController
:
class GamesController < ApplicationController
def new; end
end
Create a template for the new game page#
Test error:
Failure/Error: dashboard.new_game_link.click
ActionController::MissingExactTemplate:
GamesController#new is missing a template for request formats: text/html
This tells us that the last step was successful. We created and clicked on a
button and now the test is being directed to the new game page, but we haven’t
created a template for that page. The convention we followed above for editing
the Dashboards::Index
page was app/views/dashboards/index.html.erb
. Since
we want to edit the new game page, we need to create
app/views/games/new.html.erb
with some extremely exciting content:
<h1>Create a new game</h1>
Test error:
Failure/Error: new_game_page = Pages::Games::New.new
NameError:
uninitialized constant Pages::Games
The last past of this test was to check that the new game page has been loaded.
This requires asking the Pages::Games::New
if it is displayed. Create the
directory spec/pages/games
and then create the class at
spec/pages/games/new.rb
:
# frozen_string_literal: true
module Pages
module Games
class New < SitePrism::Page
end
end
end
Test error:
Failure/Error: expect(new_game_page).to be_displayed
SitePrism::NoUrlMatcherForPageError
We need to set the page URL in that class:
class New < SitePrism::Page
set_url "/games/new"
end
That URL follows the routing conventions previously discussed and is also the URL you would see if you looked at the dashboard link in your browser.
This actually completes the test so you’ll get a single green dot.
Moving onto the next failing test:
Failure/Error: new_game_page.name_field.set("Isogame")
NoMethodError:
undefined method `name_field'
Before we even create the form, we can know the CSS selectors for the fields because they will follow the same Rails conventions we used when working on the Devise tests.
class New < SitePrism::Page
set_url "/games/new"
element :name_field, ".new_game input[name='game[name]']"
end
Test error:
Failure/Error: new_game_page.name_field.set("Isogame")
Capybara::ElementNotFound:
Unable to find css "input[name='game[name]']"
Time to create the form in our template, app/views/games/new.html.erb
. This is
the first form we have had to create ourselves and, while I prefer the idea of
making only 1 change per test error, we have no choice but to make 2 changes
here.
While we decide which fields will be in a form, Rails does an enormous amount of the heavy lifting. To do that, however, we need to tell it what the form is for. Since we want a form to us to create a new game, we need to pass a new game into the form. The reason this is a 2 part change is that you never put logic into your view. If we are going to create a new game, it needs to be done elsewhere and passed into the view.
In this case, we can create the game in the controller and store it to an instance variable. All instance variables defined in your controller will be automatically made available for use in your view.
Let’s start there; open app/controllers/games_controller.rb
and modify the
new
method:
def new
@game = Game.new
end
That is all that will be needed in the controller. That @game
can now be
called from our view. Rails knows the difference between a new and an existing
game and will automatically handle setting up the form to create a new record
rather than editing existing record.
Moving onto app/views/games/new.html.erb
:
<h1>Create a new game</h1>
<%= form_with model: @game, class: "new_game" do |f| %>
<%= f.text_field :name %>
<%= f.submit "Create" %>
<% end %>
It is really that simple, Rails does basically everything. I’ve added the “new_game” class just to make it a little easier to find the form on the page in our tests. While Capybara is really good at finding elements on the page, using a specific class on the form just reduces any potential for issues in the future if another form is added to the page, particularly when you consider that there are no identifying characteristics to submit buttons.
So let’s run the tests again:
Failure/Error: @game = Game.new
NameError:
uninitialized constant GamesController::Game
We’ve asked to create a new instance of the Game
class but that class doesn’t
yet exist. Since we store games in the database, like players, the Game
class
needs to extend ApplicationRecord
. Create it at app/models/game.rb
:
# frozen_string_literal: true
class Game < ApplicationRecord
validates :name, presence: true
validates :name, uniqueness: true
end
The validates
lines tell Rails to run checks on records before saving them.
presence
means “make sure this field has a value” and uniqueness
checks for
duplicate records.
Test error:
Failure/Error: @game = Game.new
ActiveRecord::StatementInvalid:
PG::UndefinedTable: ERROR: relation "games" does not exist
LINE 8: WHERE a.attrelid = '"games"'::regclass
Since games are stored in the database, we need to create a table for them. To do that, we start by creating a database migration:
rails g migration CreateGames
which creates a file like db/migrate/[date]_create_games.rb
.
Add the # frozen_string_literal: true
line to the top of that file then edit
it accordingly:
def change
create_table :games do |t|
t.string :name, null: false, index: { unique: true }
t.references :owner, null: false, foreign_key: { to_table: :players }
t.timestamps
end
add_foreign_key :games, :players, column: :owner_id
end
We are creating a unique database index on the name
column which will make it
impossible to create duplicate records. We will also want to perform this
validation in the Rails model, however, that validation is subject to race
conditions.
Before creating a new record, Rails will ask the database if another record with the same name exists. After the database responds “no”, Rails will send the request to create the record. The issue occurs when two people are trying to create a record at the same time:
- Person 1 asks to create game “Isogame”
- Person 2 asks to create game “Isogame”
- Rails (person 1), asks the database if there is already a game called “Isogame”
- The database responses to Rails (person 1) with “no”
- Rails (person 2), asks the database if there is already a game called “Isogame”
- The database responses to Rails (person 2) with “no”
- Rails (person 1), asks the database to create the new game
- The new game is created
- Rails (person 2), asks the database to create the new game
- The database refuses because person 1 already created that game
A unique index is your final line of defense.
When we reference owner, we are telling Active Record to create a column called
owner_id
and place an index on it. It is most common for this reference to
match the name of another model. In this case, we are storing the ID of the
player that created the game. Given this, it would be easier to reference
:player
but this would be confusing. If you looked at a game model and saw
that it referenced one player, you might think this is a game for one. By
calling the field “owner”, we are giving more context about this relationship
but we will need to explain to Active Record what the relationship is between
this field and our application model.
While Active Record has a convention that database columns ending in _id
are
associations to other tables, that convention doesn’t exist in the database.
Adding a foreign key constraint explains this relationship to the database and
allows it to check the data being stored. When storing a new game, the database
will make sure that any number placed in the owner_id
column matches the ID of
a player in the players table. It will also ensure that you do no delete a
player if they are associated with any games.
Run this migration file with:
rails db:migrate
Since creating a model is a side task, I’d like to create a new commit:
rubocop db/migrate app/models/game.rb
git add --intent-to-add app/models/game.rb db/migrate
git add --patch app/models/game.rb db/migrate db/schema.rb
git commit
Create Game model
Store the new game#
Now, when we run the tests again:
Failure/Error: <%= form_with model: @game do |f| %>
ActionView::Template::Error:
undefined method `games_path'
At this point, I want to acknowledge that some of these errors aren’t obvious;
there are some things that just require practice and experience. Here, the
problem is that Rails has worked out that we want to create a new game. When you
submit the form for a new record, it is sent to the create
action for that
controller. The create
URL and the index
URL are the same; the difference is
the HTTP verb. The index
path for games would be a GET
request to /games
whereas the create
path would be a POST
request to /games
.
We have created a route for the new
action, but not the create
action.
Update config/routes.rb
:
resources :games, only: %i[new create]
Test error:
Failure/Error: new_game_page.create_button.click
NoMethodError:
undefined method `create_button
Moving back to the Pages class, spec/pages/games/new.rb
:
element :name_field, ".new_game input[name='game[name]']"
element :create_button, ".new_game input[type=submit]"
Test error:
Failure/Error: new_game_page.create_button.click
AbstractController::ActionNotFound:
The action 'create' could not be found for GamesController
This error tells us that the form submission is now working and we now need to write the logic for creating the game.
Jump back into the GamesController
(app/controllers/games_controller.rb
) and
create an empty create
method:
def new
@game = Game.new
end
def create
end
Test error:
Failure/Error:
expect(new_game).to have_attributes(
name: "Isogame",
owner_id: player.id
)
expected nil to respond to :name, :owner_id with 0 arguments
These errors can be confusing when you first see them, but they are not only extremely common but, once you understand them, they give you a very clear indication of where your problem lies.
In saying that it “expected nil to respond to” messages, it is telling you that
the variable you expected to be some kind of object (a game in this case) is
actually nil
.
In debugging this, the first question to ask yourself is “why did I expect
new_game
to be a game?”
We are assigning new_game
in the line:
new_game = Game.find_by(name: "Isogame")
If we are asking Rails to get the game out of the database and instead it gave
us nil
, then either the game isn’t in the database or it was stored with a
different name. Here, of course, we haven’t actually written any code to store
the game; our create
method is still empty.
At part of our process of taking the smallest steps possible per iteration, we want to create the game using hard-coded values:
def create
Game.create!(
name: "Isogame",
owner: current_player
)
end
current_player
is a helper method provided by Devise. It provides you a method
called current_[model_name]
that returns the logged in resource. Since our
Devise “resource” is a player, current_player
is whichever player requested
the creation of a new game.
Clearly we are not going to want to keep “Isogame” in there but first we focus on getting the test passing and then we can replace “Isogame” with a dynamic value coming from the form, knowing that the test will tell us when we get it right.
Test error:
Failure/Error:
Game.create!(
name: "Isogame",
owner: current_player
)
ActiveModel::UnknownAttributeError:
unknown attribute 'owner' for Game.
When we created our migration file, we told the database that a game owner is a
player but we still haven’t told Rails so, as far as Rails is concerned, there
is no such field as owner
.
Go back to the model file (app/models/game.rb
) and tell Rails that owner is an
association to the players table:
class Game < ApplicationRecord
belongs_to :owner, class_name: "Player"
validates :name, presence: true
validates :name, uniqueness: true
end
belongs_to
means this record has only one associated record and the ID of that
association should be on this table. Specifically, a game has one owner and the
games table has a column called owner_id
.
Test error:
Failure/Error: expect(new_game.players).to include player
NoMethodError:
undefined method `players'
This means our previous test has passed but, before we move on, let’s get the game name from the form.
Rails provides the form values to controllers via the params
method. Rather
than accessing the values directly, however, Rails uses a system called
strong parameters
to ensure that your users can only save a limited set of fields when setting a
request. For example, you wouldn’t want it to be possible for someone to hijack
a form to assign themselves as the owner of someone else’s game.
Change the controller to read:
def create
Game.create!(
name: game_params[:name],
owner: current_player
)
end
private
def game_params
params.require(:game).permit(:name)
end
Here, we are saying the only parameter that players can send is “name”. When
we say require(:game)
, it tells Rails to search for the parameters nested
under a “game” namespace. You will recall our inputs have names like
game[name]
.
Putting the params logic into its own private method not only makes it reusable but also helps to isolate the logic with an easy to understand method name.
Test error:
Failure/Error: expect(new_game.players).to include player
NoMethodError:
undefined method `players'
The same error again—perfect, this means our change worked.
Now Rails is saying that games don’t have a method called “players”. This is supposed to be the list of players in a game so let’s create an association in the game model:
class Game < ApplicationRecord
belongs_to :owner, class_name: "Player"
has_and_belongs_to_many :players
validates :name, presence: true
validates :name, uniqueness: true
end
has_and_belongs_to_many
tells Rails the relationship between games and players
is many-to-many. There can be many players in a game and players can play many
games.
Test error:
Failure/Error: expect(new_game.players).to include player
ActiveRecord::StatementInvalid:
PG::UndefinedTable: ERROR: relation "games_players" does not exist
LINE 1: SELECT 1 AS one FROM "players" INNER JOIN "games_players" ON...
For the many-to-many relationship to work, there will need to be a database
table that records each time a player joins a game. The table will have a
player_id
and a game_id
. When you asked a game for its players, Rails will
look in the join table for all records for that game and it will search the
players table for the players in the game.
Creating the join table will require a new database migration:
rails g migration CreateGamesPlayers
Open the new migration (db/migrate/[date]_create_games_players.rb
):
# frozen_string_literal: true
class CreateGamesPlayers < ActiveRecord::Migration[6.0]
def change
create_table :games_players, id: false do |t|
t.references :game, null: false, foreign_key: true
t.references :player, null: false, foreign_key: true
t.timestamps
end
end
end
We set id: false
because a join table doesn’t need a sequential primary key.
Those keys are used as a way to uniquely identify a record in the table but, in
the case of a join table, we can identify a record using the combination of the
game ID and player ID.
Run and then commit the migration with:
rails db:migrate
rubocop db
git add --intent-to-add db/migrate
git add --patch db
git commit
Create games_players join table
Back to running tests:
Failure/Error: expect(new_game.players).to include player
expected #<ActiveRecord::Associations::CollectionProxy []> to include #<Player id: 2, email: "player@example.com", created_at: "2020-09-27 04:36:45", updated_at: "2020-09-27 04:36:45">
Diff:
@@ -1 +1 @@
-[#<Player id: 2, email: "player@example.com", created_at: "2020-09-27 04:36:45", updated_at: "2020-09-27 04:36:45">]
+[]
Your error might look slightly different, but it should be inherently the same. It is saying that, when it asked a game for its players, it expected to receive a collection that includes only the game owner but, instead, the collection was empty.
Let’s add the player to the game when the game is created in the controller:
def create
game = Game.create!(
name: game_params[:name],
owner: current_player
)
game.players << current_player
end
Create a template for the game page#
Test error:
Failure/Error: show_game_page = Pages::Games::Show.new
NameError:
uninitialized constant Pages::Games::Show
The game is now being created in the database and Rails is trying to show the page to the player. We need a new page class for testing showing the game.
Create spec/pages/games/show.rb
:
# frozen_string_literal: true
module Pages
module Games
class Show < SitePrism::Page
end
end
end
Test error:
Failure/Error: expect(show_game_page).to be_displayed(id: new_game.id)
SitePrism::NoUrlMatcherForPageError:
SitePrism::NoUrlMatcherForPageError
We need to tell SitePrism what the URL will be. The way Rails knows which game
to show is by looking for the ID of the game in the URL in the format
/games/[id]
. Similar to how we passed token into the Devise ResetPassword
and Confirmation
page classes, we pass that ID as a parameter in the URL:
class Show < SitePrism::Page
set_url "/games/{id}"
end
Test error:
Failure/Error: expect(show_game_page).to be_displayed(id: new_game.id)
We are expecting to see a page showing the newly created game but that is not
what is happening. This is because we haven’t told Rails to perform that
redirect and we are still on the create
page.
In the controller:
def create
game = Game.create!(
name: game_params[:name],
owner: current_player
)
game.players << current_player
redirect_to game
end
Rails can see that game
is a game that has been stored in the database and so
interprets redirect_to game
as “show the new game page”.
Test error:
Failure/Error: redirect_to game
NoMethodError:
undefined method `game_url' for #<GamesController:0x00007fcb89c0c900>
Did you mean? games_url
This is another of those obscure routing errors. Rails has been told that games
have new
and create
actions but we didn’t provide a show
action. Open
config/routes.rb
and add the new action:
resources :games, only: %i[new create show]
Test error:
Failure/Error: new_game_page.create_button.click
AbstractController::ActionNotFound:
The action 'show' could not be found for GamesController
Back in the controller, add a show
method:
def new
@game = Game.new
end
def create
...
end
def show
end
Test error:
Failure/Error: new_game_page.create_button.click
ActionController::MissingExactTemplate:
GamesController#show is missing a template for request formats: text/html
Create a template for the new action at app/views/games/show.html.erb
:
<%= @game.name %>
That basic page will do nothing more than show the name of the game; enough to prove it’s working.
Test error:
Failure/Error: <%= @game.name %>
ActionView::Template::Error:
undefined method `name' for nil:NilClass
There is that nil
error again. In this case, we are sending the name
message
to @game
but @game
is nil
. We need to set @game
in our controller:
def show
@game = Game.new
end
Test error:
Failure/Error: expect(show_game_page).to have_content("Isogame")
expected to find text "Isogame" in "Cards Against Isolation"
“Isogame” was the name we set for the game. The page is now displaying
@game.name
but the name being shown is not “Isogame”. This is obviously
because @game
is just a new game, not the game we created.
Rails gave us access to form values in create
requests using the params
method. For show
requests, it uses params
to expose permitted values in the
URL. We can find the newly created game using:
def show
@game = Game.find(params[:id])
end
Avoiding duplicates#
That should complete the “it creates the game and redirects to the game page” test. Onto “when the new game name matches an existing game”.
Test error:
Failure/Error:
game = Game.create!(
name: game_params[:name],
owner: current_player
)
ActiveRecord::RecordInvalid:
Validation failed: Name has already been taken
It’s good to see that Active Record is not allowing duplicate records but,
rather than showing an error message, the page is blowing up. The reason why
an exception is thrown is because we’re using the bang in the method name,
create!
. Both create!
and save!
will throw an exception if there is an
error while create
and save
will not. I always default to using the bang
methods so there is no chance for silent errors but, in this case, it would make
more sense to use the non-bang method and check the response (true
means the
record was saved while false
means there was an error).
Update the method to:
def create
game = Game.new(
name: game_params[:name],
owner: current_player
)
if game.save
game.players << current_player
redirect_to game
else
render "new"
end
end
Now, if there is an error, Rails will go back to the new game page rather than the show game page.
Test error:
Failure/Error: expect(new_game_page).to have_error_message
expected #<Pages::Games::New:0x00007f92f1dd4b28> to respond to `has_error_message?`
error_message
was just a label we came up with to some message on the page but
we haven’t actually defined what an error message is.
This element can be defined in spec/pages/games/new.rb
:
class New < SitePrism::Page
set_url "/games/new"
element :error_message, ".alert"
element :name_field, "input[name='game[name]']"
element :create_button, "input[type=submit]"
end
Test error:
Failure/Error: expect(new_game_page).to have_error_message
expected #<Pages::Games::New:0x00007f8738161d38 @loaded=true, @load_error=nil> to have error message
Now that our test knows where to go looking for errors, it is telling us there
are no errors. We will be showing the errors in the flash messages. When the
errors are encountered in the create
action, we need to pass the error
messages as flash messages. Formatting error messages is really outside the
main task of creating a game so we’ll do this in a separate method:
def create
game = Game.new(
name: game_params[:name],
owner: current_player
)
if game.save
game.players << current_player
redirect_to game
else
render_form_errors(game)
end
end
def show
@game = Game.find(params[:id])
end
private
def game_params
params.require(:game).permit(:name)
end
def render_form_errors(game)
error_messages = game.errors.full_messages.join(". ")
flash[:alert] = error_messages
render "new"
end
game.errors.full_messages
returns an array of errors. Since there is only one
error on this form, the array looks like:
["Name has already been taken"]
All we are doing is placing all the errors on one line separated by a full stop and space. If there were 3 errors like:
["First error", "Second error", "Third error"]
The result would be:
First error. Second error. Third error
Finishing up#
Now we need to commit all that work but it seems Rubocop is upset about one of the tests having too many lines. I generally agree that tests should be short but I have a bit more flexibility for feature specs because they can be really slow and so it can be valuable to test a few things in one test.
Open .rubocop.yml
and then find and adjust RSpec/ExampleLength
:
RSpec/ExampleLength:
Exclude:
- spec/features/**/*
Max: 10
Now commit that change:
git add --patch .rubocop.yml
git commit
Remove the Rubocop maximum lines rule for feature specs
Because feature specs can be slow, it can be helpful to test many things in each test. This increases the number of lines in the tests
Now the remaining files can be committed:
rubocop
rspec
git add --intent-to-add .
git add --patch .
git commit
Allow players to create games
If you had an issues, you can review the code.