Build a forum with Rails and TDD - A thread can have replies
Hey everyone, Rodrigo here, and in this post we are continuing our forum made in Ruby on Rails using TDD. If forums were made of just threads, they would be more a list of articles, than a forum itself. So, the next feature in our rails forum will be the capability of users to create replies to threads.
A Unit test for replies
As we are using TDD, the first that we are going to do is to create a test to make sure users can read all replies of a thread, so let`s add a new test to the file forum_threads_controller_test test to assert that a user can navigate to a thread detail page and see a specific reply there:
test "a user can read replies that are associated with a thread" do
response = get forum_thread_url(@forum_thread.id)
assert_select "p", text: @reply.body
end
The new variable @reply should be created inside the method setup this way:
setup do
@forum_thread = forum_threads().first
@reply = replies().select {| reply | reply.thread_id == @forum_thread.id }.first()
end
We are assigning a value to this variable in the same we did for the threads, using a specific fixture for it, but in this case, I`m doing a tiny query just to get a reply that is associated with the thread we have already gotten before.
Talking about fixtures, we need to take a look at the fixture we create for the replies, it follows the same principle we used to populate the threads but with some tricks, let`s take a look inside the file fixtures/replies.yml:
<% ForumThread::all.each do |thread| %>
reply_<%= thread.id %>:
thread_id: <%= thread.id %>
body: <%= Faker::Lorem.paragraph %>
<% end %>
So, at the first line, we are querying for all forum threads inside the database and iterating over there. Rails tests have this very cool capability, where when you have fixtures defined, every time that you run your tests, it will automatically clean and populate your database with the values defined in the fixtures files, that way, you can use ActiveRecord to look for this data in your application.
Moving on, inside the loop we are defining an object with the prefix reply_ plus the id of threads being iterated. Inside this object, we are assigning a body generated by the Faker gem and the same thread_id we are currently iterating. We can improve this snippet of code later, but let`s keep things simple at first.
At this moment, we can run our tests and they should fail:
#Running
F
Failure:
ForumThreadsControllerTest#test_a_user_can_read_replies_that_are_associated_with_a_thread [/home/rodrigo/code/forum/test/controllers/forum_threads_controller_test.rb:25]:
Expected at least 1 element matching "p", found 0.
Expected 0 to be >= 1.
And they do 🙂
Creating a view to show the replies
Fail the test, is exactly what we need because our view is not prepared to show comments, so lets work on that. Let
s open the file app/views/forum_threads/show.html.erb and edit the code to iterate over the comments of the thread:
<div class="container">
<div class="box">
<h1><%= @thread.title %></h1>
<article><%= @thread.body %></article>
</div>
<hr />
<% @thread.replies.each do | reply | %>%
<div class="box">
<p><%= reply.body %></p>
</div>
<% end %>
</div>
Running the tests one more time, we should see a different error message:
ActionView::Template::Error: undefined method
replies' for #<ForumThread`
Rails is telling us that there are no associations between the threads and replies yet, and it makes sense given we just set up the database to every reply has a thread_id, but we didnt tell the application that. Let
s open the app/models/forum_thread.rb and fix it.
class ForumThread < ApplicationRecord
has_many :replies, :foreign_key => "thread_id"
end
When we put this expression has_many, we are already telling rails that there is an association between those two classes, and for default, it will create a property inside the thread object called replies, with the list of replies associated with the current thread. Also, we are using the option “foreign_key” to specify the column name on the database, given we tweak our column a little bit. By convention, Rails is expecting a column that resembles the name of the current class + id at the end, something like forum_thread_id, so that`s why is needed to populate this property in this case.
If we ran our tests again, they should pass:
Running:
...
Finished in 1.656263s, 1.8113 runs/s, 3.0188 assertions/s.
3 runs, 5 assertions, 0 failures, 0 errors, 0 skips
Great. We can improve things like showing the user that did a reply and when it did, but for that, we need a way to associate a given reply to the user, or its creator.
A reply can have a user
To create this association, lets begin creating a migration that will add a new field to reply called user_id, for that, just run the command
bin/rails generate migration AddUserIdToReply user:references` to generate a new migration, which should look like this:
class AddUserIdToReply < ActiveRecord::Migration[7.1]
def change
add_reference :replies, :user, null: false, foreign_key: true
end
end
And because we created many replies without a user, lets drop the database using the command
rails db:dropso we can start fresh, also, you will need to run
rails db:create,
rails db:migrate` again to have the whole structure recreated.
Moving on, we also need to tweak a bit the fixtures, so we can associate an owner to a reply in the data seed as well, let`s begin it changing the file seeds.rb.
2.times do |i|
thread = ForumThread.create(
title: Faker::Lorem.sentence,
body: Faker::Lorem.paragraph
)
5.times do |i|
password = Faker::Internet.password
user = User.new
user.email = Faker::Internet.email
user.password = password
user.password_confirmation = password
user.save!
5.times do |j|
Reply.create(
body: Faker::Lorem.paragraph,
thread_id: thread.id,
user_id: user.id
)
end
end
end
So, we adopted a strategy where we nest the creation of threads, users, and replies, where we are creating 5 replies to each user created, and create 5 users for each thread, it could be more realistic, but for test purposes I think is good enough.
Also, we need to tweak fixture files, beginning with the users we have:
<% 10.times do |n| %>
user_<%= n %>:
email: <%= Faker::Internet.email %>
encrypted_password: "secret"
<% end %>
Pretty simple right, just a simple loop with random emails generated. And for the replies fixture, we have:
<% ForumThread::all.each do |thread| %>
<% User::all.each do |user| %>
reply_<%= thread.id %>_<% user.id %>:
user_id: <%= user.id %>
thread_id: <%= thread.id %>
body: <%= Faker::Lorem.paragraph %>
<% end %>
<% end %>
Here, we are querying the database looking for created threads and users, and creating a reply for each one of them. Great, everything is prepared we can run our tests again, and we should see all tests continuing to pass. Now, we can improve the view of the thread detail by adding, two new fields, creator and created_at:
<div class="container">
<div class="box">
<h1><%= @thread.title %></h1>
<article><%= @thread.body %></article>
</div>
<hr />
<% @thread.replies.each do | reply | %>%
<div class="box">
<div>
<%= reply.user.email %> said <% reply.created_at %>
</div>
<p><%= reply.body %></p>
</div>
<% end %>
</div>
If we run our tests after adding these fields, we should see the following error:
Running:
E
Error:
ForumThreadsControllerTest#test_a_user_can_read_replies_that_are_associated_with_a_thread:
ActionView::Template::Error: undefined method `user'
And it does make sense, given we didnt tell Rails that there is an association between a reply and a user, to do it, it
s just a matter of editing the file app/models/reply.rb adding a belongs_to association:
class Reply < ApplicationRecord
belongs_to :user
end
Now, if we execute our tests we should see a green flag and if we run the application and access a thread detail page, we should see the comments there.
You can notice that the date below is in a human-readable format, I did that through the following trick. I created a custom property in replies class, like this:
include ActionView::Helpers::DateHelper
class Reply < ApplicationRecord
belongs_to :user
def created_at_for_humans
time_ago_in_words(self.created_at) + " ago"
end
end
And now, you can use the field created_at_for_humans in our rails erb template.
And that`s all for today people, next post we are going to add the capability of a user to be able to add a new reply to a thread, see you there.