327 lines
9.2 KiB
Markdown
327 lines
9.2 KiB
Markdown
---
|
||
title: "Temporary Directories For Testing Mix Tasks That Modify Files"
|
||
blurb: "Writing a test for a simple Mix task gets surprisingly complex. Application environment variables, temporary test directories, and IO capture are all involved."
|
||
...
|
||
|
||
{
|
||
id: "test-mix-task-file-modify"
|
||
}
|
||
|
||
## 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.
|
||
|
||
```elixir
|
||
@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.
|
||
|
||
::: filename-for-code-block
|
||
`test/mix/tasks/build_test.exs`
|
||
:::
|
||
|
||
```elixir
|
||
defmodule Mix.Tasks.BuildTest do
|
||
use ExUnit.Case
|
||
|
||
test "creates output files" do
|
||
Mix.Tasks.Build.run([])
|
||
|
||
assert File.exists?("/public/index.html")
|
||
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`
|
||
|
||
```elixir
|
||
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
|
||
|
||
```elixir
|
||
def run(_args) do
|
||
Mix.shell().info("running build task")
|
||
|
||
output_directory = Application.fetch_env!(:static_site, :output_directory)
|
||
|
||
File.mkdir_p!(output_directory)
|
||
output_directory |> Path.join("index.html") |> File.write!(@markup)
|
||
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.
|
||
|
||
```elixir
|
||
setup do
|
||
output_dir = "public"
|
||
Application.put_env(:static_site, :output_directory, output_dir)
|
||
|
||
[output_dir: output_dir]
|
||
end
|
||
|
||
|
||
test "creates output file", %{output_path: output_dir} do
|
||
...
|
||
assert File.exists?(Path.join(output_dir, "index.html"))
|
||
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()`
|
||
|
||
```elixir
|
||
setup do
|
||
output_dir = "public"
|
||
original = Application.fetch_env!(:static_site, :output_directory)
|
||
...
|
||
on_exit(fn ->
|
||
Application.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`](https://github.com/elixir-lang/elixir/blob/main/lib/mix/test/test_helper.exs) there's
|
||
|
||
```elixir
|
||
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.
|
||
|
||
```elixir
|
||
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.
|
||
|
||
```elixir
|
||
test "creates output file", %{output_dir: output_dir} do
|
||
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
|
||
|
||
```elixir
|
||
...
|
||
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.
|
||
|
||
```elixir
|
||
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
|
||
|
||
```elixir
|
||
on_exit(fn ->
|
||
...
|
||
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()`
|
||
|
||
```elixir
|
||
capture_io(fn -> Mix.Tasks.Build.run([]) end)
|
||
```
|
||
|
||
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.
|
||
|
||
::: filename-for-code-block
|
||
`test/mix/tasks/build_test.exs`
|
||
:::
|
||
|
||
```elixir
|
||
defmodule Mix.Tasks.BuildTest do
|
||
use ExUnit.Case
|
||
|
||
import ExUnit.CaptureIO
|
||
|
||
setup do
|
||
output_dir = "public"
|
||
original = Application.fetch_env!(:static_site, :output_directory)
|
||
Application.put_env(:static_site, :output_directory, output_dir)
|
||
|
||
on_exit(fn ->
|
||
Application.put_env(:static_site, :output_directory, original)
|
||
File.rm_rf!(tmp_path())
|
||
end)
|
||
|
||
[output_dir: output_dir]
|
||
end
|
||
|
||
test "creates output file", %{output_dir: output_dir} do
|
||
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>
|
||
""")
|
||
|
||
capture_io(fn -> Mix.Tasks.Build.run([]) end)
|
||
|
||
assert File.exists?(Path.join(output_dir, "index.html"))
|
||
|
||
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!
|