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 wrongA 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