More at rubyonrails.org:

1. Introduction

An e-commerce store isn't complete these days without product reviews. In this guide, we'll collect reviews for products, average the ratings, and give customers and admins a way to view and filter the reviews.

Let's get started!

2. Reviews Model

Product reviews typically consist of a 1 to 5 star rating and some text the user wrote about the product.

Let's start by creating the Review model to store this data.

$ bin/rails generate model Review product:belongs_to user:belongs_to rating:integer body:text images:attachments

This model has several attributes and associations:

  • product:belongs_to associates the Review with the Product
  • user:belongs_to associates the Review with the User who created it
  • rating is an integer that stores the 1-5 star rating
  • body stores the text description of the review
  • images stores images using ActiveStorage

2.1. Caches

Before we run this migration, let's modify it to add a couple things to the products table that we'll need.

  1. A counter cache to keep track of a product's total number of reviews
  2. A rating column to store the product's average rating

Open db/migrate/<timestamp>_create_reviews.rb and add these columns:

class CreateReviews < ActiveRecord::Migration[8.0]
  def change
    create_table :reviews do |t|
      t.belongs_to :product, null: false, foreign_key: true
      t.belongs_to :user, null: false, foreign_key: true
      t.integer :rating, null: false
      t.text :body, null: false

      t.timestamps
    end

    add_column :products, :reviews_count, :integer, default: 0
    add_column :products, :rating, :decimal, precision: 2, scale: 1, default: 0
  end
end

We used decimal as the type for rating with a couple options to control how the numbers are stored.

  • precision is the total number of digits
  • scale is the number of digits after the decimal

This means we can store up to 9.9. Two digits in total, with one digit after the decimal sign.

In the terminal, run the migrations to update the database.

$ bin/rails db:migrate
== 20260421200530 CreateReviews: migrating ====================================
-- create_table(:reviews)
   -> 0.0036s
-- add_column(:products, :reviews_count, :integer, {default: 0})
   -> 0.0005s
-- add_column(:products, :rating, :decimal, {precision: 2, scale: 1})
   -> 0.0004s
== 20260421200530 CreateReviews: migrated (0.0045s) ===========================

Next, let's update the model and validate that every review has a body and rating in app/models/review.rb:

class Review < ApplicationRecord
  belongs_to :product, counter_cache: true
  belongs_to :user
  has_many_attached :images

  validates :body, presence: true
  validates :rating, presence: true, numericality: { in: 1..5, only_integer: true }
end

The product association was created by the generator, but we need to add the counter_cache option so the reviews_count column is automatically updated.

The numericality validator ensures that ratings are integers between 1 and 5.

2.2. Associations

We also need to add associations to the Product and User models for reviews.

In app/models/user.rb, add the association:

class User < ApplicationRecord
  has_secure_password
  has_many :sessions, dependent: :destroy
  has_many :reviews, dependent: :destroy
  has_many :wishlists, dependent: :destroy

Do the same in app/models/product.rb:

class Product < ApplicationRecord
  include Notifications

  has_many :reviews, dependent: :destroy
  has_many :subscribers, dependent: :destroy

Now we're ready to start collecting product reviews!

3. Collecting Reviews

On the product's show page, we can ask customers to write a review and display all the current reviews. Let's start by letting users create reviews.

3.1. Public Product Reviews Routes

Let's create the routes for product reviews first. Since this is public-facing, we only need the new and create actions.

In config/routes.rb, add the following inside the resources :products block:

  resources :products do
    resource :wishlist, only: [ :create ], module: :products
    resources :reviews, only: [ :new, :create ], module: :products
    resources :subscribers, only: [ :create ]

3.2. Product Reviews Partial

Next, let's create a partial for the reviews to be rendered on the product show page.

Create app/views/products/_reviews.html.erb with the following:

<section class="reviews">
  <%= link_to "Write a review", new_product_review_path(product) %>
</section>

At the bottom of app/views/products/show.html.erb, we can render the partial:

<%= render "reviews", product: @product %>

We're passing in the product as context here so the partial knows which product to render reviews for.

3.3. Add the Reviews Controller

To render the form, we need to create the controller.

Create app/controllers/products/reviews_controller.rb with the following:

class Products::ReviewsController < ApplicationController
  before_action :set_product

  def new
    @review = Review.new
  end

  private

  def set_product
    @product = Product.find(params[:product_id])
  end
end

This controller uses the nested route to look up the Product on each request.

3.4. New Review Form

Next, let's add the view for collecting reviews.

Create app/views/products/reviews/new.html.erb with the following:

<h1>Add a review</h1>

<%= form_with model: [@product, @review] do |form| %>
  <fieldset>
    <legend>Rating</legend>
    <div class="rating">
      <% 1.upto(5).each do |i| %>
        <%= form.radio_button :rating, i, required: true, class: "sr-only" %>
        <%= form.label :rating, value: i do %>
          <span aria-hidden="true"></span>
          <span class="sr-only"><%= pluralize(i, "star") %></span>
        <% end %>
      <% end %>
    </div>
  </fieldset>

  <div>
    <%= form.label :body, style: "display: block;" %>
    <%= form.textarea :body, required: true %>
  </div>

  <div>
    <%= form.label :images, style: "display: block;" %>
    <%= form.file_field :images, multiple: true, accept: "image/*", capture: "environment" %>
  </div>

  <%= form.submit %>
<% end %>

We're using a loop to create 5 radio buttons for the different ratings a user could choose.

For image uploads, we're using a file field with a couple attributes that do the following:

  • multiple: true tells the browser to allow multiple file uploads
  • accept: "image/*" is a browser hint that filters the file selector to only images
  • capture: "environment" is used by mobile browsers to enable the camera using the outward-facing camera

3.5. Styling the Star Rating Input

The stars should be gray by default and turn gold when you select a rating. We can use a few CSS tricks to make this work like you'd expect.

Add the following to app/assets/stylesheets/application.css at the bottom:

/* Remove default styling for fieldset */
fieldset {
  border: 0;
  padding: 0;
  margin: 0;
}

/* Remove default styling for legend */
legend {
  padding: 0;
}

/* Hide text visually but not from screen readers */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.rating {
  display: flex;
}

/* Mute all stars by default */
.rating label {
  color: lightgray;
}

/* Highlight the selected/focused/hovered star and all previous ones */
.rating input:is(:checked, :focus) + label,
.rating label:has(~ input:is(:checked, :focus)),
.rating label:hover,
.rating label:has(~ input + label:hover) {
  color: gold;
}

/* Outline visible focused inputs */
.rating input:focus-visible + label {
  outline: .2em solid;
  outline-offset: 2px;
}

There are a few things going on here:

  1. The ratings display as stars, but screen reader users hear clear indications like Rating, 1 star, radio button, 1 of 5
  2. The radio buttons are hidden but keyboard accessible
  3. When a radio is checked, focused or hovered, the star labels before it are colored gold

CSS allows us to select previous siblings using :has(~ input:checked) and :has(~ input + label:hover). This selector targets labels for all the lower stars when the user selects a rating. If they choose 3 stars, the selector colors the stars 1, 2, and 3 with gold.

3.6. Creating Reviews

Next, we need to save reviews in the database when the form is submitted.

Add the create action to the controller:

class Products::ReviewsController < ApplicationController
  before_action :set_product

  def new
    @review = Review.new
  end

  def create
    @review = @product.reviews.new(review_params)
    if @review.save
      redirect_to @product, notice: "Review was created successfully."
    else
      render :new, status: :unprocessable_content
    end
  end

  private

  def set_product
    @product = Product.find(params[:product_id])
  end

  def review_params
    params.expect(review: [ :rating, :body, images: [] ]).with_defaults(user: Current.user)
  end
end

Since we used the Rails authentication generator, this controller is only accessible to authenticated users. This lets us associate the review with a User automatically by merging it in review_params to ensure the association is always set.

4. Displaying Product Reviews

We need to a way to view these product reviews next. Let's use a two-column layout that shows an overview of ratings on the left and the reviews on the right.

4.1. Rendering Reviews

First, let's query the reviews in app/controllers/products_controller.rb:

class ProductsController < ApplicationController
  allow_unauthenticated_access

  def index
    @products = Product.all
  end

  def show
    @product = Product.find(params[:id])
    @reviews = @product.reviews.with_attached_images
  end
end

with_attached_images allows us to preload all the associated ActiveStorage images for these reviews to avoid N+1 queries.

Let's update app/views/products/show.html.erb to also pass along @reviews to the partial so it knows which reviews to render.

<%= render "reviews", product: @product, reviews: @reviews %>

4.2. Calculating Rating Percentages

Let's add a method to calculate the percentage of reviews at a specific rating. Given a rating of 4, this will return the percentage of reviews with a 4 star rating.

Add the following to app/models/product.rb:

class Product < ApplicationRecord
  # ...

  def rating_percentage(rating)
    (reviews.where(rating: rating).count.to_f / reviews_count * 100).round
  end
end

We can use this to display each rating and their percentage of reviews with that rating.

4.3. Product Reviews Layout

Let's update app/views/products/_reviews.html.erb to display that information alongside the reviews.

<section class="reviews">
  <aside>
    <h3>Reviews</h3>

    <% if product.reviews_count > 0 %>
      <div role="img" aria-label="<%= product.rating.round %> out of 5 stars">
        <% 5.times do |i| %>
          <%= tag.span "★", class: (i < product.rating.round ? "gold" : "gray"), aria: { hidden: true } %>
        <% end %>
        <%= product.rating %> out of 5
      </div>

      <div><%= pluralize product.reviews_count, "review" %></div>

      <div>
        <% 5.downto(1).each do |i| %>
          <%= link_to product_path(product, rating: i), class: "review__summary", aria: { label: "#{pluralize(i, "star")}#{product.rating_percentage(i)}% of reviews" } do %>
            <div aria-hidden="true">
              <div class="review__stars"><%= i %></div>
              <div class="gold"></div>
              <div class="review__bars">
                <div class="review__bar--background"></div>
                <div class="review__bar" style="width: <%= product.rating_percentage(i) %>%;"></div>
              </div>
              <div class="review__percentage"><%= product.rating_percentage(i) %>%</div>
            </div>
          <% end %>
        <% end %>
      </div>
    <% else %>
      <p>None yet!</p>
    <% end %>

    <%= link_to "Write a review", new_product_review_path(product) %>
  </aside>

  <div>
    <%= render reviews %>
  </div>
</section>

For the review bars, we use a div to display a gray background. Then we overlay a gold bar with an inline style with the width set to the percentage of reviews with that rating.

Let's add the CSS for this to get the two-column layout and styling for the ratings in the sidebar.

Add the following to app/assets/stylesheets/application.css:

section.reviews {
  display: grid;
  grid-template-columns: 250px 1fr;
  margin-top: 2rem;
  gap: 2rem;
}

.review__summary {
  display: flex;
  align-items: center;
  margin-top: 0.25rem;
}

.review__stars {
  width: 1rem;
}

.review__bars {
  position: relative;
  flex: 1;
  display: flex;
  margin: 0px 5px;
}
.review__bar--background {
  background:#eee;
  border-radius: 15px;
  flex: 1;
  height: 12px;
}
.review__bar {
  background: gold;
  border-radius: 15px;
  position: absolute;
  inset-block: 0;
}

.review__percentage {
  text-align: right;
  width: 2.5rem;
}

.review {
  padding: 1rem;
}

.review__images {
  margin-top: 1em;
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
}

4.4. Reviews Partial

Let's add app/views/reviews/_review.html.erb so the individual reviews can be displayed.

<%= tag.div id: dom_id(review), class: "review" do %>
  <div><%= tag.strong review.user.full_name %></div>
  <div role="img" aria-label="<%= review.rating %> out of 5 stars">
    <% 5.times do |i| %>
      <%= tag.span "★", class: (i < review.rating ? "gold" : "gray"), aria: { hidden: true } %>
    <% end %>
  </div>

  <%= review.body %>

  <% if review.images.attached? %>
    <div class="review__images">
      <% review.images.each do |image| %>
        <%= link_to image, target: :_blank, title: "View full-size image (opens in new tab)", aria: { label: "View full-size image (opens in new tab)" } do %>
          <%= image_tag image.variant(resize_to_limit: [150, 150]), alt: "" %>
        <% end %>
      <% end %>
    </div>
  <% end %>
<% end %>

Under the review's author name, the loop counts from 1 to 5 and adds stars that are marked gray or gold if the number is within the rating for this review.

4.5. Updating the Average Rating Cache

You may have noticed the product's rating is always 0.0 out of 5.

When reviews are created, we save the rating but we don't actually update the average rating on the Product.

To solve this, we can add a callback to the Review model to update the associated Product rating. This works very similarly to a counter cache, but instead we're calcuating an average.

Let's add the callback to app/models/review.rb:

class Review < ApplicationRecord
  belongs_to :product, counter_cache: true
  belongs_to :user
  has_many_attached :images

  validates :body, presence: true
  validates :rating, presence: true, numericality: { in: 1..5, only_integer: true }

  after_commit :update_product_rating

  def update_product_rating
    product.update_column(:rating, product.reviews.average(:rating)&.round(1))
  end
end

We're using after_commit so this runs after a record has been created, updated, or destroyed so the average is always up-to-date.

Using update_column bypasses validations, callbacks, and timestamp updates on the Product so we can make a fast update to the record without triggering additional changes.

Try this out by opening the Rails console and calling this method on a review.

Loading development environment (Rails 8.2.0)
store(dev)> Review.last.update_product_rating
  Review Load (0.1ms)  SELECT "reviews".* FROM "reviews" ORDER BY "reviews"."id" DESC LIMIT 1 /*application='Store'*/
  Product Load (0.0ms)  SELECT "products".* FROM "products" WHERE "products"."id" = 1 LIMIT 1 /*application='Store'*/
  Review Average (0.1ms)  SELECT AVG("reviews"."rating") FROM "reviews" WHERE "reviews"."product_id" = 1 /*application='Store'*/
  Product Update (0.1ms)  UPDATE "products" SET "rating" = 4.0 WHERE "products"."id" = 1 /*application='Store'*/
=> true

The logs show it used SQL to calculate the average rating for the product and updated the product's rating column with the value.

5. Filtering reviews

It's helpful to filter reviews to find what's good and bad about a product. Our sidebar already has links to the product with a query param for filtering the rating, so let's use that in the controller to filter the reviews to the rating.

In app/models/review.rb, add the following scope:

class Review < ApplicationRecord
  belongs_to :product, counter_cache: true
  belongs_to :user
  has_many_attached :images

  scope :rated, ->(rating) { rating.present? ? where(rating: rating.to_i) : all }

  validates :body, :rating, presence: true

  after_commit :update_product_rating

  def update_product_rating
    product.update_column(:rating, product.reviews.average(:rating)&.round(1))
  end
end

This scope accepts a rating argument and filters the query to reviews matching this rating. If nil or an empty string was passed, it will return all reviews.

The scope also handles invalid ratings safely. For example, rated("foo") will call "foo".to_i which returns 0 and will return reviews with a rating of 0. Since 0 is not a valid rating, there will be no reviews returned.

Let's update app/controllers/products_controller.rb to use this new scope.

class ProductsController < ApplicationController
  allow_unauthenticated_access

  def index
    @products = Product.all
  end

  def show
    @product = Product.find(params[:id])
    @reviews = @product.reviews.with_attached_images.rated(params[:rating])
  end
end

Passing params[:rating] lets users filter reviews with a query param. There are several things that can happen:

  • If the URL contains ?rating=3, this will only return reviews with a rating of 3.
  • If this rating param is empty or missing, it will return all reviews.
  • If the rating param has an invalid value like ?rating=foo, it will return no reviews.

Let's update app/views/products/_reviews.html.erb to display the filter and a way to clear it.

<section class="reviews">
  <aside>
    <%# ... %>
  </aside>

  <div>
    <% if params[:rating] %>
      <div>
        Filtered by <%= pluralize params[:rating].to_i, "star" %>.
        <%= link_to "Clear filter", product %>
      </div>
    <% end %>

    <%= render reviews %>
  </div>
</section>

Test out filtering by clicking on a rating on the left to filter reviews. Use the "Clear filter" link to show all reviews.

6. Managing reviews

Admins need the ability to delete spam reviews, fix typos, and correct other mistakes. Let's build that next.

To start, let's add a resources route in config/routes.rb in the store namespace for reviews.

# Admins Only
namespace :store do
  resources :products
  resources :reviews
  resources :users
  resources :wishlists
  resources :subscribers
end

We can then implement the controller for this in app/controllers/store/reviews_controller.rb:

class Store::ReviewsController < Store::BaseController
  before_action :set_review, except: [ :index ]

  def index
    @reviews = Review.includes(:product, :user).with_attached_images.filter_by(params)
  end

  def show
  end

  def edit
  end

  def update
    if @review.update(review_params)
      redirect_to store_review_path(@review)
    else
      render :edit, status: :unprocessable_content
    end
  end

  def destroy
    @review.destroy
    redirect_to store_reviews_path
  end

  private

  def set_review
    @review = Review.find(params[:id])
  end

  def review_params
    params.expect(review: [ :rating, :body, images: [] ])
  end
end

To allow the index to filter by ratings, products, and users, let's add a filter_by method like we did for the Wishlist model.

Add the following to app/models/review.rb:

class Review < ApplicationRecord
  belongs_to :product, counter_cache: true
  belongs_to :user
  has_many_attached :images

  scope :rated, ->(rating) { rating.present? ? where(rating: rating.to_i) : all }

  validates :body, presence: true
  validates :rating, presence: true, numericality: { in: 1..5, only_integer: true }

  after_commit :update_product_rating

  def self.filter_by(params)
    results = rated(params[:rating])
    results = results.where(product_id: params[:product_id]) if params[:product_id].present?
    results = results.where(user_id: params[:user_id]) if params[:user_id].present?
    results
  end

  def update_product_rating
    product.update_column(:rating, product.reviews.average(:rating)&.round(1))
  end
end

To access reviews in the navigation, let's add a link to the sidebar in the layout.

Add the following to app/views/layouts/settings.html.erb:

<%= content_for :content do %>
  <section class="settings">
    <nav>
      <h4>Account Settings</h4>
      <%= link_to "Profile", settings_profile_path %>
      <%= link_to "Email", settings_email_path %>
      <%= link_to "Password", settings_password_path %>
      <%= link_to "Account", settings_user_path %>

      <% if Current.user.admin? %>
        <h4>Store Settings</h4>
        <%= link_to "Products", store_products_path %>
        <%= link_to "Reviews", store_reviews_path %>
        <%= link_to "Users", store_users_path %>
        <%= link_to "Subscribers", store_subscribers_path %>
        <%= link_to "Wishlists", store_wishlists_path %>
      <% end %>
    </nav>

    <div>
      <%= yield %>
    </div>
  </section>
<% end %>

<%= render template: "layouts/application" %>

Let's also add a link to the product's show page in app/views/store/products/show.html.erb:

<%# ... %>

<section>
  <%= link_to pluralize(@product.reviews_count, "review"), store_reviews_path(product_id: @product.id) %>
  <%= link_to pluralize(@product.wishlists_count, "wishlist"), store_wishlists_path(product_id: @product.id) %>
  <%= link_to pluralize(@product.subscribers_count, "subscriber"), store_subscribers_path(product_id: @product.id) %>
</section>

This will make it easy to jump to a product's reviews in the admin area.

6.2. Index View & Partial

Let's create the index view next in app/views/store/reviews/index.html.erb:

<h1>Reviews</h1>

<%= form_with url: store_reviews_path, method: :get do |form| %>
  <%= form.collection_select :product_id, Product.all, :id, :name, selected: params[:product_id], include_blank: "All Products" %>
  <%= form.select :rating, 5.downto(1).map{ pluralize it, "star" }, selected: params[:rating], include_blank: "All Ratings" %>
  <%= form.collection_select :user_id, User.all, :id, :full_name, selected: params[:user_id], include_blank: "All Users" %>
  <%= form.submit "Filter" %>
<% end %>

<%= render @reviews %>

Next, let's create the reviews partial in the store namespace. This will look very similar to the public version, but with some additional context since we're viewing reviews for any product.

Create app/views/store/reviews/_review.html.erb with the following:

<%= tag.div id: dom_id(review), class: "review" do %>
  <div><%= link_to review.user.full_name, store_user_path(review.user) %> reviewed <%= link_to review.product.name, store_product_path(review.product) %></div>
  <div role="img" aria-label="<%= review.rating %> out of 5 stars">
    <% 5.times do |i| %>
      <%= tag.span "★", class: (i < review.rating ? "gold" : "gray"), aria: { hidden: true } %>
    <% end %>
  </div>

  <%= review.body %>

  <% if review.images.attached? %>
    <div class="review__images">
      <% review.images.each do |image| %>
        <%= link_to image, target: :_blank, title: "View full-size image (opens in new tab)", aria: { label: "View full-size image (opens in new tab)" } do %>
          <%= image_tag image.variant(resize_to_limit: [150, 150]), alt: "" %>
        <% end %>
      <% end %>
    </div>
  <% end %>

  <div>
    <%= link_to "View review", store_review_path(review) %>
  </div>
<% end %>

6.3. Show View

Let's create the show view next in app/views/store/reviews/show.html.erb:

<%= link_to "Back to all reviews", store_reviews_path %>

<h1>Review</h1>

<%= tag.div id: dom_id(@review), class: "review" do %>
  <div><%= link_to @review.user.full_name, store_user_path(@review.user) %> reviewed <%= link_to @review.product.name, store_product_path(@review.product) %></div>
  <div>
    <% 5.times do |i| %>
      <%= tag.span "★", class: (i < @review.rating.round ? "gold" : "gray") %>
    <% end %>
  </div>

  <%= @review.body %>

  <% if @review.images.attached? %>
    <div class="review__images">
      <% @review.images.each do |image| %>
        <%= link_to image_tag(image.variant(resize_to_limit: [150, 150])), image, target: :_blank %>
      <% end %>
    </div>
  <% end %>
<% end %>

<div>
  <%= link_to "Edit", edit_store_review_path(@review) %>
  <%= button_to "Delete", store_review_path(@review), method: :delete, data: {turbo_confirm: "Are you sure?"} %>
</div>

6.4. Edit View

Last, but not least, let's create the edit view in app/views/store/reviews/edit.html.erb

<h1>Edit Review</h1>

<%= form_with model: [:store, @review] do |form| %>
  <fieldset>
    <legend>Rating</legend>
    <div class="rating">
      <% 1.upto(5).each do |i| %>
        <%= form.radio_button :rating, i, required: true, class: "sr-only" %>
        <%= form.label :rating, value: i do %>
          <span aria-hidden="true"></span>
          <span class="sr-only"><%= pluralize(i, "star") %></span>
        <% end %>
      <% end %>
    </div>
  </fieldset>

  <div>
    <%= form.label :body, style: "display: block;" %>
    <%= form.textarea :body, required: true %>
  </div>

  <div>
    <%= form.label :images, style: "display: block;" %>
    <%= form.file_field :images, multiple: true, accept: "image/*", capture: "environment" %>

    <% form.object.images.each do |image| %>
      <div>
        <%= image_tag image.variant(resize_to_limit: [150, 150]) %>
        <%= form.hidden_field :images, value: image.signed_id, multiple: true, id: nil %>
        <button onclick="this.parentElement.remove()">Remove</button>
      </div>
    <% end %>
  </div>

  <%= form.submit %>
<% end %>

By default, Rails will replace all of the existing images when a new value is assigned. To preserve existing images when editing a review, we need to create hidden fields for each image that's already uploaded. These hidden fields use the image's signed_id to reference the existing ActiveRecord object and also ensures that it wasn't tampered with. We use multiple: true so Rails generates the correct name to add the images param as an array. We also disable the id generated for this fields since it would generate duplicates and we don't need them.

With that added, store admins can now view, edit, and delete reviews as needed.

7. Testing reviews

Before we finish, we should write some tests to ensure that the functionality we just built works.

7.1. Updating Review Fixtures

Rails generated two Review test fixtures for us in test/fixtures/reviews.yml, however we need to update them to point to the tshirt product fixture.

five_star:
  product: tshirt
  user: one
  rating: 5
  body: I love this.

four_star:
  product: tshirt
  user: two
  rating: 4
  body: Great quality.

Let's also set the correct rating on the product fixtures to match their review ratings.

Update test/fixtures/products.yml with the following:

tshirt:
  name: T-Shirt
  inventory_count: 15
  rating: 4.5
  reviews_count: 2

shoes:
  name: Shoes
  inventory_count: 0
  rating: 0
  reviews_count: 0

7.2. Creating Reviews Tests

Let's start simple by writing a test to create a review in test/models/review_test.rb

require "test_helper"

class ReviewTest < ActiveSupport::TestCase
  test "create review" do
    assert_nothing_raised do
      products(:tshirt).reviews.create!(
        user: users(:one),
        rating: 5,
        body: "I love this product."
      )
    end
  end
end

Now run the tests to see it pass:

$ bin/rails test test/models/review_test.rb
Running 1 tests in a single process (parallelization threshold is 50)
Run options: --seed 42591

# Running:

.

Finished in 0.317256s, 3.1520 runs/s, 3.1520 assertions/s.
1 runs, 1 assertions, 0 failures, 0 errors, 0 skips

7.3. Testing Invalid Ratings

Ratings need to be between 1 and 5 stars. Let's add a test for that in test/models/review_test.rb too.

test "invalid rating" do
  review = products(:tshirt).reviews.create(
    rating: 0,
    user: users(:one),
    body: "Example"
  )

  refute review.valid?
  assert review.errors.has_key?(:rating)
end

For this test, we assert that the review is not valid with a 0 rating and that rating is one of the attributes with errors.

Let's run it:

$ bin/rails test test/models/review_test.rb
Running 2 tests in a single process (parallelization threshold is 50)
Run options: --seed 19665

# Running:

..

Finished in 0.323206s, 6.1880 runs/s, 9.2820 assertions/s.
2 runs, 3 assertions, 0 failures, 0 errors, 0 skips

7.4. Testing Product Average Ratings

Since reviews trigger updates on the associated product, we need to write a test for that too.

Add the following to test/models/review_test.rb:

test "updates product rating" do
  product = products(:tshirt)
  assert_equal 4.5, product.rating

  product.reviews.create!(
    rating: 3,
    user: users(:one),
    body: "Love it"
  )

  assert_equal 4, product.rating
end

Our product fixture has a rating of 4.5, so by creating a new 3-star review, we can assert that our product's new average is 4.

Run the tests:

$ bin/rails test test/models/review_test.rb
Running 3 tests in a single process (parallelization threshold is 50)
Run options: --seed 28957

# Running:

...

Finished in 0.347102s, 8.6430 runs/s, 14.4050 assertions/s.
3 runs, 5 assertions, 0 failures, 0 errors, 0 skips

7.5. rated Scope Test

Another thing we can test is the rated scope.

test "rated scope" do
  assert_equal Review.where(rating: 5), Review.rated(5)
  assert_equal Review.all, Review.rated(nil)
  assert_empty Review.rated("invalid")
end

This test ensures the results are correct for each of the cases we covered earlier:

  • 1-5 rating
  • Invalid rating
  • Nil or empty rating

Run the tests:

$ bin/rails test test/models/review_test.rb
Running 4 tests in a single process (parallelization threshold is 50)
Run options: --seed 38054

# Running:

....

Finished in 0.351257s, 11.3877 runs/s, 25.6223 assertions/s.
4 runs, 9 assertions, 0 failures, 0 errors, 0 skips

7.6. Review Creation Test

Next, let's add an integration test for a customer creating a review. We'll start by generating an integration test file.

$ bin/rails generate integration_test reviews

      invoke  test_unit
      create    test/integration/reviews_test.rb

In test/integrations/reviews_test.rb, let's add a test that submits a review as a logged-in user.

require "test_helper"

class ReviewsTest < ActionDispatch::IntegrationTest
  test "review a product" do
    product = products(:tshirt)
    sign_in_as users(:one)
    assert_difference "Review.count" do
      post product_reviews_path(product), params: { review: { rating: 3, body: "Example" } }
      assert_redirected_to product
    end
  end
end

This test logs in and submits a product review, just like a user would submit in their browser.

Run this test with the following command:

$ bin/rails test test/integration/reviews_test.rb
Running 1 tests in a single process (parallelization threshold is 50)
Run options: --seed 18242

# Running:

.

Finished in 0.642394s, 1.5567 runs/s, 6.2267 assertions/s.
1 runs, 4 assertions, 0 failures, 0 errors, 0 skips

It passes!

7.7. Filtering Reviews Tests

We also want to test filtering reviews on the frontend. Let's add a new test to the same integration test file.

require "test_helper"

class ReviewsTest < ActionDispatch::IntegrationTest
  include ActionView::RecordIdentifier

  test "review a product" do
    product = products(:tshirt)
    sign_in_as users(:one)
    assert_difference "Review.count" do
      post product_reviews_path(product), params: { review: { rating: 3, body: "Example" } }
      assert_redirected_to product
    end
  end

  test "filter product reviews" do
    get product_path(products(:tshirt), rating: 5)
    assert_response :success
    assert_dom "div", text: "Filtered by 5 stars. Clear filter"
    assert_dom "#" + dom_id(reviews(:five_star))
    assert_not_dom "#" + dom_id(reviews(:four_star))
  end
end

This test loads the product filtered to 5 star ratings. We assert several things to ensure it worked:

  • The page successfully loaded
  • The page contains the filter text and link to clear the filter
  • The page contains the 5 star review
  • The page does not contain the 4 star review

With those assertions, we can be confident that the review filter was applied correctly.

$ bin/rails test test/integration/reviews_test.rb
Running 2 tests in a single process (parallelization threshold is 50)
Run options: --seed 9516

# Running:

..

Finished in 0.720307s, 2.7766 runs/s, 11.1064 assertions/s.
2 runs, 8 assertions, 0 failures, 0 errors, 0 skips

7.8. Review Management Tests

Let's finish up by adding a couple tests to ensure that only admins can access the review management section in the admin area.

We already have an integration test file for this, so we'll add the following to test/integration/settings_test.rb

test "regular user cannot access /store/reviews" do
  sign_in_as users(:one)
  get store_reviews_path
  assert_response :redirect
  assert_equal "You aren't allowed to do that.", flash[:alert]
end

test "admin can access /store/reviews" do
  sign_in_as users(:admin)
  get store_reviews_path
  assert_response :success
end

Let's run these new tests:

$ bin/rails test test/integration/settings_test.rb
Running 8 tests in a single process (parallelization threshold is 50)
Run options: --seed 41516

# Running:

........

Finished in 0.689592s, 11.6011 runs/s, 18.8517 assertions/s.
8 runs, 13 assertions, 0 failures, 0 errors, 0 skips

Great!

We should also run the entire test suite to ensure everything passes.

$ bin/rails test
Running 40 tests in a single process (parallelization threshold is 50)
Run options: --seed 27614

# Running:

........................................

Finished in 1.865963s, 21.4367 runs/s, 55.7353 assertions/s.
40 runs, 104 assertions, 0 failures, 0 errors, 0 skips

8. Deploying to Production

Now that we're finished adding product reviews, let's deploy them to production. Commit and push your changes to the Git repository and then run:

$ bin/kamal deploy

9. What's Next

Your e-commerce store now has product reviews to help customers make more informed decisions about your products!

Here are a few ideas to build on to this:

  • Write more tests
  • Finish translating the app into another language
  • Add a carousel for product images
  • Improve the design with CSS
  • Add payments to buy products
  • Limit the number of reviews displayed on the product page

Happy building!

Return to all tutorials



Back to top