lib/describe_function.ex

defmodule DescribeFunction do
  @moduledoc """
    Inspired by a [conversation on Twitter][twitter], `DescribeFunction` adds
    an additional layer of TDD to your Elixir application.

    The `describe_function/2` macro validates that the function you are testing
    is defined. This will catch untested changes to your application's API at a
    high level in your test suite, resulting in a single, straightforward error,
    rather than many errors that might occur at a more removed level of your API.

    ## Usage

      defmodule ExampleTest do
        use ExUnit.Case, asnyc: true

        import DescribeFunction

        describe_function &Example.hello_world/0 do
          test "hello_world functions as expected" do
            assert Example.hello_world() == "hello world"
          end
        end
      end

    Assuming your `Example` module implements `hello_world/0`, your test will
    pass or fail, based on its implementation. On the other hand, if
    `Example.hello_world/0` is undefined, running your tests will raise

      ** (DescribeFunction.UndefinedFunctionError) function not defined: &Example.hello_world/0

  [twitter]: https://twitter.com/zorn/status/1572788455507374082
  """

  @doc """

    This macro expands on the functionality of `ExUnit.Case.describe/2` by
    validating that the function passed as its first argument exists.

    This will catch untested changes to your application's API at a higher
    level in your test suite, resulting in a single, straightforward error,
    rather than many errors that might occur at a more removed level in your
    API.

  ## Usage

      defmodule ExampleTest do
        use ExUnit.Case, asnyc: true

        import DescribeFunction

        describe_function &Example.hello_world/0 do
          test "hello_world functions as expected" do
            assert Example.hello_world() == "hello world"
          end
        end
      end

  Assuming your `Example` module implements `hello_world/0`, your test will pass
  or fail, based on its implementation. On the other hand, if `Example.hello_world/0`
  is undefined, running your tests will raise

      ** (DescribeFunction.UndefinedFunctionError) function not defined: &Example.hello_world/0
  """
  defmacro describe_function(f, do: describe_block) do
    quote location: :keep do
      describe(func(unquote(f)), do: unquote(describe_block))
    end
  end

  @doc false
  def func(f) when is_function(f) do
    [module: module, name: name, arity: arity, env: _, type: _] = Function.info(f)

    if function_exported?(module, name, arity),
      do: inspect(f),
      else: raise(DescribeFunction.UndefinedFunctionError.for_function(f))
  end
end