Temporary Directories For Testing Mix Tasks That Modify Files
Intro
Last time, we added a Mix task to our project that writes an HTML
file to a directory /public
in the container’s filesystem.
Today, we will write a test for that task.
Here is the code we want to write a test for.
@markup """
<!DOCTYPE html>
<html>
<head></head>
<body>hello world</body>
</html>
"""
@output_directory "/public"
@impl Mix.Task
def run(_args) do
Mix.shell().info("running build task")
File.mkdir_p!(@output_directory)
@output_directory |> Path.join("index.html") |> File.write!(@markup)
end
First pass
The task writes some HTML markup to a file,
/public/index.html
, so our test should ensure that after
the task is ran, that file exists.
test/mix/tasks/build_test.exs
defmodule Mix.Tasks.BuildTest do
use ExUnit.Case
"creates output files" do
test Mix.Tasks.Build.run([])
File.exists?("/public/index.html")
assert end
end
Let’s try running the test.
$ docker run --rm -w /opt -v $PWD:/opt hw_dev mix test
running build task
...
Finished in 0.02 seconds (0.02s async, 0.00s sync)
1 doctest, 2 tests, 0 failures
While it passes, this is not a valid test.
/public/index.html
already exists before our test runs the
build task, because our Dockerfile
tells Docker to run the
task when it builds our image. To make the test valid, we need it to use
a clean directory.
If we make the output directory configurable during runtime, we can create a temporary directory to use as a mock output directory during test runs. We need to move where the output directory is stored, from a hard-coded module attribute into something configureable at runtime.
Separating
test
and dev
environment data
Right now the value for the output directory is stored in a module attribute in the task module. If we had it stored in a variable, we could change it when we run our tests.
Configuring the output directory
1. Store the value in an application environment variable
To store it in an application environment variable, add this in
mix.exs
def application do
[
env: [output_directory: "/public"],
...
]
end
Then, in run()
in lib/mix/tasks/build.ex
,
we fetch the output directory from the application environment instead
of hard-coding it in a module attribute
def run(_args) do
Mix.shell().info("running build task")
= Application.fetch_env!(:static_site, :output_directory)
output_directory
File.mkdir_p!(output_directory)
|> Path.join("index.html") |> File.write!(@markup)
output_directory end
Now that the output directory is stored in an application env var, let’s see about changing it for our test run.
2. Change the value when running tests
Now we can dynamically set the output directory in our test. Really we just have to set it a relative path instead of an absolute. Once we make it a relative path, that mix task will create the output directory relative to the current directory.
do
setup = "public"
output_dir Application.put_env(:static_site, :output_directory, output_dir)
[output_dir: output_dir]
end
"creates output file", %{output_path: output_dir} do
test ...
File.exists?(Path.join(output_dir, "index.html"))
assert end
3. Restore the original value
But this changes the output directory application-wide, and it’s good
practice to put anything the tests change back to the way they were
before. We can use on_exit()
to set the original value when
the test run finishes. We have to save it before changing the
application variable and pass it to the call in
on_exit()
do
setup = "public"
output_dir = Application.fetch_env!(:static_site, :output_directory)
original ...
(fn ->
on_exitApplication.put_env(:static_site, :output_directory, original)
end)
...
end
Now that we can change the output directory in our tests, we can point to a temporary directory that is separate from our dev files. But where should we put it?
Adding a
temporary tmp
directory for test artifacts
1. Locate the temporary test directory
Before we spend too much time thinking about it, let’s take a look
how it’s done in the Elixir source code. In lib/mix/test/test_helper.exs
there’s
def tmp_path do
Path.expand("../tmp", __DIR__)
end
def tmp_path(extension) do
Path.join(tmp_path(), remove_colons(extension))
end
__DIR__
is whatever directory the current file is in, so
for our task test in test/mix/tasks
the temp directory
would be test/mix/tmp
.
That seems like a good place to us. Let’s copy
tmp_path()
into our test file. We can leave off the call to
remove_colons()
, since we don’t have any colons to deal
with.
defp tmp_path, do: Path.expand("../tmp", __DIR__)
defp tmp_path(extension), do: Path.join(tmp_path(), extension)
Now let’s see about changing the location of the output directory.
2. Change the current directory during the test
We can use tmp_path/1
to create a temporary directory
specifically for this test.
"creates output file", %{output_dir: output_dir} do
test File.mkdir_p!(tmp_path("build"))
File.cd!(tmp_path("build"))
...
And at the end of our test, we need to change back into the test file’s directory with
...
File.cd!(__DIR__)
end
3. Add fixture for missing file
And since we’re no longer in the project root directory, our task
will not find the root index.html
file. We need to add a
fixture that our task can read from.
File.write("index.html", """
<!-- Auto-generated fixture -->
<!DOCTYPE html>
<html>
<body>hello world</body>
</html>
""")
After running the test,
$ docker run --rm -w /opt -v $PWD:/opt hw_dev mix test
running build task
...
Finished in 0.02 seconds (0.02s async, 0.00s sync)
1 doctest, 2 tests, 0 failures
we have our test directory, test/mix/tmp
,
$ find test/mix/tmp
test/mix/tmp
test/mix/tmp/build
test/mix/tmp/build/public
test/mix/tmp/build/public/index.html
test/mix/tmp/build/index.html
and our fixture file.
$ cat test/mix/tmp/build/index.html
<!-- Auto-generated fixture -->
<!DOCTYPE html>
<html>
<body>hello world</body>
</html>
Cleaning up temporary directories
Our tests are pretty solid now, but it is a good idea to clean the
tmp
directory up when the tests are done. We can do that in
the on_exit()
call in our test setup block
(fn ->
on_exit...
File.rm_rf!(tmp_path())
end)
Now, after we run the tests, all the files and directories created during the test run have been removed.
$ find test/mix/tmp/
find: ‘test/mix/tmp/’: No such file or directory
Suppressing IO messages
Our mix task outputs a useful message when it runs, but we don’t want
that to clutter up the test results. We can prevent those messages by
“capturing” the output with the ExUnit.CaptureIO
module.
Add import ExUnit.CaptureIO
to the top of our test file.
Then, in our tests, we can pass our call to the mix task as a function
to capture_io()
(fn -> Mix.Tasks.Build.run([]) end) capture_io
When we run our tests now, there are no more IO messages cluttering up the test results.
$ docker run --rm -w /opt -v $PWD:/opt hw_dev mix test
...
Finished in 0.02 seconds (0.02s async, 0.00s sync)
1 doctest, 2 tests, 0 failures
Final form
Here’s what the final draft of our test looks like.
test/mix/tasks/build_test.exs
defmodule Mix.Tasks.BuildTest do
use ExUnit.Case
import ExUnit.CaptureIO
do
setup = "public"
output_dir = Application.fetch_env!(:static_site, :output_directory)
original Application.put_env(:static_site, :output_directory, output_dir)
(fn ->
on_exitApplication.put_env(:static_site, :output_directory, original)
File.rm_rf!(tmp_path())
end)
[output_dir: output_dir]
end
"creates output file", %{output_dir: output_dir} do
test File.mkdir_p!(tmp_path("build"))
File.cd!(tmp_path("build"))
File.write("index.html", """
<!-- Auto-generated fixture -->
<!DOCTYPE html>
<html>
<body>hello world</body>
</html>
""")
(fn -> Mix.Tasks.Build.run([]) end)
capture_io
File.exists?(Path.join(output_dir, "index.html"))
assert
File.cd!(__DIR__)
end
defp tmp_path, do: Path.expand("../tmp", __DIR__)
defp tmp_path(extension), do: Path.join(tmp_path(), extension)
end
Conclusion
This test seemed trivial at first, but increased in complexity quickly. We had to set some of our configuration in application environment variables, change the configuration temporarily before a test run and then change it back after, clean up test artifacts after the run, and capture IO messages that were generated during it. That’s enough going on that we thought it would be a good topic for a post. Cheers and happy coding!