...

/

Our First Test

Our First Test

Learn how to use ExUnit by writing our first test.

From the beginning, Elixir was developed with ExUnit, Elixir’s testing framework, as part of the core library. As a result, most of the test tooling we’ve come to utilize in our production applications is straight out of the Elixir core. This means that if we have Elixir and are familiar with what’s available, we can write effective tests without bringing in other libraries.

Write our first test

Let’s write our first test in our rain alert app, Soggy Waffle. Soggy Waffle makes calls to an API and gets data back from it. It then standardizes that data according to its terms and data structures for the rest of the application. While those calls to the API aren’t something we’ll focus on in the course, the response transformation is an excellent example of code that can be tested in isolation. Let’s take a look at the code we’ll be testing.

Note: If you want to code along with the examples, you should do so inside the provided Soggy Waffle application, adding test files in the locations we indicate before the examples. Don’t worry though, because we will be providing these updates as we go along the course.

In theory, Soggy Waffle could work with any weather API as long as it returns a timestamp and the weather conditions. As a best practice, our application translates the API response into a list of SoggyWaffle.Weather structs that it defines in lib/soggy_waffle/weather.ex with two fields, one for the timestamp, datetime, and one for whether or not the weather condition translates to rain.

#file path -> soggy_waffle/lib/soggy_waffle/weather.ex
#add this code at the indicated place mentioned in comments of soggy_waffle/lib/soggy_waffle/
#weather.ex in the playground widget
defstruct [:datetime, :rain?]
@spec imminent_rain?([t()], DateTime.t()) :: boolean()
def imminent_rain?(weather_data, now \\ DateTime.utc_now()) do
Enum.any?(weather_data, fn
%__MODULE__{rain?: true} = weather ->
in_next_4_hours?(now, weather.datetime)
_ ->
false
end)
end
defp in_next_4_hours?(now, weather_datetime) do
four_hours_from_now =
DateTime.add(now, _4_hours_in_seconds = 4 * 60 * 60)
DateTime.compare(weather_datetime, now) in [:gt, :eq] and
DateTime.compare(weather_datetime, four_hours_from_now) in [:lt, :eq]
end

The SoggyWaffle.Weather struct is defined in line 4.

This means that our ResponseParser module will hold a lot of information specific to the API we choose. In this case, that’s openweathermap.org. Notice the module attributes in the code as well as the comment about where that information came from.

#file path -> soggy_waffle/lib/soggy_waffle/weather_api/response_parser.ex
#add this code at the indicated place mentioned in comments of soggy_waffle/lib/
#soggy_waffle/weather_api/response_parser.ex in the playground widget
@thunderstorm_ids [200, 201, 202, 210, 211, 212, 221, 230, 231, 232]
@drizzle_ids [300, 301, 302, 310, 311, 312, 313, 314, 321]
@rain_ids [500, 501, 502, 503, 504, 511, 520, 521, 522, 531]
@all_rain_ids @thunderstorm_ids ++ @drizzle_ids ++ @rain_ids
@spec parse_response(Weather.t()) ::
{:ok, list(Weather.t())} | {:error, atom()}
def parse_response(response) do
results = response["list"]
Enum.reduce_while(results, {:ok, []}, fn
%{"dt" => datetime, "weather" => [%{"id" => condition_id}]},
{:ok, weather_list} ->
new_weather = %Weather{
datetime: DateTime.from_unix!(datetime),
rain?: condition_id in @all_rain_ids
}
{:cont, {:ok, [new_weather | weather_list]}}
_anything_else, _acc ->
{:halt, {:error, :response_format_invalid}}
end)
end

Our test will need to pass in a response, and it’ll expect the response data to be in the shape of a list of SoggyWaffle.Weather structs that it’s defined.

Now let’s open up a new test file at soggy_waffle/test/soggy_waffle/weather_api/response_parser_test.exs in the provided Soggy Waffle code and start writing our first test!

#file path -> soggy_waffle/test/soggy_waffle/weather_api/response_parser_test.exs
#add this code at the indicated place mentioned in comments of soggy_waffle/
#test/soggy_waffle/weather_api/response_parser_test.exs
#in the playground widget
describe "parse_response/1" do
test "success: accepts a valid payload, returns a list of structs" do
api_response = %{
"list" => [
%{"dt" => 1_574_359_200, "weather" => [%{"id" => 600}]},
%{"dt" => 1_574_359_900, "weather" => [%{"id" => 299}]}
]
}
assert {:ok, parsed_response} =
ResponseParser.parse_response(api_response)
for weather_record <- parsed_response do
assert match?(
%Weather{datetime: %DateTime{}, rain?: _rain},
weather_record
)
assert is_boolean(weather_record.rain?)
end
end
end

Before running our test, let’s first go over what it’s doing. This is a pretty basic test, but already quite a bit is going on. An assert macro is being called (line 13), and there are assertions inside a list comprehension (line 16). Let’s look at each piece separately.

Assert macro

The assert call at line 13 works because if the function evaluates to true or something “truthy,” the assert will pass. In our case, we’re handing it a pattern match. If a successful pattern matches, it returns the data that matched the pattern, which is “truthy.” If we want to assert that a list is empty, use Enum.empty?/1. ExUnit also provides the opposite, refute, which will only pass if the expression is given as a parameter that evaluates false or nil.

Note: Be careful, though, as refute doesn’t work with a pattern match in the way you’d expect. If a pattern doesn’t match, a MatchError is raised. Also, don’t make the common mistake of thinking refute will pass with an empty list (refute []). The refute function will only pass if the expression it’s given evaluates to false or nil. The [] list is empty, but it’s still a list.

List comprehensions

The last part for us to look at is the list comprehension at line 16. A list comprehension here means that we want to apply the same assertions to multiple pieces of data.

Note: An Enum. each would work just as well here, and the choice of a list comprehension is purely a style choice in this case.

Using a list comprehension implies an assertion about the shape of our response—that it’s enumerable. If the result can’t be iterated through, the test will raise a Protocol.UndefinedError, revealing the bad value.

Let’s now look at the assertions inside the code block.

assert match?(
         %Weather{datetime: %DateTime{}, rain?: _rain},
         weather_record
       )

The match?/2 function is the other way of asserting on a pattern match. This assertion is focused solely on the shape of the data and not on the values provided. Even though match? doesn’t bind a value like using = would, we’ll still get a compile warning if we don’t use an _ before the variable in the pattern.

assert is_boolean(weather_record.rain?)

This assertion still focuses on the shape of the data and not on a specific value because converting weather IDs is additional functionality. Unit tests are typically quick to run, and keeping them focused on a single aspect of our code can make finding errors faster and easier. We’ll add other tests shortly to focus on specific values in the response.

Try it yourself

Update the files and then press “Run” to verify the test. If the test is written correctly, it’ll pass, and we’ll see a lovely green dot representing a passed test.

defmodule SoggyWaffle.MixProject do
  use Mix.Project

  def project do
    [
      app: :soggy_waffle,
      version: "0.1.0",
      elixir: "~> 1.9",
      start_permanent: Mix.env() == :prod,
      deps: deps()
    ]
  end

  # Run "mix help compile.app" to learn about applications.
  def application do
    [
      extra_applications: [:logger]
    ]
  end

  # Run "mix help deps" to learn about dependencies.
  defp deps do
    [
      {:httpoison, "~> 1.6"},
      {:jason, "~> 1.1"},
      # test only
      {:exvcr, "~> 0.11.0", only: [:test]}
    ]
  end
end
Playground widget

Access this course and 1200+ top-rated courses and projects.