IO.puts("Hello, World")
2. Hello, World
Let’s continue with tradition and start with a program that prints out "Hello, World" to console.
Create a new directory, learn-elixir-with-tests/
to contain the code we will work through throughout this book.
Within that directory, create a new hello-world/
directory to contain the code for this chapter.
In hello-world/
, create a file named hello.exs
file, typing in the following code:
After saving the file, you can run it in your terminal with elixir hello.exs
, and see the expected output.
Our minimal code uses a call to IO.puts
to print out a string to standard output, which in turn is written onto the terminal screen by your shell.
2.1. How to test
Our code works as expected, but how do we test it? As it stands, our code does two things: creates a string and prints it to standard output. The latter is hard to test, but we can separate out the former to make that part testable. Generally, its a good patten to separate out the core "domain" or "business logic" code out from its external "side effects" (in this case, printing to standard output) so that it is easy to test in isolation.
So let’s separate the creation of the string from the printing of it.
Modify hello.exs
to contain instead the following code:
defmodule HelloWorld do
def greet do
"Hello, World"
end
end
IO.puts(HelloWorld.greet())
Here we use defmodule
to create a new module.
In Elixir, functions are organized into modules, similar to how methods are organized into classes for most object-oriented languages.
In our new module, we define our hello
function which returns the "Hello, World" string. We then call it at the bottom of our file.
You can run elixir hello.exs
again, and verify that we are still getting the same expected output of "Hello, World" printed to our screen.
2.1.1. Writing our first test
With this refactored code, we can now create our first test. Create a new hello_test.exs
file in the same directory, and add the following:
Code.require_file("hello.exs")
ExUnit.start()
defmodule HelloWorldTest do
use ExUnit.Case
test "greets the world" do
assert HelloWorld.greet() == "Hello, World"
end
end
In our test file, we use Code.require_file
to load our HelloWorld
definition from hello.exs
.
Before defining our test, we invoke ExUnit.start
to start up ExUnit so it can run the tests we define.
Within our HelloWorldTest
module, we use ExUnit.Case
to "hook into" ExUnit.
It creates the test
macro that we use to define our test, which ExUnit then runs.
And finally, in our test itself we assert
that the string returned from our hello
function is as expected.
The assert
macro will fail the test if its argument is not true.
What is a "macro"?
Macros are code that generate other code at compile time. They are an advanced concept we won’t cover until much later in the book. For now, think of them as a special progamming trick that Elixir uses to make some functionality easier for us to use. |
2.1.2. Running our first test
You can run the test with elixir hello_test.exs
. You should see output similar to:
$ elixir hello_test.exs
Hello, World
.
Finished in 0.06 seconds (0.06s on load, 0.00s on tests)
1 test, 0 failures
Randomized with seed 40368
The output shows how many tests were run and how many of those tests failed. As it runs it outputs a dot (which should be green if your terminal supports colors) for each successful test executed, in real time.
It also mentions a "randomized seed". Each time ExUnit runs, it executes the queued tests in a randomized order. That "seed" represents, in an indirect way, the specific order in which the tests ran this time, used for advanced test debugging. We won’t need to concern ourselves with this part of the output for any of the exercises in this book.
Lastly, the test output also has "Hello, World" at the top.
This is because we never removed the IO.puts
call at the end of our main hello.exs
file.
When we did Code.require_file
in hello_test.exs
to load our HelloWorld
module, Elixir ran everything in the file.
It ran the code within the defmodule
block to define our module, but also that IO.puts
statement, which is why "Hello, World" is printed out before our test even ran!
2.1.3. Cleanup: .exs and .ex
Right now our hello.exs
file is trying to do two different things.
First, it defines a HelloWorld
module to encompass a testable and reusable way to greet the world.
It’s trivially simple now, but we will be greatly expanding the functionality later in this chapter.
Secondly, hello.exs
has a small "script" at the end that directly runs the code contained in our module.
Whenever we have two concerns like this, it is a good idea to separate them out.
As we’ve seen, this way of structuring things has already lead to a minor problem around that print statement, IO.puts(HelloWorld.greet())
running during our tests, muddying our output.
Other developers in the future may want to use our module and will hit a similar issue. Everytime they try to load our module, we’ll be printing out into their console.
An undesirable side-effect, indeed!
In Elixir, we differentiate between files meant for "scripting" and files meant for "reusable libraries" by the file extension.
.exs
files are meant to be run directly, and are expected to have side-effects when run.
.ex
files are meant to be compiled and run, but to have no side-effects when they are initially loaded.
Let’s apply these principals by refactoring our implementation.
First, let’s rename hello.exs
to hello.ex
to make it clear its not a script file. Additionally, let’s remove the print statement from the file, too.
defmodule HelloWorld do
def greet do
"Hello, World"
end
end
Since we renamed our file, we need to update the import in our test, too.
A test is meant to be invoked directly and is not meant to be re-used, so we will keep the .exs
extension.
Code.require_file("hello.ex")
ExUnit.start()
defmodule HelloWorldTest do
use ExUnit.Case
test "greets the world" do
assert HelloWorld.greet() == "Hello, World"
end
end
Lastly, we will create a new script.exs
file to contain our small little script.
If we really want to print that beautiful "Hello, World" string to our console, we can run that directly!
Code.require_file("hello.ex")
IO.puts(HelloWorld.greet())
Nice! We can verify everything is still working by running our test and seeing the rogue "Hello, World" printing is gone:
$ elixir hello_test.exs
.
Finished in 0.03 seconds (0.03s on load, 0.00s on tests)
1 test, 0 failures
Randomized with seed 194747
And if we invoke our new script file directly, we can also see that things are working on that front, too:
$ elixir script.exs
Hello, World
With that out of the way, we can move onto making our HelloWorld
even more featureful.
2.2. Hello, Reader
It’s time to make our module a bit more intimate.
Let’s change greet
to take in a string that specifies who to greet.
If we call it with "Reader", it should respond "Hello, Reader".
2.2.1. TDD and arity
Let’s start with the test:
Code.require_file("hello.ex")
ExUnit.start()
defmodule HelloWorldTest do
use ExUnit.Case
test "greets the world" do
assert HelloWorld.greet() == "Hello, World"
end
test "greets the noun that is passed in" do
assert HelloWorld.greet("Reader") == "Hello, Reader"
end
end
We’ve created a new test that asserts what we outlined above. Now, we’re going to run it. We run it even though we haven’t implemented the functionality to make sure our test is working. If it is, then it should fail. You would be surprised, but its not uncommon to write a test that doesn’t quite work, that always passes because the test itself was incorrect.
When you run it, you’ll see something like this:
$ elixir hello_test.exs
.
1) test greets what is passed in (HelloWorldTest)
hello_test.exs:11
** (UndefinedFunctionError) function HelloWorld.greet/1 is undefined or private
code: assert HelloWorld.greet("Reader") == "Hello, Reader"
stacktrace:
HelloWorld.greet("Reader")
hello_test.exs:12: (test)
Finished in 0.03 seconds (0.03s on load, 0.00s on tests)
2 tests, 1 failure
Randomized with seed 340990
We see one dot for our first test, which is still passing.
Our new test fails because our greet
function is not implemented.
Specifically, there is no HelloWorld.greet/1
implemented.
In Elixir, a function definition consists of two parts: the name and the number of parameters, or arity.
The function we do have implemented is actually HelloWorld.greet/0
, and that is the one our first test uses with great success.
2.2.2. String concat operator (<>
)
Let’s try fixing the error and getting the test to pass by adding a new greet/1
function.
defmodule HelloWorld do
def greet do
"Hello, World"
end
def greet(noun) do
"Hello, " <> noun
end
end
The string concatenation operator in Elixir is <>
.
More specifically, it is the binary concatenation operator.
All strings in Elixir are represented by a contiguous sequence of bytes known as binaries.
If that sounds obscure, don’t worry - we’ll dig more into it in a later chapter.
For now, just know that we can append two binaries with the <>
operator, and strings are technically binaries, so we can use it to append two strings, too.
If we run elixir hello_test.exs
again, we will see that all tests are passing. Sweet!
2.2.3. Default parameter values
Now that our tests are passing, we can look to see if there is anyway to make our code a bit more elegant or simple. The tests we have allows us to change things around with confidence, as we know that any mistake we may make will be caught by them.
In both greet
functions, we have a "Hello" string that is shared.
That indicates to us that there is some degree of duplication across the two functions that we can eliminate.
The functions are of course quite similar, the only difference being that greet/0
has a hard-coded noun of "World".
In fact, we can make that more clear by changing the code like so, to leverage greet/1
and thus eliminate the duplication of the "Hello" string:
defmodule HelloWorld do
def greet do
greet("World")
end
def greet(noun) do
"Hello, " <> noun
end
end
This pattern of having lower-arity functions that pass along certain values to call higher-arity functions with is quite common in Elixir.
It’s so common that Elixir provides a special operator to do it more succinctly, the \\
default argument operator.
Update your module with the code below to see it in action:
defmodule HelloWorld do
def greet(noun \\ "World") do
"Hello, " <> noun
end
end
The Elixir compiler under the hood will do exactly what we just did manually: define a greet/0
function that calls greet/1
with the default value of "World".
With our refactor done, run your tests once more to verify everything is working. This is what TDD empowers us to do: pursue elegance with confidence, knowing that the tests will keep us within safe bounds.
2.2.4. The TDD cycle
With this change, we’ve seen how the Test-Driven Development (TDD) cycle works. It’s pretty simple:
-
Write the test first; verify that is runs and fails correctly.
-
Write the minimal amount of code that makes the test pass.
-
Refactor the code, leveraging the tests to ensure the code still works.
This process is sometimes referred to more simply as "red, green, blue". Red to represent the usual color of the failing test in the terminal, green to represent the color of the passing test, and blue to arbitrarily represent the refactoring step.
Throughout this book, we will use this cycle as we explore new Elixir concepts. You may encounter many who propose that this sort of TDD cycle should be used for all code you create. I’m not personally so dogmatic: I mainly use TDD here because I think it is a great way to learn a new language, as the tight feedback cycle familiarizes one with invoking ones code and the sort of runtime and compiler errors the language has. I like to think of TDD as another tool in a software developer’s toolkit, and by the end of this book you will be well on your way to mastering the style.
2.3. Bonjour, monsieur
Right now our code only says "hello" in English.
Let’s make it so that it can greet us in other languages, too.
We will modify our greet
function to additionally take in a language
parameter, and use that to determine which greeting to use.
Let’s start, as always, with a test:
Code.require_file("hello.ex")
ExUnit.start()
defmodule HelloWorldTest do
use ExUnit.Case
test "greets the world" do
assert HelloWorld.greet() == "Hello, World"
end
test "greets the noun that is passed in" do
assert HelloWorld.greet("Reader") == "Hello, Reader"
end
test "greets the noun in French" do
assert HelloWorld.greet("monsieur", "French") == "Bonjour, monsieur"
end
end
We’ve created a test that calls a yet-to-be-defined HelloWorld.greet/2
function, passing in the desired greeting language as the second parameter.
When we run the test to verify it is failing correctly, we see a familiar error about no greet/2
function defined:
1) test greets the noun in French (HelloWorldTest)
hello_test.exs:15
** (UndefinedFunctionError) function HelloWorld.greet/2 is undefined or private. Did you mean one of:
* greet/0
* greet/1
This UndefinedFunctionError
is exactly what we would expect, since we have not defined the function.
It is always useful to verify that the test not only fails, but fails in the way you expect it to, as it does here.
By getting to know the various types of compile-time and runtime failures Elixir has, and what they look like,
we build up a deeper understanding of the language itself. In programming, knowing why something doesn’t work is
very close to knowing what needs to be changed to make it work.
2.3.1. Flow control with if
and else
Let’s make the change to fix our failing test.
We need to add a new function greet/2
that takes in our new language
parameter.
That paramater needs to default to "English" if not explicitly provided, so our existing tests keep on working as before.
defmodule HelloWorld do
def greet(noun \\ "World", language \\ "English") do
if language == "French" do
"Bonjour, " <> noun
else
"Hello, " <> noun
end
end
end
Here we use if
and else
to determine what greeting to use.
The statement next to the if
is evaluated, and if it evaluates to a truthy value, the block underneath it is executed.
If the statement evaluates to a falsy value, then the else
block is executed instead.
If we run our tests again, we will see they are all passing.
$ elixir hello_test.exs
...
Finished in 0.07 seconds (0.06s on load, 0.00s async, 0.01s sync)
3 tests, 0 failures
Randomized with seed 432929
2.4. Hola, mundo
Two languages is cool and all, but let’s finish it up by supporting a third: Spanish.
Let’s add a test. It’ll be pretty similar to the previous one:
Code.require_file("hello.ex")
ExUnit.start()
defmodule HelloWorldTest do
use ExUnit.Case
test "greets the world" do
assert HelloWorld.greet() == "Hello, World"
end
test "greets the noun that is passed in" do
assert HelloWorld.greet("Reader") == "Hello, Reader"
end
test "greets the noun in French" do
assert HelloWorld.greet("monsieur", "French") == "Bonjour, monsieur"
end
test "greets the noun in Spanish" do
assert HelloWorld.greet("mundo", "Spanish") == "Hola, mundo"
end
end
If you run the tests, you will see it fail with a nicely formatted error:
1) test greets the noun in Spanish (HelloWorldTest)
hello_test.exs:19
Assertion with == failed
code: assert HelloWorld.greet("mundo", "Spanish") == "Hola, mundo"
left: "Hello, mundo"
right: "Hola, mundo"
stacktrace:
hello_test.exs:20: (test)
The error message says the code on the left side of the ==
operator evaluates to "Hello, mundo"
, while right side says it should be "Hola, mundo"
.
For this error, the issue is not having a function with the correct arity, but instead our existing greet/2
function not providing the correct behavior.
2.4.1. Handling cases with case
To get our test to pass, we need to handle this third case in our existing code.
In addition to if
and else
for when you have one or two different cases to handle in your code,
Elixir also has the conveniently named case
expression for handling any number of cases.
Here we can see it in action:
defmodule HelloWorld do
def greet(noun \\ "World", language \\ "English") do
case language do
"French" ->
"Bonjour, " <> noun
"Spanish" ->
"Hola, " <> noun
"English" ->
"Hello, " <> noun
end
end
end
To use case
, we provide a paramater and then define a series of clauses that could match the parameter.
After each clause we have an arrow ->
and then the code we want to execute if the parameter matches that clause.
If we run our tests, we see that everything is passing:
$ elixir hello_test.exs
....
Finished in 0.04 seconds (0.04s on load, 0.00s async, 0.00s sync)
4 tests, 0 failures
Randomized with seed 151267
2.4.2. Pattern matching with case
Our HelloWorld
module is looking pretty good, but it currently has one major flaw: it doesn’t
handle the case of an unknown language. If I call it with the "Gibberish"
language, for example,
it won’t know what to do!
Let’s update it to handle this case. As always, before we start adding any functionality, let us first add a test outlining what we want:
Code.require_file("hello.ex")
ExUnit.start()
defmodule HelloWorldTest do
use ExUnit.Case
test "greets the world" do
assert HelloWorld.greet() == "Hello, World"
end
test "greets the noun that is passed in" do
assert HelloWorld.greet("Reader") == "Hello, Reader"
end
test "greets the noun in French" do
assert HelloWorld.greet("monsieur", "French") == "Bonjour, monsieur"
end
test "greets the noun in Spanish" do
assert HelloWorld.greet("mundo", "Spanish") == "Hola, mundo"
end
test "apologizes to the noun if unknown language" do
assert HelloWorld.greet("Bob", "Gibberish") == "Sorry, Bob. I can't speak Gibberish!"
end
end
If we run the test, we will see an error:
1) test greets the noun in English if unknown language (HelloWorldTest)
hello_test.exs:23
** (CaseClauseError) no case clause matching: "Gibberish"
code: assert HelloWorld.greet("Bob", "Gibberish") == "Sorry, Bob. I can't speak Gibberish!"
stacktrace:
hello.ex:3: HelloWorld.greet/2
hello_test.exs:24: (test)
Here our test hit an error when executing. That CaseClauseError
says that our case
statement
does not have a clause that matches the parameter, "Gibberish"
. What we want is a clause that matches any language
parameter
we may provide to the greet/2
function.
Let’s add it:
defmodule HelloWorld do
def greet(noun \\ "World", language \\ "English") do
case language do
"French" ->
"Bonjour, " <> noun
"Spanish" ->
"Hola, " <> noun
"English" ->
"Hello, " <> noun
lang ->
"Sorry, " <> noun <> ". I can't speak " <> lang <> "!"
end
end
end
And if we run it, we see our tests pass:
$ elixir hello_test.exs
.....
Finished in 0.07 seconds (0.07s on load, 0.00s async, 0.00s sync)
5 tests, 0 failures
Randomized with seed 616791
For string and other constants, like "English"
, the match
only succeeds if the value in the clause and the parameter match exactly. If the case clause
contains a variable, however, it will match any parameter. Moreover, Elixir
will assign the value of the matched parameter into the variable.
This also means that the clauses in the case
block are matched from top to bottom. In fact,
if we moved our lang →
clause to the top, none of our other clauses would get matched, since our
variable clause matches everything!
That may all seem like a bunch of "Gibberish" to you, but don’t worry! What I’ve described is the "pattern matching" that is at the core of Elixir. We will see it again and again in how other parts of Elixir works, and build up understanding slowly but surely.
2.5. Conclusion
Our HelloWorld
module is done and fully functional. In fact, we also have a full suite of tests
proving that it works just the way we want it to! Our code is elegant and succinct, as most Elixir
code tends to be, while being clear to read. We know what it does and how it works decently well,
and that understanding, if incomplete, will be filled in more over the coming chapters.
2.5.1. What we learned in this chapter
-
.exs
files are for running directly, and often have "side effects", such as printing to console. -
.ex
files are for reusable modules, and usually do not have any "side effects". -
In Elixir, functions are defined by their name and the number of parameters they have, or arity.
-
You can concatinate strings in Elixir with the
<>
operator. -
TDD, or test-driven development, is the practice of writing the test before developing the implementation.
-
We can use
if
andelse
for handling one or two cases differently in our code. -
With
case
, we can handle any number of cases with a series of different clauses. -
With
case
, variables match any parameter, and are assigned the value of the parameter. -
Pattern matching is at the core of much of Elixir, and it is how
case
works.
2.5.2. Exercises
Here are some additional exercises you can do. Each of these involve updating the test with the new requirements, then updating the implementation to make the tests pass. As always: start with the test!
-
Update
greet
to handle the"Esperanto"
language. Hello in Esperanto is saluton. -
Change the default noun from World to Space.
-
Change the default language from English to Esperanto.