{ 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 """ hello world """ @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", """ hello world """) ``` 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 hello world ``` ## 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", """ hello world """) 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!