miti.sh/docs/2023-11-15-test-mix-task-file-modify.md

322 lines
9.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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."
...
## 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!