In programming, there are three major classes of errors: syntax errors, runtime errors, and semantic errors. The Elixir compiler takes care of finding syntax errors for you, and in Chapter 10 you learned how to handle runtime errors. That leaves logic errors, when you tell Elixir to do something that you didn’t mean to say. While logging and tracing can help you find logic errors, the best way to solve them is to make sure that they never make their way into your program in the first place, and that is the role of static analysis, typespecs and unit testing.
Static analysis refers to debugging by analyzing the source code of a program without running it. The Dialyzer (DIscrepancy AnalYZer for ERlang programs) is a tool that does static analysis of Erlang source and .beam files to check for such problems as unused functions, code that can never be reached, improper lists, patterns that are unused or cannot match, etc.To make it easier to use Dialyzer with Elixir, use the dialyxir tool. We followed the global install path with these commands:
git clone https://github.com/jeremyjh/dialyxir cd dialyxir mix archive.build mix archive.install mix dialyzer.plt
The last command builds a Persistent Lookup Table (PLT) that stores results of Dialyzer analyses; the PLT will analyze most of the commonly used Erlang and Elixir libraries so that they don’t have to be scanned every time you use Dialyzer. This step will take several minutes to complete, so be patient and go out and enjoy a short break. As per the instructions on github, you must re-run that command whenver you install newer versions of Elixir or Erlang.
Now that dialyxir has set up Dialyzer, let’s see it in action. Consider this code, which you will find in ch10a/ex1-guards, which adds a very wrong function to the example in ch03/ex2-guards:
defmodule
Drop
do
def
fall_velocity
(
:earth
,
distance
)
when
distance
>=
0
do
:math
.
sqrt
(
2
*
9.8
*
distance
)
end
def
fall_velocity
(
:moon
,
distance
)
when
distance
>=
0
do
:math
.
sqrt
(
2
*
1.6
*
distance
)
end
def
fall_velocity
(
:mars
,
distance
)
when
distance
>=
0
do
:math
.
sqrt
(
2
*
3.71
*
distance
)
end
def
wrongness
()
do
total_distance
=
fall_velocity
(
:earth
,
20
)
+
fall_velocity
(
:moon
,
20
)
+
fall_velocity
(
:jupiter
,
20
)
+
fall_velocity
(
:earth
,
"20"
)
total_distance
end
end
If you go into IEx, the compiler will not detect any errors; it is only when you run the wrongness/0
function
that things go bad:
$
iex -S mix Erlang/OTP19
[
erts-8.0]
[
source
]
[
64-bit]
[
smp:4:4]
[
async-threads:10]
[
hipe]
[
kernel-poll:false]
Compiling1
file(
.ex)
Generated drop app Interactive Elixir(
1.3.1)
- press Ctrl+C toexit
(
type
h()
ENTERfor
help
)
iex(
1)
> Drop.wrongness()
**(
FunctionClauseError)
nofunction
clause matching in Drop.fall_velocity/2(
drop)
lib/drop.ex:3: Drop.fall_velocity(
:jupiter, 20)
(
drop)
lib/drop.ex:18: Drop.wrongness/0 iex(
1)
>
Dialyzer, however, would have warned you about this problem in advance. The first mix clean
command clears out
any compiled files so that dialyzer is starting “from scratch.” The output has been broken into separate lines
for formatting:
$
mix clean$
mix dialyzer Compiling1
file(
.ex)
Generated drop app Starting Dialyzer dialyzer --no_check_plt --plt /home/david/.dialyxir_core_19_1.3.1.plt -Wunmatched_returns -Werror_handling -Wrace_conditions -Wunderspecs /Users/elixir/code/ch10a/ex1-guards/_build/dev/lib/drop/ebin Proceeding with analysis... drop.ex:15: Function wrongness/0 has nolocal
return
drop.ex:18: The call'Elixir.Drop'
:fall_velocity(
'jupiter'
,20)
will neverreturn
since it differs in the 1st argument from the success typing arguments:(
'earth'
|
'mars'
|
'moon'
,number())
drop.ex:19: The call'Elixir.Drop'
:fall_velocity(
'earth'
,<<_:16>>)
will neverreturn
since it differs in the 2nd argument from the success typing arguments:(
'earth'
|
'mars'
|
'moon'
,number())
done
in 0m3.27sdone
(
warnings were emitted)
Dialyzer compiles your file and then checks it. All the -W
items on the dialyzer
command line tell what things Dialyzer will give warning messages for in addition to the default things that it warns about.
The first error: Function wrongness/0 has no local return
means that the function never returns a value, because it has other errors in it. (If wrongness/0
had no errors, but called functions that did have errors, Dialyzer would give you the same error.)
The second error tells you that the call fall_velocity(:jupiter, 20)
(which Dialyzer punctuates differently, as it belongs to the Erlang universe) won’t work because there is no pattern defined with :jupiter
as the first argument.
The last error shows the power of Dialyzer. Even though the code hasn’t given a @spec
for drop/1
, Dialyzer mystically divined that the second argument must be a number, which makes fall_velocity(:earth, "20")
incorrect. (OK, it’s not mystical. It’s a really good algorithm.)
Dialyzer is an excellent tool and can do a lot on its own, but you can assist it in doing its job (and readers of your code in doing theirs) by explicitly specifying the types of parameters and return values of your functions. Consider the following module that implements several gravity-related equations:
defmodule
Specs
do
@spec
fall_velocity
({
atom
(),
number
()},
number
())
::
float
()
def
fall_velocity
({
_planemo
,
gravity
},
distance
)
when
distance
>
0
do
:math
.
sqrt
(
2
*
gravity
*
distance
)
end
@spec
average_velocity_by_distance
({
atom
(),
number
()},
number
())
::
float
()
def
average_velocity_by_distance
({
planemo
,
gravity
},
distance
)
when
distance
>
0
do
fall_velocity
({
planemo
,
gravity
},
distance
)
/
2.0
end
@spec
fall_distance
({
atom
(),
number
()},
number
())
::
float
()
def
fall_distance
({
_planemo
,
gravity
},
time
)
when
time
>
0
do
gravity
*
time
*
time
/
2.0
end
def
calculate
()
do
earth_v
=
average_velocity_by_distance
({
:earth
,
9.8
},
10
)
moon_v
=
average_velocity_by_distance
({
:moon
,
1.6
},
10
)
mars_v
=
average_velocity_by_distance
({
3.71
,
:mars
},
10
)
IO
.
puts
(
"After 10 seconds, average velocity is:"
)
IO
.
puts
(
"Earth:
#{
earth_v
}
m."
)
IO
.
puts
(
"Moon:
#{
moon_v
}
m."
)
IO
.
puts
(
"Mars:
#{
mars_v
}
m."
)
end
end
Each of these functions takes a tuple giving a planemo name and its gravitational acceleration as its first parameter, and a distance or time (as appropriate) for its second parameter. You may have noticed that the calculate/1
function has an error in the calculation for mars_dist
; it uses a string instead of a number. The compiler won’t catch the error, so you get this result from Iex:
iex(1)>
Specs
.
calculate
()
** (ArithmeticError) bad argument in arithmetic expression
(specs) lib/specs.ex:15: Specs.fall_distance/2
(specs) lib/specs.ex:26: Specs.calculate/0
iex(1)>
Dialyzer, however, with the assistance of @spec
, will tell you there is a problem:
$
mix dialyzer Starting Dialyzer dialyzer --no_check_plt --plt /home/david/.dialyxir_core_19_1.3.1.plt -Wunmatched_returns -Werror_handling -Wrace_conditions -Wunderspecs /Users/elixir/code/ch10a/ex2-specs/_build/dev/lib/specs/ebin Proceeding with analysis... specs.ex:23: Function calculate/0 has nolocal
return
specs.ex:26: The call'Elixir.Specs'
:average_velocity_by_distance({
3.70999999999999996447,'mars'
}
,10)
will neverreturn
since the success typing is({
atom()
,number()}
,number())
-> float()
and the contract is({
atom()
,number()}
,number())
-> float()
done
in 0m3.04sdone
(
warnings were emitted)
This is one of those instances when using a @spec
gives a less clear message from Dialyzer; without the @spec
, you get this error instead:
specs.ex:26: The call'Elixir.Specs'
:average_velocity_by_distance({
3.70999999999999996447,'mars'
}
,10)
will neverreturn
since it differs in the 1st argument from the success typing arguments:({
atom()
,number()}
,number())
In either case, however, Dialyzer alerts you to a problem.
There is a lot of duplication in the @spec
s. You can eliminate that duplication by creating a typesepc (type specification) of your own. In ch10a/ex3-type, we define a planetuple
type and use it in the
@spec
for each function:
defmodule
NewType
do
@type
planetuple
::
{
atom
(),
number
()}
@spec
fall_velocity
(
planetuple
,
number
())
::
float
()
def
fall_velocity
({
_planemo
,
gravity
},
distance
)
when
distance
>
0
do
:math
.
sqrt
(
2
*
gravity
*
distance
)
end
@spec
average_velocity_by_distance
(
planetuple
,
number
())
::
float
()
def
average_velocity_by_distance
({
planemo
,
gravity
},
distance
)
when
distance
>
0
do
fall_velocity
({
planemo
,
gravity
},
distance
)
/
2.0
end
@spec
fall_distance
(
planetuple
,
number
())
::
float
()
def
fall_distance
({
_planemo
,
gravity
},
time
)
when
time
>
0
do
gravity
*
time
*
time
/
2.0
end
def
calculate
()
do
earth_v
=
average_velocity_by_distance
({
:earth
,
9.8
},
10
)
moon_v
=
average_velocity_by_distance
({
:moon
,
1.6
},
10
)
mars_v
=
average_velocity_by_distance
({
3.71
,
:mars
},
10
)
IO
.
puts
(
"After 10 seconds, average velocity is:"
)
IO
.
puts
(
"Earth:
#{
earth_v
}
m."
)
IO
.
puts
(
"Moon:
#{
moon_v
}
m."
)
IO
.
puts
(
"Mars:
#{
mars_v
}
m."
)
end
end
If you want a custom type to be private, use @typep
instead of @type
; if you want it to be
public without showing its internal structure, use @opaque
. For a complete list of all the built-in type
specifications as well as those defined by Elixir, see http://elixir-lang.org/docs/stable/elixir/typespecs.html.
In addition to static analysis and defining @spec
s for your functions, you can avoid some debugging by adequately testing your code beforehand, and Elixir has a unit-testing module named ExUnit
to make this easy for you.
To demonstrate ExUnit
, we use Mix to create a new project named drop
. In the lib/drop.ex file, we wrote a Drop
module with an error in it. The gravity constant for Mars has been accidentally mistyped as 3.41
instead of 3.71
(someone’s finger slipped on the numeric keypad):
defmodule Drop do def fall_velocity(planemo, distance) do gravity = case planemo do :earth -> 9.8 :moon -> 1.6 :mars -> 3.41 end :math.sqrt(2 * gravity * distance) end end
In addition to the lib directory, Mix has already created a test directory. If you look in that directory, you will find two files with an extension of .exs: test_helper.exs and drop_test.exs. The .exs extension indicates that these are script files, which do not need to be compiled. The test_helper.exs file sets up ExUnit
to run automatically. You then define tests in the drop_test.exs file using the test
macro. Here are two tests. The first tests that a distance of zero returns a velocity of zero, and the second tests that a fall of 10 meters on Mars produces the correct answer. Save this in a file named drop_test.exs:
defmodule
DropTest
do
use
ExUnit.Case
,
async
:
true
test
"Zero distance gives zero velocity"
do
assert
Drop
.
fall_velocity
(
:earth
,
0
)
==
0
end
test
"Mars calculation correct"
do
assert
Drop
.
fall_velocity
(
:mars
,
10
)
==
:math
.
sqrt
(
2
*
3.71
*
10
)
end
end
The use
line allows Elixir to run the test cases in parallel.
A test begins with the macro test
and a string that describes the test. The content of the test
consists of executing some code and then asserting
some condition. If the result of executing the code is true
, then the test passes; if the result is false
, the test fails.
To run the tests, type mix test
at the command line:
$ mix test Compiling 1 file (.ex) Generated drop app . 1) test Mars calculation correct (DropTest) test/drop_test.exs:8 Assertion with == failed code: Drop.fall_velocity(:mars, 10) == :math.sqrt(2 * 3.71 * 10) lhs: 8.258329128825032 rhs: 8.613942186943213 stacktrace: test/drop_test.exs:9: (test) Finished in 0.06 seconds 2 tests, 1 failure Randomized with seed 585665
The line starting .
indicates the status of each test; a .
means that the test succeeded.
Fix the error by going into the Drop
module and changing Mars’s gravity constant to the correct value of 3.71. Then run the test again, and you will see what a successful test looks like:
$ mix test Compiling 1 file (.ex) .. Finished in 0.05 seconds 2 tests, 0 failures Randomized with seed 811304
In addition to assert/1
, you may also use refute/1
, which expects the condition you are testing to be false
in order for a test to pass. Both assert/1
and refute/1
automatically generate an appropriate message. There is also a two-argument version of each function that lets you specify the message to produce if the assertion or refutation fails.
If you are using floating-point operations, you may not be able to count on an exact result. In that case, you can use the assert_in_delta/4
function. Its four arguments are the expected value, the value you actually received, the delta, and a message. If the expected and received values are within delta of each other, the test passes. Otherwise, the test fails and ExUnit
prints your message. Here is a test to see if a fall velocity from a distance of one meter on Earth is close to 4.4 meters per second. You could add the test to the current drop_test.exs file, or you can (as we have), create a new file named drop2_test.exs in the test directory.
defmodule Drop2Test do use ExUnit.Case, async: true test "Earth calculation correct" do calculated = Drop.fall_velocity(:earth, 1) assert_in_delta calculated, 4.4, 0.05, "Result of #{calculated} is not within 0.05 of 4.4" end end
If you want to see the failure message, add a new test to require the calculation to be more precise, and save it. (This version is in file ch10/ex4-testing/test/drop3_test.exs.)
defmodule Drop3Test do use ExUnit.Case, async: true test "Earth calculation correct" do calculated = Drop.fall_velocity(:earth, 1) assert_in_delta calculated, 4.4, 0.0001, "Result of #{calculated} is not within 0.0001 of 4.4" end end
This is the result:
$
mixtest
.. 1)
test
Earth calculation correct(
Drop3Test)
test
/drop3_test.exs:4 Result of 4.427188724235731 is not within 0.0001 of 4.4 stacktrace:test
/drop3_test.exs:6:(
test
)
. Finished in 0.08 seconds4
tests,1
failure Randomized with seed 477713
You can also test that parts of your code will correctly raise exceptions. These following two tests will check that an incorrect planemo and a negative distance actually cause errors. In each test, you wrap the code you want to test in an anonymous function. You can find these additional tests in file ch10/ex4-testing/test/drop4_test.exs:
defmodule
Drop4Test
do
use
ExUnit.Case
,
async
:
true
test
"Unknown planemo causes error"
do
assert_raise
CaseClauseError
,
fn
->
Drop
.
fall_velocity
(
:planetX
,
10
)
end
end
test
"Negative distance causes error"
do
assert_raise
ArithmeticError
,
fn
->
Drop
.
fall_velocity
(
:earth
,
-
10
)
end
end
end
You can also specify code to be run before and after each test, as well as before any tests start and after all tests finish. For example, you might want to make a connection to a server before you do any tests, and then disconnect when the tests finish.
To specify code to be run before any of the tests, you use the setup_all
callback. This callback should return :ok
and, optionally, a keyword list that is added to the testing context, which is an Elixir Map
you may access from your tests. Consider this code, which you will find in ch10a/ex5-setup:
setup_all
do
IO
.
puts
"Beginning all tests"
on_exit
fn
->
IO
.
puts
"Exit from all tests"
end
{
:ok
,
[
connection
:
:fake_PID
}]
}
end
This code adds a :connection
keyword to the context for the tests; the on_exit
specifies code to be run after all the tests finish.
>Code to be run before and after each individual test is specified via setup
. This code accesses the context:
setup
context
do
IO
.
puts
"About to start a test. Connection is
#{
Map
.
get
(
context
,
:connection
)
}
"
on_exit
fn
->
IO
.
puts
"Individual test complete."
end
:ok
end
Finally, you may access the context within an individual test, as shown here:
test
"Zero distance gives zero velocity"
,
context
do
IO
.
puts
"In zero distance test. Connection is
#{
Map
.
get
(
context
,
:connection
)
}
"
assert
Drop
.
fall_velocity
(
:earth
,
0
)
==
0
end
Here is the result of running the tests; we have set async: false
so that you can see all the output in order; if you set async: true
, tests are run in parallel, so the order may not be as easy to determine from the output:
$
mixtest
Beginning all tests About to start a test. Connection is fake_PID In zero distancetest
, connection is fake_PID Individualtest
complete. .About to start a test. Connection is fake_PID Test two Individualtest
complete. .Exit from all tests Finished in 0.08 seconds2
tests,0
failures Randomized with seed 519579
There is one other way to do tests: by embedding them in the documentation for your functions and modules, which is referred to as doctest. In this case, your test script looks like this:
defmodule
DropTest
do
use
ExUnit.Case
,
async
:
false
doctest
Drop
end
Following doctest
is the name of the module you want to test. doctest
will look through the module’s documentation for lines that look like commands and output from IEx. These lines begin with iex>
or iex(n)>
where n is a number; the following line is the expected output. Blank lines indicate the beginning of a new test. The following code shows an example, which you may find in ch10a/ex6-doctest:
defmodule
Drop
do
@doc
"""
Calculates speed of a falling object on a given planemo
(planetary mass object)
iex(1)> Drop.fall_velocity(:earth, 10)
14.0
iex(2)> Drop.fall_velocity(:mars, 20)
12.181953866272849
iex> Drop.fall_velocity(:jupiter, 10)
** (CaseClauseError) no case clause matching: :jupiter
"""
def
fall_velocity
(
planemo
,
distance
)
do
gravity
=
case
planemo
do
:earth
->
9.8
:moon
->
1.6
:mars
->
3.71
end
:math
.
sqrt
(
2
*
gravity
*
distance
)
end
end
Elixir’s testing facilities also allow you to test whether messages have been received or not, write functions to be shared among tests, and much more. The full details are available in the Elixir documentation.
18.188.154.252