Symbols, Attributes & Errors

I mentioned that OOP is a way of grouping data and behavior under the same concept, but most of the projects we worked until now were mostly about behavior.

With the exception of our Greeter, that has a @language attribute, the other classes we created (NumberConverter, StringDecorator and StatisticsCalculator) were all about behavior, with no "internal state" data to be managed.

In this chapter we're going to use an attribute to manage the object's state and expose some methods to let users change the state in a way we can control.

Let's pretend you want to found your own fintech startup? So let's start our primitive amazing banking system by creating a Wallet class.

Note: for simplicity sake our wallet will not deal with the concept of "currency". The amount of "money" is going to be represented simply by Integers.

Our project starts with:

mkdir wallet
cd wallet

The Test List

Creating a Wallet is not that trivial. Before writing any test we need to think in some requirements. And we better write down what we have in mind to avoid being overwhelmed with a lot of ideas consuming our mental energy.

Here's what I have in mind right now

Maybe we can think in more requirements later, but with this short list of requirements we can start something. I feel much better having this list written down and not occupying my brain. Now I can focus on writing tests and implementing code to pass the tests

A Wallet Starts With a Zero Balance

Write the test first

Let's create a wallet_test.rb file, and, from that list, pick "a wallet starts with zero balance" and write a test for it:

require "minitest/autorun"
require_relative "wallet"

class WalletTest < Minitest::Test
  def test_wallet_starts_with_zero_balance
    wallet = Wallet.new
    assert_equal 0, wallet.balance
  end
end

Run the test and check the error.

Write the minimal amount of code for the test to run

You know the drill... After running the code and solving all errors until the test fails without errors, you should have something like this:

class Wallet
  def balance
  end
end

And a failure like this:

  1) Failure:
WalletTest#test_wallet_starts_with_zero_balance [code/wallet/wallet_test.rb:7]:
Expected: 0
  Actual: nil

Write enough code to make the test pass

Let's just "fake it 'till you make it":

class Wallet
  def balance
    0
  end
end

Refactor

We know that a balance is an attribute of a wallet, then I think we need an instance variable to store the current amount of money in the wallet.

The current requirement we're working on says that a wallet starts with a zero balance, then let's create a constructor method initializing an instance variable with zero. The code now looks like this:

class Wallet
  def initialize
    @balance = 0
  end

  def balance
    0
  end
end

Now we can replace that 0 in #balance with the instance variable:

  def balance
    @balance
  end

The tests must still pass.

We're going to refactor even more, but first let's get acquainted with some other Ruby concepts: symbols and attribute accessors.

Symbols

In Ruby we have a, let's say, "primitive type" called symbol. You can consider a symbol as an immutable and unique string. It is a lightweight, efficient way to represent and compare identifiers because each symbol is unique and reusable throughout the program.

We create a symbol simply by using the syntax :symbol_name. Ruby then checks if a symbol with the same name already exists in its internal symbol table. If it does, it reuses the existing symbol reference. Otherwise it creates a new one right away, ensuring memory efficiency. This makes symbols particularly suitable for identifiers where you need a consistently comparable and reusable entity without the overhead and mutability of strings.

Symbols are useful for many things, and in our use case here we're going to use a simple :balance to identify an attribute accessor.

Attribute Accessors

In our last refactoring we created a remarkably trivial method:

  def balance
    @balance
  end

This is a method that exists for the sole purpose of reading the value of an attribute. When we have such situation, we can use a Ruby's syntactic-sugar known as "attribute accessors". In this case, as we want to read, the accessor is attr_reader.

So, our #balance method can be entirely replaced with this line:

attr_reader :balance

This single line is enough to express that "the class has an instance variable called @balance and a public method called #balance that returns its value."

It's usual to put this line as one of the first ones in the class's definition. That's so the future programmer reading the code can be aware of the class's attributes right away. Our Wallet class now looks like this:

class Wallet
  attr_reader :balance

  def initialize
    @balance = 0
  end
end

And our test is still passing!

NOTE:

The usual attribute accessors are:

  • attr_reader: the one we saw above

  • attr_writer: creates an instance variable with the given identifier and allows you to assign values to it

  • attr_accessor: equivalent to using both reader and writer.

That's it! Let's move to the next item in our list...

Deposit Method

Write the test first

class WalletTest < Minitest::Test
  # ...
  
  def test_deposit_increases_balance
    wallet = Wallet.new
    wallet.deposit(10)
    assert_equal 10, wallet.balance
  end
end

By running the test we'll have this error:

NoMethodError: undefined method 'deposit' for an instance of Wallet

Write the minimal amount of code for the test to run

class Wallet
  attr_reader :balance

  def initialize
    @balance = 0
  end

  # added this method
  def deposit(amount)
  end
end

Now the test fails with:

Expected: 10
  Actual: 0

Write enough code to make the test pass

When we make a deposit in a wallet, we want the balance to increase. Let's solve this by adding the given amount to the wallet's @balance:

class Wallet
  # ...
  
  def deposit(amount)
    @balance = @balance + amount
  end
end

Run the tests and they should pass.

Refactor

In Ruby there's a shorthand for a situation like this:

@balance = @balance + amount

It can be rewritten like this:

@balance += amount

Therefore, our class now looks like this:

class Wallet
  attr_reader :balance

  def initialize
    @balance = 0
  end

  def deposit(amount)
    @balance += amount
  end
end

If the tests are still passing, let's move on to the next item from our list.

Withdraw Method

Write the test first

class WalletTest < Minitest::Test
  # ...
  
  def test_withdraw_decreases_balance
    wallet = Wallet.new
    wallet.deposit(100)
    wallet.withdraw(20)
    assert_equal 80, wallet.balance
  end
end

This is the error:

NoMethodError: undefined method 'withdraw' for an instance of Wallet

Write the minimal amount of code for the test to run

class Wallet
  # ...
  def withdraw(amount)
  end
end

The failure is:

  1) Failure:
WalletTest#test_withdraw_decreases_balance [wallet_test.rb:20]:
Expected: 80
  Actual: 100

Which means that #deposit worked as expected but #withdraw did not.

Write enough code to make the test pass

That shorthand notation we used for addition, +=, has an equivalent for subtraction: -=. Then let's use it:

class Wallet
  # ...
  
  def withdraw(amount)
    @balance -= amount
  end
end

The test should pass.

Refactor

The code is pretty neat:

class Wallet
  attr_reader :balance

  def initialize
    @balance = 0
  end

  def deposit(amount)
    @balance += amount
  end

  def withdraw(amount)
    @balance -= amount
  end
end

No need for a refactoring this time.

Ruby Errors and Exceptions

If the wallet has not enough funds, the withdraw should result in an error, right? But before addressing such requirement, let's quickly understand how errors work in Ruby.

The way to handle errors in Ruby is by raising an exception. An Exception is a special kind of object that can be raised to stop the normal execution of a program. As mentioned on the Exception class documentation:

Class Exception and its subclasses are used to indicate that an error or other problem has occurred, and may need to be handled.

The programmer using a code that can raise an exception should either deal with the problem that raised it or let Ruby exit the program completely.

In the documentation we also have the built-in exception class hierarchy, where we can see many errors that we're already dealing with since the "Hello, World" chapter. Namely:

  • NameError, when we execute the first test of a chapter and the class is not yet created.

  • NoMethodError, when a method was not yet created.

  • ArgumentError, when the number of arguments we pass to a method is not what it's expecting to receive.

Those errors are all descendants of the Exception class.

Let's start an irb session and raise some errors on purpose:

# IRB SESSION

# reference to an undefined identifier
##############################################
> my_var = this_is_an_unknown_identifier
# (irb):1:in '<main>': undefined local variable or method 'this_is_an_unknown_identifier' for main (NameError)
#         from <internal:kernel>:168:in 'Kernel#loop'
#         from ...

# creating a dummy object and calling a method
##############################################
> my_var = Object.new
# => #<Object:0x00007f2f27cf0ac8>
> my_var.this_is_an_unknown_method
# (irb):3:in '<main>': undefined method 'this_is_an_unknown_method' for #<Object:0x00007f2f27cf0ac8> (NoMethodError)
#         from <internal:kernel>:168:in 'Kernel#loop'
#         from ...


# creating a dummy method that does nothing
##############################################
> def my_method; end
# => :my_method
> my_method("one", "two")
# (irb):5:in 'my_method': wrong number of arguments (given 2, expected 0) (ArgumentError)
#         from (irb):6:in '<main>'
#         from <internal:kernel>:168:in 'Kernel#loop'
#         from ...

As you can see, code with perfectly valid syntax can raise errors when we make mistakes. This is how Ruby warns us that we're doing wrong things in our code.

There are situations where we make mistakes that are perfectly valid for Ruby, but not OK for the problem domain we're working with. In such cases we need to explicitly raise errors in our own code.

For example, for Ruby, when we subtract a number from a smaller one, it simply results in a negative value. In our domain, the banking system, we don't want to allow that. When we try to withdraw more funds than what's available in a wallet, it must result in an error.

The simplest way to explicitly raise an error is just using raise followed by a string with a description. Example:

# IRB SESSION

> raise "Not enough funds"
# (irb):1:in '<main>': Not enough funds (RuntimeError)
#         from <internal:kernel>:168:in 'Kernel#loop'
#         from ...

As you can see, the raised error is quite generic: RuntimeError. We can also raise a more specific error by giving to raise the name of the error class.

In our case, we want to raise an error when the argument given to #withdraw is greater than the balance. Maybe we can use ArgumentError... Let's see how to raise it:

# IRB SESSION

> raise ArgumentError, "Not enough funds"
# (irb):1:in '<main>': Not enough funds (ArgumentError)
#         from <internal:kernel>:168:in 'Kernel#loop'
#         from ...

Although ArgumentError is more specific than RuntimeError, it's still generic. It's the same error raised if we call #withdraw with 3 arguments, for example. We can (and should) be even more specific here. Let's write this as a requirement in our list and deal with it later.

For now I want to just make sure an error is raised when there's no funds.

Raise Error When There's Not Enough Balance

Our requirements list now looks like this:

Write the test first

The syntax to write an assertion that certain code raises an error is a bit different from what we've been using. Check it out:

class WalletTest < Minitest::Test
  # ...
  
  def test_withdraw_more_than_balance_raises_error
    wallet = Wallet.new
    # the code expected to raise an error must be
    # inside the block given to 'assert_raises'
    assert_raises(ArgumentError) { wallet.withdraw(10) }
  end
end

Run the tests and you'll see this failure:

  1) Failure:
WalletTest#test_withdraw_more_than_balance_raises_error [code/wallet/wallet_test.rb:25]:
ArgumentError expected but nothing was raised.

Write enough code to make the test pass

Well, checking if the given amount is greater than the balance is pretty straight forward, we just need an if and then raise an error when the condition is true:

class Wallet
  # ...
  
  def withdraw(amount)
    if amount > @balance
      raise "Not enough funds"
    end

    @balance -= amount
  end
end

Run the tests:

  1) Failure:
WalletTest#test_withdraw_more_than_balance_raises_error [code/wallet/wallet_test.rb:25]:
[ArgumentError] exception expected, not
Class: <RuntimeError>
Message: <"Not enough funds">
---Backtrace---
/.../code/wallet/wallet.rb:14:in 'Wallet#withdraw'
code/wallet/wallet_test.rb:25:in 'block in WalletTest#test_withdraw_more_than_balance_raises_error'
---------------

Whoops! I forgot to specify the specific error to raise. Then it raised the super generic RuntimeError, which is not expected by our test. Let's fix that:

def withdraw(amount)
  if amount > @balance
    raise ArgumentError, "Not enough funds"
  end

  @balance -= amount
end

And now all tests should be passing!

Refactor

The first refactoring I want to do is to use another Ruby syntactic-sugar. When we have an if block with only one instruction, we can write the expression in an almost-plain-English line, like this:

raise ArgumentError, "Not enough funds" if amount > @balance

Then, the withdraw method becomes this:

def withdraw(amount)
  raise ArgumentError, "Not enough funds" if amount > @balance

  @balance -= amount
end

Run the tests and they should pass.

Creating a Custom Error

As we mentioned, although the ArgumentError is more specific than RuntimeError, it's still generic. An evidence for this is that it requires a "Not enough funds" description to make it clear which kind of problem actually happened.

Let's solve this by creating a custom error class with a self-documenting name, like NotEnoughFundsError.

Write the test first

We still don't know how to create a custom error. Regardless, let's go ahead and change our test to assert that the desired error is raised:

class WalletTest < Minitest::Test
  # ...

  # here we're changing the existing test, now
  # passing 'NotEnoughFundsError' in the assertion
  def test_withdraw_more_than_balance_raises_error
    wallet = Wallet.new
    assert_raises(NotEnoughFundsError) { wallet.withdraw(10) }
  end
end

Running the tests:

  1) Error:
WalletTest#test_withdraw_more_than_balance_raises_error:
NameError: uninitialized constant WalletTest::NotEnoughFundsError
    code/wallet/wallet_test.rb:25:in 'WalletTest#test_withdraw_more_than_balance_raises_error'

Write the minimal amount of code for the test to run

The simplest way to create a custom error is by simply creating an empty class inheriting from StandardError, like this:

class NotEnoughFundsError < StandardError; end

And that's enough!

A question that can arise now is: in which file should we put this?

Let's keep things simple and declare it in the same file as the wallet code. So, add this to the top of your wallet.rb:

class NotEnoughFundsError < StandardError; end

class Wallet
  # ...
end

Run the tests and you should see this failure:

  1) Failure:
WalletTest#test_withdraw_more_than_balance_raises_error [code/wallet/wallet_test.rb:25]:
[NotEnoughFundsError] exception expected, not
Class: <ArgumentError>
Message: <"Not enough funds">
---Backtrace---
/.../code/wallet/wallet.rb:15:in 'Wallet#withdraw'
code/wallet/wallet_test.rb:25:in 'block in WalletTest#test_withdraw_more_than_balance_raises_error'
---------------

Write enough code to make the test pass

We already saw that kind of failure. To fix that we just need to change our code and make it raise the expected error. Then our #withdraw method becomes like this:

def withdraw(amount)
  raise NotEnoughFundsError, "Not enough funds" if amount > @balance

  @balance -= amount
end

Run the tests and they should pass.

Refactor

You know what? There's a redundancy in this line that's bothering me:

raise NotEnoughFundsError, "Not enough funds" if amount > @balance

If the error is named NotEnoughFundsError I think there's no need to say "Not enough funds" again. Then let's just remove that message from the code:

raise NotEnoughFundsError if amount > @balance

Taking a look at our wallet.rb as a whole:

class NotEnoughFundsError < StandardError; end

class Wallet
  attr_reader :balance

  def initialize
    @balance = 0
  end

  def deposit(amount)
    @balance += amount
  end

  def withdraw(amount)
    raise NotEnoughFundsError if amount > @balance

    @balance -= amount
  end
end

Run the tests and they should pass.

Let's mark the last item from our todo list as done and wrap up this chapter.

Handling Edge Cases

Here are some ideas of scenarios for you to write tests and implement:

Key Concepts

Ruby

  • A symbol is a way to have a unique and reusable identifier to be used in our Ruby code (see the Symbol class documentation for more info).

  • Attribute accessors are useful to provide access to attributes with a less verbose notation.

  • An Exception is used to indicate that an error has occurred in our program. Useful information can be found in the Exception class documentation

  • We explicitly raise exceptions when we want to make clear to the users of our code that they're doing something wrong

  • A simple way to create a custom exception is by creating an empty class inheriting from StandardError.

  • This is just an intro to Ruby exceptions. In later chapters we will cover more sophisticated scenarios.

Testing

  • Again, writing down in a list the ideas for tests as soon as they appear in your mind is useful to keep your focus on the task you're solving at the moment.

  • assert_raises expects the code that raises the exception to be in a block. Check the documentation for more info.

Last updated