What error drives our eyes and ears amiss?William Shakespeare (through the tongue of Antipholus of Syracuse), The Comedy of Errors
Mistakes are a part of life. One of the reasons for adopting test-driven development is to ensure that we can go as fast as we safely can, minimizing bugs in code.
The next item on our feature list is to improve error handling.
5 USD x 2 = 10 USD |
10 EUR x 2 = 20 EUR |
4002 KRW / 4 = 1000.5 KRW |
5 USD + 10 USD = 15 USD |
Separate test code from production code |
Remove redundant tests |
5 USD + 10 EUR = 17 USD |
1 USD + 1100 KRW = 2200 KRW# |
Determine exchange rate based on the currencies involved (from → to) |
Improve error handling when exchange rates are unspecified |
Allow exchange rates to be modified |
The way our code currently handles missing exchange rates is buggy. Let’s address this shortcoming. Table 10-1 shows our wish list for handling errors due to missing exchange rates.
Item | Description |
---|---|
1 |
The evaluate method should signal an explicit error when one or more necessary exchange rates are missing. |
2 |
The error message should be “greedy" — that is, it should indicate all the missing exchange rates that prevent a Portfolio from being evaluated, not just the first missing exchange rate. |
3 |
To prevent the error from being ignored by the caller, no valid |
For instance, if we try to evaluate a portfolio in the currency “Kalganid”, 1 for which there are no defined exchange rates, we should get a detailed error message listing all the missing exchange rates.
We’ll need to change the signature of our convert
and Evaluate
methods when there is a missing exchange rate. We are currently returning only one return value from these methods. To indicate an error — the inability to find an exchange rate — we need a second return value.
In Go, the idiomatic way to indicate failure is to return an error
as the last return value from a function or method so the caller can check for it.
Here is the pseudocode for how Evaluate
and convert
should collaboratively work using Go’s idioms:
Evaluate: For each Money struct: Try to convert Money to target currency and add it to the total amount If convert returns an error: Capture the "from" and "to" currencies in "failures" If there are no failures: Return a Money struct with the total amount and target currency; return nil for error Otherwise: Return an empty Money struct; return an error message including all the failures
With this pseudocode sketched out, let’s write a failing test in money_test.go
. This test will be slightly different from the existing tests: it expects an error to be returned and compares the error message with an expected message.
func
TestAdditionWithMultipleMissingExchangeRates
(
t
*
testing
.
T
)
{
var
portfolio
s
.
Portfolio
oneDollar
:=
s
.
NewMoney
(
1
,
"USD"
)
oneEuro
:=
s
.
NewMoney
(
1
,
"EUR"
)
oneWon
:=
s
.
NewMoney
(
1
,
"KRW"
)
portfolio
=
portfolio
.
Add
(
oneDollar
)
portfolio
=
portfolio
.
Add
(
oneEuro
)
portfolio
=
portfolio
.
Add
(
oneWon
)
expectedErrorMessage
:=
"Missing exchange rate(s):[USD->Kalganid,EUR->Kalganid,KRW->Kalganid,]"
_
,
actualError
:=
portfolio
.
Evaluate
(
"Kalganid"
)
if
expectedErrorMessage
!=
actualError
.
Error
(
)
{
t
.
Errorf
(
"Expected [%s] Got: [%s]"
,
expectedErrorMessage
,
actualError
.
Error
(
)
)
}
}
Expected error message should list each missing exchange rate; note the terminating comma
We don’t care about the first return value, so we assign it to the blank identifier
Go’s implicit semicolon rule requires a trailing comma in composite literals. The trailing comma after the last exchange rate in our error message reflects this syntactic preference of Go.
This test is similar to the two existing test for addition. We expect an error with the detailed message as the second return value from Evaluate
method. We ignore the first return value by assigning it to the blank identifier.
We compare the expected and actual error messages directly in our test. We cannot use our assertEqual
function as it currently exists because it can only compare Money
s. We should improve this assertEqual
function; we’ll defer it until the REFACTOR phase.
In Go, we can assign any return value from a function to an underscore (_
). This is the “blank identifier” — it effectively means “we don’t care about this value”.
This code doesn’t compile. If we try to run it, we’ll get an error in money_test.go
:
...
assignment
mismatch
:
2
variables
but
portfolio
.
Evaluate
returns
1
values
To get this test to pass, we first have to change the signature of the Evaluate
method to return two values, the second one being an error
. How would Evaluate
know when to return an error
? It would know if one (or more) of the calls to convert
failed, because convert
is where any missing exchange rates would be detected. This means that we have to change the signature of convert
method, too.
Let’s redesign the convert
method first, so that it returns a boolean to indicate whether the rate was found or not:
func
convert
(
money
Money
,
currency
string
)
(
float64
,
bool
)
{
exchangeRates
:=
map
[
string
]
float64
{
"EUR->USD"
:
1.2
,
"USD->KRW"
:
1100
,
}
if
money
.
currency
==
currency
{
return
money
.
amount
,
true
}
key
:=
money
.
currency
+
"->"
+
currency
rate
,
ok
:=
exchangeRates
[
key
]
return
money
.
amount
*
rate
,
ok
}
We modify the signature of convert
to add a second return type: a bool
. If the “from” and “to” currencies are the same, the conversion is trivial as before: we return the money.amount
unchanged and a true
as the second return value to indicate success. If the “from” and “to” currencies are different, we look up the exchange rate in our map. We use the success or failure of this lookup, captured in the ok
variable, as the second return value of convert
method.
In Go, when we look for a key in a map, the second return value is true if the key was found, false otherwise. Conventionally, the second return value is assigned to a variable named ok
— hence the name of this idiom: “comma, ok”.
We have modified convert
’s signature; we need to redesign Evaluate
too.
import
"errors"
...
func
(
p
Portfolio
)
Evaluate
(
currency
string
)
(
Money
,
error
)
{
total
:=
0.0
failedConversions
:=
make
(
[
]
string
,
0
)
for
_
,
m
:=
range
p
{
if
convertedAmount
,
ok
:=
convert
(
m
,
currency
)
;
ok
{
total
=
total
+
convertedAmount
}
else
{
failedConversions
=
append
(
failedConversions
,
m
.
currency
+
"->"
+
currency
)
}
}
if
len
(
failedConversions
)
==
0
{
return
NewMoney
(
total
,
currency
)
,
nil
}
failures
:=
"["
for
_
,
f
:=
range
failedConversions
{
failures
=
failures
+
f
+
","
}
failures
=
failures
+
"]"
return
NewMoney
(
0
,
""
)
,
errors
.
New
(
"Missing exchange rate(s):"
+
failures
)
}
The errors
package is needed to create errors
If there are no failed conversions, a nil
error is returned as the second value
If there are failed conversions, an error listing all the failed conversions is returned as the second value
There are several new lines of code; however, they are a faithful representation of the pseudocode we sketched out earlier. Manufacturing the error message string from the failedConversions
slice requires a second for
loop, but is conceptually straightforward.
With these changes, we get compilation failures in the the three other tests we have for addition. We get this error message, in triplicate:
...
assignment
mismatch
:
1
variable
but
portfolio
.
Evaluate
returns
2
values
Because we have changed the signature of Evaluate
to return two values, we must also change the existing calls to this method to receive the second value, albeit with a “talk to the hand” blank identifier! One example is shown below:
actualValue
,
_
:=
portfolio
.
Evaluate
(
"USD"
)
With these changes, all the tests now pass.
Time to refactor: let’s address the assertion if
block in our newest test. We’d like to call the assertEqual
method, but its signature currently requires two Money
objects, whereas we want to compare two `string`s. The body of the method is fine as it is: it compares the two things it’s given and prints a formatted error message if they’re unequal.
Is there a way we could declare the two parameters to assertEqual
in a more generic fashion?
Yes, there is. In Go, `struct`s can implement one or more `interface`s. The mechanism of this implementation is rather sublime: if a struct happens to be the receiver for all the methods defined in an interface, then it automatically implements that interface. There is no declaration in code explicitly saying “Hear ye! This struct hereby implements that interface.” (There isn’t a programmatic version of this town crier announcement, either.) Go’s interfaces are an interesting blend of static typechecking and dynamic dispatch.
An interface in Go is implemented by anything — user-defined struct or built-in type — that implements all the methods in the interface.
Of particular interest is the empty interface, which defines exactly zero methods. Because the empty interface has no methods, it is implemented by every type.
In Go, the empty interface{}
is implemented by every type. 2
Since the empty interface is implemented by every type, we can change the signature of the assertEqual
method to accept an expected
value and an actual
value, both of which are of the type interface{}
. We can then happily pass in two string`s or two `Money
s, as we need:
func
assertEqual
(
t
*
testing
.
T
,
expected
interface
{
}
,
actual
interface
{
}
)
{
if
expected
!=
actual
{
t
.
Errorf
(
"Expected [%+v] Got: [%+v]"
,
expected
,
actual
)
}
}
We can now replace the if
block in TestAdditionWithMultipleMissingExchangeRates
with a call to this modified assertEqual
method:
func
TestAdditionWithMultipleMissingExchangeRates
(
t
*
testing
.
T
)
{
...
assertEqual
(
t
,
expectedErrorMessage
,
actualError
.
Error
(
)
)
}
Call to the modified assertEqual
method. Note that the last parameter is now actualError.Error()
, to ensure type consistency with the second parameter
Neat! The tests are still green, and we have fewer lines of code. We have accomplished the three items listed in Table 10-1.
There is still duplication in the code: the bits where we create the key
in both convert
and Evaluate
. We need to simplify our code. We’ll add it to our feature list.
We’d like to throw an Error from evaluate
with a detailed message when one or more exchange rates are not found. Let’s write a test in test_money.js
to describe the specific message this exception should have.
testAdditionWithMultipleMissingExchangeRates
(
)
{
let
oneDollar
=
new
Money
(
1
,
"USD"
)
;
let
oneEuro
=
new
Money
(
1
,
"EUR"
)
;
let
oneWon
=
new
Money
(
1
,
"KRW"
)
;
let
portfolio
=
new
Portfolio
(
)
;
portfolio
.
add
(
oneDollar
,
oneEuro
,
oneWon
)
;
let
expectedError
=
new
Error
(
"Missing exchange rate(s):[USD->Kalganid,EUR->Kalganid,KRW->Kalganid]"
)
;
assert
.
throws
(
function
(
)
{
portfolio
.
evaluate
(
"Kalganid"
)
}
,
expectedError
)
;
}
This test is similar to the existing tests for addition, with the notable difference that we are trying to evaluate the Portfolio
in “Kalganid”. The assert.throws
takes a reference to an anonymous function that calls the evaluate
function as the first parameter, and the expected error as the second parameter.
In JavaScript, we don’t call the method-under-test as part of the assert.throws
when we expect an exception to be thrown; otherwise the assert statement would itself fail to execute successfully. Instead, we pass an anonymous function object as the first parameter which calls the method-under-test.
This test fails because our evaluate
method currently doesn’t throw the expected exception:
AssertionError
[
ERR_ASSERTION
]
:
Missing
expected
exception
(
Error
).
...
code
:
'ERR_ASSERTION'
,
actual
:
undefined
,
expected
:
Error
:
Missing
exchange
rate
(
s
)
:
[
USD
->
Kalganid
,
EUR
->
Kalganid
,
KRW
->
Kalganid
]
We could write a trivial (“silly”) conditional statement at the top of the evaluate
method to get the test to pass. We could then write yet another test to force us towards the non-trivial (“better”) implementation:
evaluate
(
currency
)
{
//////////////////////////////////////
// We *could* do this; but let's not!
//////////////////////////////////////
if
(
currency
==
"Kalganid"
)
{
throw
new
Error
(
"Missing exchange rate(s):[USD->Kalganid,EUR->Kalganid,KRW->Kalganid]"
);
}
...
}
Let’s see if we can speed things up by aiming for the non-trivial implementation right away.
In Chapter 9, we saw that when we query a Map
in JavaScript with a key that doesn’t exist, we get an undefined
return value. We could implement convert
in a similar way: return the converted amount when the rate is found, undefined
otherwise.
convert
(
money
,
currency
)
{
let
exchangeRates
=
new
Map
(
)
;
exchangeRates
.
set
(
"EUR->USD"
,
1.2
)
;
exchangeRates
.
set
(
"USD->KRW"
,
1100
)
;
if
(
money
.
currency
===
currency
)
{
return
money
.
amount
;
}
let
key
=
money
.
currency
+
"->"
+
currency
;
let
rate
=
exchangeRates
.
get
(
key
)
;
if
(
rate
==
undefined
)
{
return
undefined
;
}
return
money
.
amount
*
rate
;
}
When “converting” Money
from a currency to the same currency, simply return the amount
as the result
When no exchange rate is found, return undefined
as the result
When an exchange rate exists, use it to compute the converted amount
In evaluate
, we can check each call to convert
while reduce`ing the `moneys
array. If any conversions result in an undefined
value, we note down the missing conversion key (i.e the “from” and “to” currencies) in an array. At the end, we either return a new Money
object as before if every conversion worked, or throw an error whose message contains the missing conversion keys if there were failures.
evaluate
(
currency
)
{
let
failures
=
[
]
;
let
total
=
this
.
moneys
.
reduce
(
(
sum
,
money
)
=>
{
let
convertedAmount
=
this
.
convert
(
money
,
currency
)
;
if
(
convertedAmount
==
undefined
)
{
failures
.
push
(
money
.
currency
+
"->"
+
currency
)
;
return
sum
;
}
return
sum
+
convertedAmount
;
}
,
0
)
;
if
(
failures
.
length
==
0
)
{
return
new
Money
(
total
,
currency
)
;
}
throw
new
Error
(
"Missing exchange rate(s):["
+
failures
.
join
(
)
+
"]"
)
;
}
If there are no failures, a new Money
object with the correct amount and currency is returned
If there are conversions failures, an error listing all the failed conversions is returned
The tests are all green and we’ve accomplished the items in Table 10-1.
There is a subtle unpleasant odor in our code, however. The duplication where we create the conversion key
in both convert
and evaluate
is the source of this odor. We’ll add this clean-up item to our feature list.
We’d like to raise an Exception
when evaluate
fails due to missing exchange rates. In its message, the exception should describe all the missing exchange rate keys (ie. the “from” and “to” currencies). Let’s start with a test that validates this behavior.
Python has a refined class hierarchy for exceptions, errors, and warnings. All user-defined exceptions should extend Exception
.
def
testAdditionWithMultipleMissingExchangeRates
(
self
):
oneDollar
=
Money
(
1
,
"USD"
)
oneEuro
=
Money
(
1
,
"EUR"
)
oneWon
=
Money
(
1
,
"KRW"
)
portfolio
=
Portfolio
()
portfolio
.
add
(
oneDollar
,
oneEuro
,
oneWon
)
with
self
.
assertRaisesRegex
(
Exception
,
"Missing exchange rate(s):[USD->Kalganid,EUR->Kalganid,KRW->Kalganid]"
,
):
portfolio
.
evaluate
(
"Kalganid"
)
This test is similar to the existing tests for addition, with a couple of differences. First: we are attempting to evaluate
a Portfolio
in “Kalganid”, for which no exchange rates exist. Second: we expect the evaluate
method to throw an exception with a specific error message that we verify in the assertRaisesRegex
statement.
assertRaisesRegex
is one of the many useful assertion methods defined in Python’s TestCase
class. Since our exception string has several characters that have special meaning in regular expressions, we escape them using the backslash character.
The test fails with two exceptions. First, there’s the KeyError
which we expect: there is no exchange rate key involving the “Kalganid” currency. The second error is the assertion failure we sought to cause:
FAIL
:
testAdditionWithMultipleMissingExchangeRates
(
__main__
.
TestMoney
)
----------------------------------------------------------------------
KeyError
:
'USD->Kalganid'
During
handling
of
the
above
exception
,
another
exception
occurred
:
...
AssertionError
:
"Missing exchange rate(s):[USD->Kalganid,EUR->Kalganid,KRW->Kalganid]"
does
not
match
"'USD->Kalganid'"
This reveals that our test is throwing an Exception, however, the message in the Exception does not match what our test demands. Notice that the message in the Exception that is thrown is “USD→Kalganid" — which is at least one part of our desired error message. We have a head start!
The “USD→Kalganid” message is in the KeyError
Exception that’s raised when we look for a missing key in the dictionary of exchangeRates
. Could we capture all such messages in evaluate
and raise an Exception with the manicured message?
We need to modify our evaluate
method to respond to Exceptions arising from its calls to __convert
. Let’s unroll the lambda
expression into a loop and add a try ... except
block to capture any failures. If there are no failures, we return a new Money
object as before. If there are failures, we raise an Exception whose message is a comma-separated list of the stringified KeyError
exceptions that are caught:
def
evaluate
(
self
,
currency
)
:
total
=
0.0
failures
=
[
]
for
m
in
self
.
moneys
:
try
:
total
+
=
self
.
__convert
(
m
,
currency
)
except
KeyError
as
ke
:
failures
.
append
(
ke
)
if
len
(
failures
)
==
0
:
return
Money
(
total
,
currency
)
failureMessage
=
"
,
"
.
join
(
str
(
f
)
for
f
in
failures
)
raise
Exception
(
"
Missing exchange rate(s):[
"
+
failureMessage
+
"
]
"
)
If there are no failures, a new Money
object with the correct amount and currency is returned
If there are conversions failures, an Exception listing all the failed conversions is returned
When we run our test now, we get an AssertionError:
AssertionError
:
"
Missing exchange rate
(s
):
[USD->Kalganid,EUR->Kalganid,KRW->Kalganid
]
"
does
not
match
"
Missing exchange rate(s):[
'
USD->Kalganid
'
,
'
EUR->Kalganid
'
,
'
KRW->Kalganid
'
]
"
Ah! The difference is that the stringified KeyError
contains single-quotes that are missing from our desired message.
So close and yet so far! We are tempted to change our test to add the single-quotes around each missing exchange-rate key. Should we do that?
On occasion, there may be valid reasons to change our requirements to match our result — if the change isn’t that overwhelming, or the feature isn’t that critical. We could mount both those arguments against further changes to the evaluate
method in this case.
However, there is something icky about moving the goalposts after the game has started. And we are so close! A quick examination of the documentation of KeyError
reveals that, like all subclasses of BaseException
, it has an args
property which contains a list of string arguments provided when the exception object is created. The first message in this list — at index 0 — is the message we seek. A simple change to the way we assemble our failureMessage
can fix our problem:
failureMessage
=
"
,
"
.
join
(
f
.
args
[
0
]
for
f
in
failures
)
All the tests are now green and we’ve accomplished what we set out to do: the items in Table 10-1. However, that icky feeling that things aren’t great — that we don’t have the simplest code that works — is still with us. For one thing: we unrolled our compact lambda expression into a verbose loop.3 For another: we reached into the depths of a built-in exception class to craft our error message.
We’ll add an item to our list to refactor the part of our code dealing with exchange rates.
The error handling we added to our code merits a commit to our local Git repository. Let’s do this:
git add .
git commit -m "feat: improved error handling for missing exchange rates"
We have added error handling to the way we evaluate Portfolios. The resilience this brings to our code is no mean feat.
In doing so, however, we’ve gradually become aware of the clumsy way in which we’ve modeled exchange rates thus far. By keeping the implementation not just within Portfolio
but within the evaluation of a Portfolio
, we’ve strayed away from the elegance of simplicity.
Let’s add a feature to our list to improve our implementation of exchange rates.
5 USD x 2 = 10 USD |
10 EUR x 2 = 20 EUR |
4002 KRW / 4 = 1000.5 KRW |
5 USD + 10 USD = 15 USD |
Separate test code from production code |
Remove redundant tests |
5 USD + 10 EUR = 17 USD |
1 USD + 1100 KRW = 2200 KRW# |
Determine exchange rate based on the currencies involved (from → to) |
Improve error handling when exchange rates are unspecified |
Improve the implementation of exchange rates |
Allow exchange rates to be modified |
1 “Kalganid” is a fictitious currency in Isaac Asimov’s “Foundation” series.
2 To test empty Go interfaces, try this useful example in your browser: https://tour.golang.org/methods/14
3 There is no trivial way to catch exceptions within a Python lambda. There was an enhancement proposal to Python — PEP 463 — that was about this very feature. However, that proposal was rejected in 2014. See https://www.python.org/dev/peps/pep-0463/
4 As with many other software terms, Martin Fowler’s website has a useful page on the topic of “CodeSmell” https://www.martinfowler.com/bliki/CodeSmell.html
18.119.105.239