Author's Avatar

RODRIGO VARGAS

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. Lets 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. Lets 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 runrails 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, its 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.

Threads detail page showing the body of the thread and a section one comment

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.