Sanity Tests

It is common for test "classifications" to have a plethora of definitions. Sanity tests are no different. Wikipedia says the following on the subject:

the sanity test [...] determines whether it is possible and reasonable to proceed with further testing.

This implies that a sanity test is some sort of "pre-test" to determine if further testing even makes sense.

I, however, think of sanity testing as breaking the fourth wall of the codebase. I believe sanity testing should test assumptions about the codebase itself rather than the behavior of the code.

I've found sanity testing to be extremely useful both in saving time by allowing future contributors to avoid common pitfalls as well as codifying codebase conventions. Let's make the concept of sanity tests more explicit with a few examples from an Elixir codebase I've been working on recently.

Note: my tests use the ex_spec library that adds a simple BDD-like syntax to ExUnit.

Freezing Migrations

Many applications use libraries to interact with a database. These tools often provide facilities for codifying your database schema and for modifying it as time passes, a concept generally called "database migrations" or "migrations" for short. In Elixir, the Ecto library provides a migration feature similar to Rails' ActiveRecord migrations. Because these files live in your codebase and version control system, often several members of the team can be modifying a migration at the same time. That's fine as long as the migration has not yet been run in your production environment. If it has been run in production, however, we would like to ensure that the migration in our codebase never changes in the future. If we want to make changes to what that migration does we'll need to write a brand new migration to perform those changes.

We can write a sanity test to enforce this rule.

defmodule MyApp.SanityTest do  
  use ExSpec

  @migration_hashes %{
    "00100_release_1_00.exs"  => "de1a663896925fb53abc0341eb5ca414",
    "00200_release_2_00.exs"  => "823cc2738e1e43f2dbfe3ffc4b26fb8f",
    "00300_release_3_00.exs"  => "b417b004600b4dd3518f5708aebf6262",
    "00400_release_4_00.exs"  => "51b1ec3944d4875b217a7167d9cd6571",
    "00500_release_5_00.exs"  => "91e0173099631350ec4c40624b5bc862",
  }

  describe "frozen migrations" do
    it "will not change after being deployed" do
      glob = Path.join([
        __DIR__,
        "..",
        "..",
        "priv",
        "repo",
        "migrations",
        "*"
      ])

      Enum.each(Path.wildcard(glob), fn file_name ->
        file = Path.basename(file_name)
        expected_hash = @migration_hashes[file]

        if expected_hash do
          hash = file_name |> File.read! |> md5

          assert hash == expected_hash
        end
      end)
    end
  end

  defp md5(s) do
    :crypto.hash(:md5, s)
    |> :erlang.binary_to_list
    |> Enum.map(&(:io_lib.format("~2.16.0b", [&1])))
    |> List.flatten
    |> :erlang.list_to_bitstring
   end
end  

This test iterates over all migrations within priv/repo/migrations and, if the filename is specified in @migration_hashes, it verifies that the md5 of the file contents is the expected value. When a new release of the project is created, any migrations that are released will be added to the @migration_hashes map. If a team member accidentally modifies a migration that has already been deployed, the test will fail and they'll be gently reminded to create a new migration.

Valid Test Files

More than once I've accidentally created a test file in my Elixir application's test directory that isn't named properly. I'll either forget to end my test filename with _test.exs or I'll remember the correct filename but accidentally use the .ex extension rather than .exs. These errors are particularly painful because they manifest themselves by simply not running your tests in that file. This is a prime candidate for a sanity test. Let's write one.

defmodule MyApp.SanityTest do  
  use ExSpec

  describe "test files" do
    it "checks that all test files are named properly" do
      exclusions = ~w(
        test_helper.exs
      )

      test_glob = Path.join([__DIR__, "..", "**", "*"])

      bad_files = test_glob
      |> Path.wildcard
      |> Enum.map(&Path.expand/1)
      |> Enum.reject(&File.dir?(&1))
      |> Enum.reject(&String.ends_with?(&1, "_test.exs"))
      |> Enum.reject(fn path ->
        Enum.any?(exclusions, &matches?(path, &1))
      end)

      assert bad_files == []
    end
  end

  defp matches?(string, pattern) when is_binary(pattern) do
    String.ends_with?(string, pattern)
  end

  defp matches?(string, pattern) do
    String.match?(string, pattern)
  end
end  

Here we find all of our test files and then exclude any that are properly named (ending in _test.exs). We also exclude any files that are explicitly named in our exclusions list. Our exclusions can either be string literals that (specifying explicitly the end of the excluded file's name) or regexs that can match any number of files. This allows us to exclude files from our test like test_helper.exs which should not follow the convention of ending in _test.exs. If we accidentally add a test file with a malformed name this sanity will fail, saving us countless hours and lots of frustration.

Use Them!

I hope these two examples convey the usefulness of sanity tests as both tools for saving time as well as conveying patterns and idioms. Next time you think to yourself "I can't believe I did that again", consider writing a sanity test to stop yourself from making the same mistake in the future.