Chapter 11. Static Analysis, Typespecs, and Testing

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

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:

Example 11-1. Erroneous calls to clauses
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/OTP 19 [erts-8.0] [source] [64-bit] [smp:4:4] [async-threads:10] [hipe] [kernel-poll:false]

Compiling 1 file (.ex)
Generated drop app
Interactive Elixir (1.3.1) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> Drop.wrongness()
** (FunctionClauseError) no function 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
Compiling 1 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 no local return
drop.ex:18: The call 'Elixir.Drop':fall_velocity('jupiter',20) will never return
  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
  never return since it differs in the 2nd argument from the success
  typing arguments: ('earth' | 'mars' | 'moon',number())
 done in 0m3.27s
done (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.)

Typespecs

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 no local return
specs.ex:26: The call 'Elixir.Specs':average_velocity_by_distance({3.70999999999999996447, 'mars'},10)
  will never return since the success typing is ({atom(),number()},number()) -> float()
  and the contract is ({atom(),number()},number()) -> float()
 done in 0m3.04s
done (warnings were emitted)
Note

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 never return 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 @specs. 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.

Writing Unit Tests

In addition to static analysis and defining @specs 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:

$ mix test
..

  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 seconds
4 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

Setting up Tests

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:

$ mix test
Beginning all tests
About to start a test. Connection is fake_PID
In zero distance test, connection is fake_PID
Individual test complete.
.About to start a test. Connection is fake_PID
Test two
Individual test complete.
.Exit from all tests


Finished in 0.08 seconds
2 tests, 0 failures

Randomized with seed 519579

Embedding Tests in Documentation

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.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.188.154.252