Model Specs

Your First Model Spec

We already have a model called "Food". In the future, we will write the test first before we create the model. Right now, let's just write our first model for "Food" class.

Create a new file "spec/models/food_spec.rb" and fill it with these lines:

require 'rails_helper'

describe Food do
  it "is valid with a name and description"
  it "is invalid without a name"
  it "is invalid without a description"
  it "is invalid with a duplicate name"
end

Execute The Spec

Then execute "rspec" command in your console. You should see result similar to this:

****

Pending:
  Food is valid with a name and description
    # Not yet implemented
    # ./spec/models/food_spec.rb:4
  Food is invalid without a name
    # Not yet implemented
    # ./spec/models/food_spec.rb:5
  Food is invalid without a description
    # Not yet implemented
    # ./spec/models/food_spec.rb:6
  Food is invalid with a duplicate name
    # Not yet implemented
    # ./spec/models/food_spec.rb:7

Finished in 0.00074 seconds (files took 6.14 seconds to load)
4 examples, 0 failures, 4 pending

Valid With

Now let's start from our first example.

# -cut-
describe Food do
  it "is valid with a name and description" do
    food = Food.new(
      name: "Nasi Uduk",
      description: "Betawi style steamed rice cooked in coconut milk. Delicious!",
      price: 10000.0
    )
    expect(food).to be_valid
  end

  # -cut-
end

We use RSpec's "be_valid" matcher to verify that our model knows what it has to look like to be valid.

Now run "rspec" from your console again.

Invalid Without

Next, we should verify that our model returns error without mandatory fields.

# -cut-
describe Food do
  # -cut-

  it "is invalid without a name" do
    food = Food.new(
      name: nil,
      description: "Betawi style steamed rice cooked in coconut milk. Delicious!",
      price: 10000.0
    )
    food.valid?
    expect(food.errors[:name]).to include("can't be blank")
  end

  # -cut-
end

Write Your Own

Now complete the third example to verify that our model returns if "description" field is not filled.

Invalid Without Description

It should looks something like this:

# -cut-
describe Food do
  # -cut-

  it "is invalid without a description" do
    food = Food.new(
      name: "Nasi Uduk",
      description: nil,
      price: 10000.0
    )
    food.valid?
    expect(food.errors[:description]).to include("can't be blank")
  end

  # -cut-
end

Test for Duplicate

For our last example, we will make sure that our model rejects entries with duplicate name.

# -cut-
describe Food do
  # -cut-

  it "is invalid with a duplicate name" do
    food1 = Food.create(
      name: "Nasi Uduk",
      description: "Betawi style steamed rice cooked in coconut milk. Delicious!",
      price: 10000.0
    )
    
    food2 = Food.new(
      name: "Nasi Uduk",
      description: "Just with a different description.",
      price: 10000.0
    )

    food2.valid?
    expect(food2.errors[:name]).to include("has already been taken")
  end

  # -cut-
end

Don't Forget to Commit

It's time to commit your progress to your Github repository again. By now you should already be familiar with the commands.

Testing Class Methods and Scopes

Apparently your new app is a hit! The product manager comes with new requirements: he wants your menu to be sorted by name.

 

This time, before we code the feature, we will write the test first!

Testing for Failures

# -cut-
describe Food do
  # -cut-
  
  it "returns a sorted array of results that match" do
    food1 = Food.create(
      name: "Nasi Uduk",
      description: "Betawi style steamed rice cooked in coconut milk. Delicious!",
      price: 10000.0
    )

    food2 = Food.create(
      name: "Kerak Telor",
      description: "Betawi traditional spicy omelette made from glutinous rice cooked with egg and served with serundeng.",
      price: 8000.0
    )

    food3 = Food.create(
      name: "Nasi Semur Jengkol",
      description: "Based on dongfruit, this menu promises a unique and delicious taste with a small hint of bitterness.",
      price: 8000.0
    )

    expect(Food.by_letter("N")).to eq([food3, food1])
  end

  # -cut-
end

Follow The Message

Now when we run "rspec" in our command line, it will return a failure, as expected:

....F

Failures:

  1) Food returns a sorted array of results that match
     Failure/Error: expect(Food.by_letter("N")).to eq([food3, food1])
     NoMethodError:
       undefined method 'by_letter' for #<Class:0x007ffa31cd2c50>
     # /Users/iqbalfarabi/.rvm/gems/ruby-2.3.1/gems/activerecord-5.0.6/lib/active_record/dynamic_matchers.rb:21:in 'method_missing'
     # ./spec/models/food_spec.rb:69:in 'block (2 levels) in <top (required)>'

Finished in 0.40915 seconds (files took 5.89 seconds to load)
5 examples, 1 failure

Failed examples:

rspec ./spec/models/food_spec.rb:50 # Food returns a sorted array of results that match

Make It Pass

As previous error message tells us, we will create a class method named "by_letter" in our "Food" model.

class Food < ApplicationRecord
  # -cut-

  def self.by_letter(letter)
    where("name LIKE ?", "#{letter}%").order(:name)
  end
end

Now when you execute "rspec" command again, it will return all green. All is well.

Negative Scenario

# -cut-
describe Food do
  # -cut-
  
  it "omits results that do not match" do
    food1 = Food.create(
      name: "Nasi Uduk",
      description: "Betawi style steamed rice cooked in coconut milk. Delicious!",
      price: 10000.0
    )

    food2 = Food.create(
      name: "Kerak Telor",
      description: "Betawi traditional spicy omelette made from glutinous rice cooked with egg and served with serundeng.",
      price: 8000.0
    )

    food3 = Food.create(
      name: "Nasi Semur Jengkol",
      description: "Based on dongfruit, this menu promises a unique and delicious taste with a small hint of bitterness.",
      price: 8000.0
    )

    expect(Food.by_letter("N")).not_to include(food2)
  end

  # -cut-
end

Don't Forget to Commit

You did good. And good things always end with a git commit.

DRYer Specs

As you may have noticed that our Food spec is not very faithful to the DRY principle. While we said that we do not really focus on DRY specs, does not mean we forget it altogether.

Describe

We can make our specs a little bit more DRY by using "context", "before", and "after".

First, we add more description inside our Food spec:

# -cut-
describe Food do
  # -cut-
  describe "filter name by letter" do
    # Let it be empty for now
  end
end

Before

Next, we create a "before" block and put all the test data creation there:

# -cut-
describe Food do
  # -cut-
  describe "filter name by letter" do
    before :each do
      @food1 = Food.create(
        name: "Nasi Uduk",
        description: "Betawi style steamed rice cooked in coconut milk. Delicious!",
        price: 10000.0
      )

      @food2 = Food.create(
        name: "Kerak Telor",
        description: "Betawi traditional spicy omelette made from glutinous rice cooked with egg and served with serundeng.",
        price: 8000.0
      )

      @food3 = Food.create(
        name: "Nasi Semur Jengkol",
        description: "Based on dongfruit, this menu promises a unique and delicious taste with a small hint of bitterness.",
        price: 8000.0
      )
    end
  end
end

Context

Then we create two contexts, one for matching letters and another one for non-matching letters. Lastly, we put our examples inside the contexts.

# -cut-
describe Food do
  # -cut-
  describe "filter name by letter" do
    # -cut-
    # We put the contexts below before block
    context "with matching letters" do
      it "returns a sorted array of results that match" do
        expect(Food.by_letter("N")).to eq([@food3, @food1])
      end
    end

    context "with non-matching letters" do
      it "omits results that do not match" do
        expect(Food.by_letter("N")).not_to include(@food2)
      end
    end
  end
end

Result

Now if you run "rspec --format documentation" in your console, the result should look like this:

Food
  is valid with a name and description
  is invalid without a name
  is invalid without a description
  is invalid with a duplicate name
  filter name by letter
    with matching letters
      returns a sorted array of results that match
    with non-matching letters
      omits results that do not match

Finished in 0.45187 seconds (files took 6.24 seconds to load)
6 examples, 0 failures

Don't Forget to Commit

You did good. And good things always end with a git commit.

Sum It Up

So far we have learned how to create specs for Model. Here are some important things to remember:

  1. Use active, explicit expectations
  2. Test for what you expect to happen
  3. And test for what you expect not to happen
  4. Test for edge cases
  5. Organize your specs for good readability

Exercise

  1. Create a spec to verify that our "Food" model:
    • Does not accept non numeric values for "price" field.
    • Does not accept "price" less than 0.01
    • Does not accept "image_url" string that ends with anything other than ".gif", ".jpg", or ".png"
  2. You should have noticed that our specs for validating presence of "name" and "description" is not so DRY. Make them DRYer!

Solutions (1)

1.a. Specs for price - valid for numeric greater or equal to 0.01

# -cut-
describe Food do
  # -cut-

  it "is valid with numeric price greater or equal to 0.01" do
    food = Food.new(
      name: "Nasi Uduk",
      description: "Betawi style steamed rice cooked in coconut milk. Delicious!",
      price: 0.01
    )

    expect(food).to be_valid
  end

  # -cut-
end

Solutions (2)

1.b. Specs for price - invalid for non numeric price

# -cut-
describe Food do
  # -cut-

  it "is invalid with non numeric price" do
    food = Food.new(
      name: "Nasi Uduk",
      description: "Betawi style steamed rice cooked in coconut milk. Delicious!",
      price: "sepuluh ribu"
    )

    expect(food.errors[:price]).to include("is not a number")
  end

  # -cut-
end

Solutions (3)

1.c. Specs for price - invalid for numeric price less than 0.01

# -cut-
describe Food do
  # -cut-

  it "is invalid with non numeric price" do
    food = Food.new(
      name: "Nasi Uduk",
      description: "Betawi style steamed rice cooked in coconut milk. Delicious!",
      price: -1000
    )

    expect(food.errors[:price]).to include("must be greater than or equal to 0.01")
  end

  # -cut-
end

Solutions (4)

1.d. Specs for image_url - valid for string ending with ".gif", ".jpg", or ".png"

# -cut-
describe Food do
  # -cut-

  it "is valid with image_url ending with '.gif', '.jpg', or '.png'" do
    food = Food.new(
      name: "Nasi Uduk",
      description: "Betawi style steamed rice cooked in coconut milk. Delicious!",
      imgae_url: "Nasi Uduk.jpg",
      price: 0.01
    )

    expect(food).to be_valid
  end

  # -cut-
end

Solutions (5)

1.e. Specs for image_url - invalid for string ending not with ".gif", ".jpg", or ".png"

# -cut-
describe Food do
  # -cut-

  it "is invalid with image_url ending not with '.gif', '.jpg', or '.png'" do
    food = Food.new(
      name: "Nasi Uduk",
      description: "Betawi style steamed rice cooked in coconut milk. Delicious!",
      imgae_url: "Nasi Uduk.csv",
      price: 0.01
    )

    expect(food.errors[:image_url]).to include("must be a URL for GIF, JPG or PNG image.")
  end

  # -cut-
end

Solutions (6)

2. DRYer specs for invalid without name and description

# -cut-
describe Food do
  # -cut-

  describe "invalid without name or description" do
    before :each do
      @food = Food.new(
        name: nil,
        description: nil,
        price: 10000.0
      )
    end
    
    it "is invalid without name" do
      @food.valid?
      expect(@food.errors[:name]).to include("can't be blank")
    end

    it "is invalid without description do
      @food.valid?
      expect(@food.errors[:description]).to include("can't be blank")
    end
  end

  # -cut-
end

[Go-Jek x BNCC] Model Specs

By qblfrb

[Go-Jek x BNCC] Model Specs

  • 371