Skip to content

Instantly share code, notes, and snippets.

@rayhamel
Last active October 4, 2019 13:41
Show Gist options
  • Save rayhamel/a900b50fcc7aeb6a22b5 to your computer and use it in GitHub Desktop.
Save rayhamel/a900b50fcc7aeb6a22b5 to your computer and use it in GitHub Desktop.
Redis, AJAX, Testing AJAX Walkthrough

This walkthrough uses our group project as an example; we will be creating an upvote/downvote system.

Redis

Install Redis and the Redis gem (in terminal).

$ brew install redis
$ ln -sfv /usr/local/opt/redis/*.plist ~/Library/LaunchAgents
$ launchctl load ~/Library/LaunchAgents/homebrew.mxcl.redis.plist
$ gem install redis

Add to Gemfile

# ...

gem 'redis'

# ...

Create a new file /config/initializers/redis.rb

We can now call all the Redis methods using the REDIS constant anywhere in the application.

Note: Make sure you specify different Redis databases locally if you're using it in more than one app!

if Rails.env.development?
  REDIS = Redis.new(host: 'localhost', port: 6379, db: 0)
elsif Rails.env.test?
  REDIS = Redis.new(host: 'localhost', port: 6379, db: 1)
else
  uri = URI.parse(ENV["REDISCLOUD_URL"])
  REDIS = Redis.new(host: uri.host, port: uri.port, password: uri.password)
end

Configure Heroku (in terminal).

Note: Obviously, this won't do anything unless you have already deployed the app to Heroku, and are logged in as the person who deployed the app.

$ heroku addons:add rediscloud:25

Add to .travis.yml

# ...

services:
- redis-server

# ...

And you're ready for action!

Let's define the logic behind how upvotes and downvotes work and how we interface with Redis. We're going to want to use this logic in our reviews controller, but we should keep it out of the controller file itself ("skinny controllers"). Instead, let's create a module Votable in /app/controllers/concerns/votable.rb. A module is a group of methods that can be included in a class.

Redis, like most other NoSQL databases, behaves like a (very fast) hash table, using key-value pairs. This means it can be significantly faster than SQL when you don't care about how two objects are related to each other. (On the other hand, SQL is far better suited to querying or modifying relationships). NoSQL databases also typically are not as strict about writing database insertions to disk before returning that they were successful, so they can be considerably quicker for writing relatively unimportant data. It can also be far less code, as we shall see.

There are a number of different datatypes a value can have in a Redis key-value pair. Here we'll be using sets, which are essentially arrays, and strings, which can be either a simple string or an integer.

I'm going to be using the following Redis commands:

String:

Redis#set(key, value)

Sets a key-value pair using the string datatype. Returns the string "OK", can overwrite other datatypes.

Redis#get(key)

Returns the string stored at the key. Returns nil if no string found, raises an exception if another datatype is stored at the key.

Set:

Redis#sadd(key, item)

Adds item to the set stored at the key. Returns true if successful, raises an exception if another datatype is stored at the key.

Redis#srem(key, item)

Removes item from the set stored at the key. Returns true if successful, false if the item or a set is not present at the key, raises an exception if another datatype is stored at the key.

Redis#scard(key)

Returns the number of items in the set stored at the key. Returns 0 if there is no set present at the key, raises an exception if another datatype is stored at the key.

I strongly suggest you read the excellent Redis docs to learn about all the Redis commands and datatypes. You can play around with Redis commands by running "rails c" in the terminal, which will open an irb or pry session with all files in the app required. When you're done playing around, Redis#flushdb deletes all entries from the database. The equivalent of "rails c" in Heroku is "heroku run console".

The docs specify the "Big-O" complexity of each command, a concept you might recall from my algorithms talk/cheatsheet. You'll note that each command we execute here has a Big-O complexity of Θ(1). In general, you should aim for your Redis commands to be completed in Θ(1) time. If not, you should consider whether it is possible to accomplish the same thing using different commands, a different Redis datatype, or whether SQL would be a better option.

The following voting logic will allow for logical "Reddit-style" upvoting and downvoting: a second click on the voting arrow will remove one's vote, downvoting after upvoting will reduce the score by 2 and vice versa (because the upvote is removed and the downvote is added), and each user can only increase or decrease the score by a net of 1. Remember that Redis#srem returns true or false if this logic confuses you.

module Votable
  extend ActiveSupport::Concern

  def send_vote(review_id, user_id, direction, opposite)
    unless REDIS.srem("review_#{direction}votes_#{review_id}", user_id)
      REDIS.sadd("review_#{direction}votes_#{review_id}", user_id)
      REDIS.srem("review_#{opposite}votes_#{review_id}", user_id)
    end

    REDIS.set(
      "review_score_#{review_id}", REDIS.scard("review_upvotes_#{review_id}") -
      REDIS.scard("review_downvotes_#{review_id}")
    )
  end
end

We also want to be able to display the score of a review. Since we want to be able to access this method from a view, we will put it in a helper module ScoreHelper in /app/helpers/score_helper.rb. Helper modules in the helpers directory are automatically included in all views. Remember that Redis#get returns nil if no value is found if this logic confuses you.

module ScoreHelper
  def score(id)
    REDIS.get("review_score_#{id}") || "0"
  end
end

Let's define some routes for upvoting and downvoting in /config/routes.rb.

Note: Custom routes aren't necessary for Redis or AJAX. However, if you're using the generic "update" route and using jQuery, you can't use $.get or $.post, you must use $.ajax and specify "method" as "PUT" or "PATCH" in the options JSON.

Rails.application.routes.draw do
  # ...

  post "reviews/:id/upvote", to: "reviews#upvote", as: "upvote"
  post "reviews/:id/downvote", to: "reviews#downvote", as: "downvote"

  # ...
end

AJAX

Now let's define controller actions that correspond to these routes, at /app/controllers/reviews_controller.rb. We're going to need to include both the modules we just defined in the class ReviewsController.

Note that we're deliberately avoiding ActiveRecord queries. That's because we want to accomplish what we're doing with no SQL queries at all!

Since we're using AJAX for this action, we need to tell Rails to respond with JSON instead of HTML. We do this by using the respond_to method and then calling #json, and then tell Rails to create the JSON we want (in this case, the review's updated score). It is possible to tell Rails to respond with still other filetypes, too (CSS, Javascript, XML, etc. etc.).

class ReviewsController < ApplicationController
  include ScoreHelper
  include Votable

  # ...

  def upvote
    vote("up", "down")
  end

  def downvote
    vote("down", "up")
  end

  private

  def vote(direction, opposite)
    send_vote(params[:id], current_user.id, direction, opposite)
    respond_to { |format| format.json { render json: score(params[:id]) } }
  end

  # ...
end

Now we can create the voting buttons in our view. Note that the links do not actually go to anything! That's because we'll be using AJAX. Instead we'll create two HTML attributes, "reviewID" and "path", that we will pass to our script.

/app/views/tutorials/_reviews.html.erb (in my group project app)

<!-- ... -->
<div class="small-1 column">
  <%= link_to (image_tag("chevron-up.png")), '#', class: "upvote",
    reviewID: "#{review.id}", path: "#{upvote_path(review)}" %>
  <h6 class="vote-score" id="review-<%= review.id %>"><%= score(review.id) %></h6>
  <%= link_to (image_tag("chevron-down.png")), '#', class: "downvote",
    reviewID: "#{review.id}", path: "#{downvote_path(review)}" %>
</div>
<!-- ... -->

Now we can write our script for the AJAX function.

Here's what this one does, line by line.

  1. After the page is fully loaded and rendered in the browser,
  2. When elements matching the CSS selector ".upvote, .downvote"* are clicked,
  3. Set the variable "reviewID" to that element's HTML attribute reviewID,
  4. Set the variable "path" to that element's HTML attribute path,
  5. Send an AJAX post request to "path", then handle the data the server sends back,
  6. Replace the element representing the score for that review with the new data,
  7. And return from the function. (This prevents snapping to the top of the screen each time the script is run).

*items with the class "upvote" or the class "downvote"

/app/assets/javascripts/voting.js

$(document).ready(function () {
    $(".upvote, .downvote").click(function () {
        var reviewID = $(this).attr("reviewID");
        var path = $(this).attr("path");
        $.post(path, function (data) {
            $("#review-" + reviewID).text(data);
        });
        return false;
    });
});

And we're done! With Redis and AJAX this is lightning-fast; the entire request can be completed in about 12ms on Heroku and 8ms locally.

Testing AJAX

Fact is, Capybara just doesn't play nice with AJAX. Instead of trying to fight Capybara, it's likely better just to write unit tests in pure Rspec.

Assuming you require authentication for your AJAX function, add this to your /spec/rails_helper.rb file, if it's not already present.

RSpec.configure do |config|
  # ...
  config.include Devise::TestHelpers, type: :controller
end

YOU NEED NOT/SHOULD NOT USE POLTERGEIST OR DATABASE CLEANER OR ANY OTHER GEM, AND YOU NEED NOT MAKE ANY OTHER CHANGES TO YOUR RSPEC CONFIGUATION!

Now that we're in Rspec, we have a few bits of functionality that are not available in Capybara, including the Devise #sign_in helper, and the ability to directly make requests to the server.

Create a new folder /spec/controllers. Our spec file will be /spec/controllers/reviews_controller_spec.rb. We need to require "reviews_controller" as well as "rails_helper", and then our tests should run inside a "describe ReviewsController, type: :controller" block (obviously, substitute "reviews" as needed).

Flush the Redis database before each test or you may get random failures, and add the "js: true" parameter to each test.

Next we can sign in as a user, and make our HTTP requests (post, in this case). The first parameter specifies the name of the path, the second is an options hash containing any params (id, in this case) and other options (in this case, that we expect the server response to be in JSON format).

Then we compare the server response to the value we expect.

This is not a full feature test, since it does not test that the Javascript we wrote works correctly, but it does check that everything works server-side.

require "rails_helper"
require "reviews_controller"

# As a user
# I want to vote on a tutorial's review
# So that I can voice my opinion on its usefulness

describe ReviewsController, type: :controller do
  let!(:review) { FactoryGirl.create(:review) }

  before(:each) do
    REDIS.flushdb
  end

  it "should upvote correctly", js: true do
    sign_in review.user
    post(:upvote, id: review.id, format: "json")
    expect(response.body).to eq "1"
    post(:upvote, id: review.id, format: "json")
    expect(response.body).to eq "0"
  end

  it "should downvote correctly", js: true do
    sign_in review.user
    post(:downvote, id: review.id, format: "json")
    expect(response.body).to eq "-1"
    post(:downvote, id: review.id, format: "json")
    expect(response.body).to eq "0"
  end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment