Back to Tutorials
Jutsu6/8/2025

Bloggy #6 - Bloggy: How to test a Grape web API (models and controllers)

automated testsgraperspecrubytestingunit test

We are getting to my favorite part: automated testing! I used to hate it before and then I had to work on a big legacy application, which luckily had tests. I was so happy to be able to refactor it without breaking anything that now, I simply love writing tests for my applications.

Just kidding, writing tests is a pain in the ass but the usefulness outweigh the pain, by a huge margin, trust me.

Let's test our API and have a lot of fun doing it! ;)

Master Ruby Web APIs

APIs are evolving. Are you prepared?

The Bloggy series

You can find the full list of jutsus for this series on this page and the jutsu before this one is available there.

Getting the code

You can get the source code to follow this tutorial from GitHub if you didn't follow the previous jutsu.

Coding Time ¯\(ツ)

Alright, time to finish Bloggy. The tests we are going to write today will be the last thing we'll do until the next series where we will build a Javascript front-end application to go with Bloggy. When that happens, we will probably tweak a few things and add authentication to the admin controller.

But for now, tests!

Writing tests is an art and most people have their own style. You can write tests in a huge number of ways. My main rules when I write tests is to try to keep them as simple as possible and keep the amount of expectations (asserts) to the minimum: 1. I also like to keep tests isolated and I don't hesitate to create fake classes and stub (not too much though) for that purpose.

Also, I tend to follow the best practices described there.

If you have any comments about my testing style, I'd be happy to hear them. Don't forget to be a civilized person so we can have a nice conversation. ;)

1. Setting up testing environment

We already setup our testing environment in the jutsu 17, when we were doing TDD for the Yumi library. If you didn't follow that jutsu, head over there, follow the first part where we setup the testing environment and come back here.

Done? Good, let's go!

2. Writing Rspec model tests

First, we are going to write model tests for posts, tags and comments. Our models are not super complicated so we're just going to test that they have a valid factory and that they follow the validation rules we defined.

2.0 Rspec introduction

If you don't know anything about Rspec, here is a quick introduction. Skip it if you've already used it in the past.

Rspec allows you to write assertions to ensure that the given input is equal to the expected output.

def multiply_by_2(a)
  a * 2
end

it 'returns 6 when param is 3' do
  expect(multiply_by_2(3)).to eq(6)
end

Simple right? Don't worry the first tests in this tutorial are pretty easy too.

Before continuing, let's go over some important vocabulary:

  • describe: Define what we are testing. It can be a class or a string.

  • context: Allow us to group our tests in a logical way. For example, in the previous test, we could make a context from 'when 3' (but when integer would make more sense) like this:

context 'when param is 3' do
  it 'returns 6' do
    expect(multiply_by_2(3)).to eq(6)
  end
end
  • it: Define a test. Give it a name and a block to run.
  • expect: Similar to assert in other testing frameworks/languages. Will make the test fail if we don't get what we're expecting.

I hope this short introduction showed you enough to understand all the tests we are going to write now ;)

2.1 Post Tests

Before we test our models, we want to be sure that the factories we made in the jutsu 17 are good.

Testing that a model has a valid factory is pretty straight-forward. We just build it and use the neat be_valid provided by Rspec.

We end up with something like this:

it 'has a valid factory' do
  expect(build(:post)).to be_valid
end

And here is a reminder of how our factory looks like:

factory :post do
  slug 'my-slug'
  title 'My Title'
  content 'Some Random Content.'
end

Obviously, it's valid because we provide the slug and title. Next we are going to build the validation tests.

Here is the test that checks if the post is invalid without a slug:

it 'is invalid without a slug' do
  expect(build(:post, slug: nil)).to_not be_valid
end

See how we use FactoryGirl build method to build a post and pass a hash as a second parameter? Well this hash will override values in our default factory. Instead of creating a new factory, I like to use this feature because it makes the test very easy to read.

We use build and not create here because we don't actually need to save the record in the database. Using build makes the tests run much faster because there are no communications with the database.

Here is the full testing file for post. Create the folder spec/models and the file spec/models/post_spec.rb before putting the following content in it. Read the whole content to get a good understanding of what we're doing too!

# spec/models/post_spec.rb
require 'spec_helper'

describe Post do

  it 'has a valid factory' do
    expect(build(:post)).to be_valid
  end

  describe 'validations' do

    it 'is invalid without a slug' do
      expect(build(:post, slug: nil)).to_not be_valid
    end

    it 'is invalid without a title' do
      expect(build(:post, title: nil)).to_not be_valid
    end

    it 'is invalid with a duplicated slug' do
      create(:post)
      expect(build(:post)).to_not be_valid
    end

  end

end

Run the specs with rspec spec/models/post_spec.rb to see if everything works:

Output:

Finished in 0.06814 seconds (files took 1.05 seconds to load)
4 examples, 0 failures

2.2 Tag Tests

The Tag tests are pretty similar to the ones we just wrote. Create the file spec/models/tag_spec.rb and put the following inside:

# spec/models/tag_spec.rb
require 'spec_helper'

describe Tag do

  it 'has a valid factory' do
    expect(build(:tag)).to be_valid
  end

  describe 'validations' do

    it 'is invalid without a slug' do
      expect(build(:tag, slug: nil)).to_not be_valid
    end

    it 'is invalid without a name' do
      expect(build(:tag, name: nil)).to_not be_valid
    end

    it 'is invalid with a duplicated slug in the same post' do
      post = build(:post)
      create(:tag, post: post)
      expect(build(:tag, post: post)).to_not be_valid
    end

  end

end

Run the specs with rspec spec/models/tag_spec.rb:

Output:

Finished in 0.11031 seconds (files took 1.43 seconds to load)
4 examples, 0 failures

2.3 Comment Tests

And finally the Comment tests goes into spec/models/comment_spec.rb:

# spec/models/comment_spec.rb
require 'spec_helper'

describe Comment do

  it 'has a valid factory' do
    expect(build(:comment)).to be_valid
  end

  describe 'validations' do

    it 'is invalid without an author' do
      expect(build(:comment, author: nil)).to_not be_valid
    end

    it 'is invalid without a content' do
      expect(build(:comment, content: nil)).to_not be_valid
    end

  end

end

Run the specs with rspec spec/models/comment_spec.rb to see if everything works:

Output:

Finished in 0.06424 seconds (files took 1.13 seconds to load)
3 examples, 0 failures

Alright, we just wrote tests for our Mongoid models, but that was the easy part.

3. Writing Rspec request tests

Now we are going to write tests for our controllers by making requests to our endpoints and see if the response match what we were expecting. Sounds like fun, right?

3.1 The Root Specs

First, we're going to write tests for the API::Root class which only contains one endpoint (/status) and mount all our other controllers. We will be testing 2 things:

  • Our /status endpoint works as expected when using the correct media type
  • The API cannot be accessed with an unsupported media type

Since we are testing a rack application, we need to include Rack-Test helper methods and create a method named app that returns our application.

The code to do this is:

def app
  OUTER_APP
end

It works with the OUTER_APP constant that we defined in the spec/spec_helper.rb file.

Currently, we are not requiring the rack-test gem anywhere so let's open the spec/spec_helper.rb file and add it there.

# spec/spec_helper.rb

# We need to set the environment to test
ENV['RACK_ENV'] = 'test'

require 'ostruct'
require 'factory_girl'
require 'mongoid_cleaner'

# Add this!
require 'rack/test'

# ...

Alright, let's create the folders spec/requests and spec/requests/api, and the file root_spec.rb inside. Here is the content of this file. I put comments to explain everything so don't just copy/paste it, read it ;)

# spec/requests/api/root_spec.rb
require 'spec_helper'

# Specify which class we want to test
describe API::Root do
  # Rack-Test helper methods like get, post, etc
  include Rack::Test::Methods

  # required app method for Rack-Test
  def app
    OUTER_APP
  end

  # We are going to specifically test the /status
  # endpoint so we use describe here
  describe 'GET /status' do

    # We define contexts depending on the media type
    # in this case, we will use the media type application/json
    context 'media-type: application/json' do

      # This will be called every time before each following test
      before do
        # Set the header to application/json
        header 'Content-Type', 'application/json'
        # Make the actual request to /api/status using GET
        get '/api/status'
      end

      # Define our first test. Since we're using a media type
      # not supported, we expect 415
      it 'returns HTTP status 415' do
        expect(last_response.status).to eq 415
      end

      # The endpoint should also returns a JSON document
      # containing the error 'Unsupported media type'
      it 'returns Unsupported media type' do
        expect(JSON.parse(last_response.body)).to eq(
          {"error"=>"Unsupported media type"})
      end

    end

    # For this context, we use the correct media type
    context 'media-type: application/vnd.api+json' do

      # We use a different approach here. See below for explanation.
      # Basically we have our two asserts in the same test
      # I kinda prefer the first approach but wanted to show you both
      it 'returns 200 and status ok' do
        header 'Content-Type', 'application/vnd.api+json'
        get '/api/status'
        expect(last_response.status).to eq 200
        expect(JSON.parse(last_response.body)).to eq(
          { 'status' => 'ok' })
      end

    end

  end

end

One thing to note in this test file. In the first context, I used only one assert per test while in the second context, I just one test with 2 asserts. I just wanted to show you the difference, there are pros and cons for each:

First approach (1 assert per test):

  • You're only testing one thing. If your test start to fail, you can easily identify the failing test.
  • It will take longer to run and if you have a huge set of tests, it will take your tests suite longer to run.

Second approach (2 asserts in one test):

  • Faster to run for huge tests suite.
  • You have the whole request tested in one test.
  • Harder to identify why a test is failing
  • Does not follow best practices

One could argue that those asserts test the same thing because we are just testing the response. However, when building APIs that follow the hypermedia path, it's important to not limit yourself to the response content. The headers are quite important and deserve their own tests.

For the rest of this jutsu, I use the first approach because I find it simpler to read and because Bloggy is not a huge application with a lot of tests :)

Alright, so we are done writing the API::Root tests. Let's continue our journey with the Posts controller tests!

3.2 The Posts specs

Create the folder spec/requests/api/v1 and add the file spec/requests/api/v1/posts_spec.rb inside. Here is the content for this file. Copy/paste it if you want but then READ IT because I put a bunch of comments to explain everything.

# spec/requests/api/v1/posts_spec.rb
require 'spec_helper'

describe API::V1::Posts do
  include Rack::Test::Methods

  def app
    OUTER_APP
  end

  # Define a few let variables to use in our tests
  let(:url) { 'http://example.org:80/api/v1' }
  # We need to create a post because it needs to be in the database
  # to allow the controller to access it
  let(:post_object) { create(:post) }

  before do
    header 'Content-Type', 'application/vnd.api+json'
  end

  # Tests for the endpoint /api/v1/posts
  describe 'get /' do

    it 'returns HTTP status 200' do
      get '/api/v1/posts'
      expect(last_response.status).to eq 200
    end

    # In this describe, we split the testing of each part
    # of the JSON document. Like this, if one fails we'll know which part
    # is not working properly
    describe 'top level' do

      before do
        post_object
        get '/api/v1/posts'
      end

      it 'contains the meta object' do
        expect(json['meta']).to eq({
          'name' => 'Bloggy',
          'description' => 'A simple blogging API built with Grape.'
        })
      end

      it 'contains the self link' do
        expect(json['links']).to eq({
          'self' => "#{url}/posts"
        })
      end

      # I got lazy and didn't put the whole JSON document I'm expected,
      # instead I used the presenter to generate it.
      # It's not the best way to do this obviously.
      it 'contains the data object' do
        expect(json['data']).to eq(
          [to_json(Presenters::Post.new(url, post_object).as_json_api[:data])]
        )
      end

      it 'contains the included object' do
        expect(json['included']).to eq([])
      end

    end

    # I want to test the relationships separately
    # because they require more setup and deserve their own tests
    describe 'relationships' do

      # We need to create some related models first
      let(:tag) { create(:tag, post: post_object) }
      let(:comment) { create(:comment, post: post_object) }

      # To avoid duplicated hash, I just use a method
      # that takes a few parameters and build the hash we want
      # Could probably use shared examples instead of this but I find
      # it easier to understand
      def relationship(url, type, post_id, id)
        {
          "data" => [{"type"=> type , "id"=> id }],
          "links"=> {
            "self" => "#{url}/posts/#{post_id}/relationships/#{type}",
            "related" => "#{url}/posts/#{post_id}/#{type}"
          }
        }
      end

      # We need to call our let variables to define them
      # before the controller uses the presenter to generate
      # the JSON document
      before do
        tag
        comment
        get '/api/v1/posts'
      end

      # The following tests check that the relationships are correct
      # and that the included array is equal to the number of related
      # objects we created
      it 'contains the tag relationship' do
        id = tag.id.to_s
        expect(json['data'][0]['relationships']['tags']).to eq(
          relationship(url, 'tags', post_object.id, id)
        )
      end

      it 'contains the comment relationship' do
        id = comment.id.to_s
        expect(json['data'][0]['relationships']['comments']).to eq(
          relationship(url, 'comments', post_object.id, id)
        )
      end

      it 'includes the tag and comment in the included array' do
        expect(json['included'].count).to eq(2)
      end

    end

  end

  # Tests for the endpoint /api/v1/posts/1234567890
  describe 'get /:id' do

    # The post object is created before the request
    # since we use it to build the url
    before do
      get "/api/v1/posts/#{post_object.id}"
    end

    it 'returns HTTP status 200' do
      expect(last_response.status).to eq 200
    end

    # Repeat the same kind of tests than we defined for
    # the index route. Could totally be in shared examples
    # but that will be for another jutsu
    describe 'top level' do

      it 'contains the meta object' do
        expect(json['meta']).to eq({
          'name' => 'Bloggy',
          'description' => 'A simple blogging API built with Grape.'
        })
      end

      it 'contains the self link' do
        expect(json['links']).to eq({
          'self' => "#{url}/posts/#{post_object.id}"
        })
      end

      it 'contains the data object' do
        expect(json['data']).to eq(to_json(Presenters::Post.new(url, post_object).as_json_api[:data]))
      end

      it 'contains the included object' do
        expect(json['included']).to eq([])
      end

    end

    describe 'relationships' do

      let(:tag) { create(:tag, post: post_object) }
      let(:comment) { create(:comment, post: post_object) }

      def relationship(url, type, post_id, id)
        {
          "data" => [{"type"=> type , "id"=> id }],
          "links"=> {
            "self" => "#{url}/posts/#{post_id}/relationships/#{type}",
            "related" => "#{url}/posts/#{post_id}/#{type}"
          }
        }
      end

      before do
        tag
        comment
        get '/api/v1/posts'
      end

      it 'contains the tag relationship' do
        id = tag.id.to_s
        expect(json['data'][0]['relationships']['tags']).to eq(
          relationship(url, 'tags', post_object.id, id)
        )
      end

      it 'contains the comment relationship' do
        id = comment.id.to_s
        expect(json['data'][0]['relationships']['comments']).to eq(
          relationship(url, 'comments', post_object.id, id)
        )
      end

      it 'includes the tag and comment in the included array' do
        expect(json['included'].count).to eq(2)
      end

    end

  end

end

That's it for the Posts controller tests. Now let's see what we got for the Comments controller.

3.3 The Comments Specs

You're probably an expert tester by now! The following tests follow the same logic that we've seen before so it shouldn't be too complicated.

Create the file spec/requests/api/v1/comments_spec.rb and put the following code in it. Once again, the code is commented.

# spec/requests/api/v1/posts_spec.rb
require 'spec_helper'

describe API::V1::Comments do
  include Rack::Test::Methods

  def app
    OUTER_APP
  end

  let(:url) { BASE_URL }
  let(:post_object) { create(:post) }

  # We need to define a set of correct attributes to create comments
  let(:attributes) do
    {
      author: 'Tibo',
      email: 'thibault@example.com',
      website: 'devmystify.com',
      content: 'Super Comment.'
    }
  end

  # And valid_params that use the previous attributes and
  # add the JSON API spec enveloppe
  let(:valid_params) do
    {
      data: {
        type: 'comments',
        attributes: attributes
      }
    }
  end

  # We also need an invalid set of params to test
  # that Grape validates correctly
  let(:invalid_params) do
    {
      data: {}
    }
  end

  before do
    header 'Content-Type', 'application/vnd.api+json'
  end

  describe 'POST /posts/:post_id/comments' do

    # We use contexts here to separate our requests that
    # have valid parameters vs the ones that have invalid parameters
    context 'with valid attributes' do

      # Now we're using post and not get to make our requests.
      # We also pass the parameters we want
      it 'returns HTTP status 201 - Created' do
        post "/api/v1/posts/#{post_object.id}/comments", valid_params.to_json
        expect(last_response.status).to eq 201
      end

      # After the request, we check in the database that our comment
      # was persisted
      it 'creates the resource' do
        post "/api/v1/posts/#{post_object.id}/comments", valid_params.to_json
        comment = post_object.reload.comments.find(json['data']['id'])
        expect(comment).to_not eq nil
      end

      # Here we check that all the attributes were correctly assigned during
      # the creation. We could split this into different tests but I got lazy.
      it 'creates the resource with the specified attributes' do
        post "/api/v1/posts/#{post_object.id}/comments", valid_params.to_json
        comment = post_object.reload.comments.find(json['data']['id'])
        expect(comment.author).to eq attributes[:author]
        expect(comment.email).to eq attributes[:email]
        expect(comment.website).to eq attributes[:website]
        expect(comment.content).to eq attributes[:content]
      end

      # Here we check that the endpoint returns what we want, in a format
      # that follows the JSON API specification
      it 'returns the appropriate JSON document' do
        post "/api/v1/posts/#{post_object.id}/comments", valid_params.to_json
        id = post_object.reload.comments.first.id
        expect(json['data']).to eq({
          'type' => 'comments',
          'id' => id.to_s,
          'attributes' => {
            'author' => 'Tibo',
            'email' => 'thibault@example.com',
            'website' => 'devmystify.com',
            'content' => 'Super Comment.'
          },
          'links' => { 'self' => "#{BASE_URL}/comments/#{id}" },
          'relationships' => {}
        })
      end

    end

    # What happens when we send invalid attributes?
    context 'with invalid attributes' do

      # Grape should catch it and return 400!
      it 'returns HTTP status 400 - Bad Request' do
        post "/api/v1/posts/#{post_object.id}/comments", invalid_params.to_json
        expect(last_response.status).to eq 400
      end

    end

  end

  # Let's try to update stuff now!
  describe 'PATCH /posts/:post_id/comments/:id' do

    # We make a comment, that's the one we will be updating
    let(:comment) { create(:comment, post: post_object) }

    # What we want to change in our comment
    let(:attributes) do
      {
        author: 'Tibo',
        content: 'My bad.'
      }
    end

    # Once again, separate valid parameters and invalid parameters
    # with contexts. The tests don't have anything new compared to
    # what we wrote for the creation tests.
    context 'with valid attributes' do

      it 'returns HTTP status 200 - OK' do
        patch "/api/v1/posts/#{post_object.id}/comments/#{comment.id}", valid_params.to_json
        expect(last_response.status).to eq 200
      end

      it 'updates the resource author and content' do
        patch "/api/v1/posts/#{post_object.id}/comments/#{comment.id}", valid_params.to_json
        expect(comment.reload.author).to eq 'Tibo'
        expect(comment.reload.content).to eq 'My bad.'
      end

      it 'returns the appropriate JSON document' do
        patch "/api/v1/posts/#{post_object.id}/comments/#{comment.id}", valid_params.to_json
        id = comment.id
        expect(json['data']).to eq({
          'type' => 'comments',
          'id' => id.to_s,
          'attributes' => {
            'author' => 'Tibo',
            'email' => 'thibault@example.com',
            'website' => 'devmystify.com',
            'content' => 'My bad.'
          },
          'links' => { 'self' => "#{BASE_URL}/comments/#{id}" },
          'relationships' => {}
        })
      end

    end

    context 'with invalid attributes' do

      it 'returns HTTP status 400 - Bad Request' do
        patch "/api/v1/posts/#{post_object.id}/comments/#{comment.id}", invalid_params.to_json
        expect(last_response.status).to eq 400
      end

    end

  end

  # Let's delete stuff, yay \o/
  describe 'DELETE /posts/:post_id/comments/:id' do

    let(:comment) { create(:comment, post: post_object) }

    # The request works...
    it 'returns HTTP status 200 - Ok' do
      delete "/api/v1/posts/#{post_object.id}/comments/#{comment.id}"
      expect(last_response.status).to eq 200
    end

    # ... but did it really remove the comment from the DB?
    it 'removes the comment' do
      id = comment.id
      delete "/api/v1/posts/#{post_object.id}/comments/#{id}"
      comment = post_object.reload.comments.where(id: id).first
      expect(comment).to eq nil
    end

  end

end

3.4 Testing!

Now that our tests are ready, we can run rspec to run all the tests of our application!

rspec

Output:

Finished in 0.97313 seconds (files took 1.13 seconds to load)
74 examples, 0 failures

Awesome, everything works correctly! Now anytime we make a change to our API, we can re-run the tests to ensure that we didn't break anything.

The best practice here is to actually setup a flow where your tests run every time you push some code to your repository (before it gets deployed) using some CI tool like CircleCI for example.

3.5 Writing the admin tests

I'm not going to show you the tests for the admin controller. I'd love to have you write them! It's the perfect exercise to finish this jutsu. Not just this jutsu actually, the whole series about Bloggy!

What's next?

This tutorial was the last one in the Bloggy series where we built an API from scratch using Grape, Mongoid and the JSON API specification - what a journey!

I originally wanted to include the jutsus to build the front-end in this series but I think it will go into a different one. We will obviously reuse the API we just built but since the main focus will be Javascript, it should be separated.

For now, I have to go write my book, see you soon!

Source Code

The source code is available on GitHub.

Comments

Loading comments...

Level Up Your Dev Skills & Income 💰💻

Learn how to sharpen your programming skills, monetize your expertise, and build a future-proof career — through freelancing, SaaS, digital products, or high-paying jobs.

Join 3,000+ developers learning how to earn more, improve their skills, and future-proof their careers.

Bloggy #6 - Bloggy: How to test a Grape web API (models and controllers) | Devmystify