In this post, I am not going to talk how many approaches we can use to test private function behavior, alternatively, I am going to go through an example and show my solution step by step.

As we all known that we shouldn’t test private function explicitly, but that doesn’t mean that we can ignore testing private functions and assume that they are working well as far as their caller - public functions are good. We still need tests to cover the private functions fully.

Most of the time, when we find ourselves struggling on how to test a private function, the opportunity of code optimization is coming.

Here is an example!

Let’s say there is an Employee module, it has an attribute called employee_id. The employee_id is a combination of first_name and last_name.

  defmodule Employee do
    defstruct [:first_name, :last_name, :age, :employee_id]

    def create_changeset(struct, params \\ %{}) do
      params = params |> Map.put(params |> generate_employee_id)
      changeset(struct, params)
    end

    defp generate_employee_id(%{first_name: first_name, last_name: last_name})
      "#{first_name}_#{last_name}"
    end
  end

I can easily test the behavior of generate_employee_id by this

  test "creates an employee changeset with a correct employee_id" do
    params = %{first_name: "Haimeng", last_name: "Zhou", age: 10}
    changeset = Employee.create_changeset(%Employee{}, params)
    assert changeset.changes[:employee_id] == "Haimeng_Zhou"
  end

Increase complexity of this example!

The employee_id should be unique, I add more code to enhance this feature

  defmodule Employee do
    defstruct [:first_name, :last_name, :age, :employee_id]

    def create_changeset(struct, params \\ %{}) do
      params = params |> Map.put(params |> generate_employee_id)
      changeset(struct, params)
    end

    # Add a safeguard here for the recurring function
    defp generate_employee_id(%{first_name: first_name, last_name: last_name}, n) when n >= 3 do
      suffix = Enum.random(0..999) |> Integer.to_string
      "#{first_name}_#{last_name}_#{suffix}"
    end

    # I add suffix to the employee_id in order to make the id unique
    defp generate_employee_id(%{first_name: first_name, last_name: last_name} = params, n \\ 1)
      suffix = Enum.random(0..999) |> Integer.to_string
      employee_id = "#{first_name}_#{last_name}_#{suffix}"

      # If the generated id is not unique then it goes over from the top
      !(employee_id |> is_unique_employee_id) ?
        params |> generate_employee_id(n + 1) : employee_id
    end

    defp is_unique_employee_id(employee_id) do
      # A sql query to look up the employee_id in the employees table and then return a boolean from this function
      query = from e in Employee,
        where: e.employee_id == ^employee_id
      !(query |> Repo.all |> List.any?)
    end
  end

Something I want to test upon to the above code, I want to make sure that

  • The new employee_id is unique (it is in a nested private function)
  • The recurring function can be executed no more than 3 times
  • The employee_id format is corret

I am going to do some surgeries on the code.

Firstly, move those 3 employee_id generating functions to a separate file

Those 3 employee_id correlated functions can be decoupled from Employee module. I create a new module.

  defmodule EmployeeIdGenerator do
    # Add a safeguard here for the recurring function
    def generate_employee_id(%{first_name: first_name, last_name: last_name}, n) when n >= 3 do
      ...
    end

    def generate_employee_id(%{first_name: first_name, last_name: last_name}, n \\ 1)
      ...
    end

    defp is_unique_employee_id(employee_id) do
      ...
    end
  end

  defmodule Employee do
    defstruct [:first_name, :last_name, :age, :employee_id]

    def create_changeset(struct, params \\ %{}) do
      params = params |> Map.put(params |> EmployeeIdGenerator.generate_employee_id)
      changeset(struct, params)
    end
  end

The generate_employee_id/2 is a public function

Now I can concentrate on testing the functions which I care about!

That is not enough, I am not able to test the recurring function.

Secondly, utilize Mix.env to hack the Enum.random

  defmodule EmployeeIdGenerator do
    # Add a safeguard here for the recurring function
    def generate_employee_id(%{first_name: first_name, last_name: last_name}, n) when n >= 3 do
      "#{first_name}_#{last_name}_#{suffix}"
    end

    def generate_employee_id(%{first_name: first_name, last_name: last_name} = params, n \\ 1)
      employee_id = "#{first_name}_#{last_name}_#{suffix(n)}"

      !(employee_id |> is_unique_employee_id) ?
        params |> generate_employee_id(n + 1) : employee_id
    end

    defp suffix(n \\ 1)
      case Mix.env do
        :test -> n |> Integer.to_string
        _ ->
          Enum.random(0..999)
            |> Integer.to_string
      end
    end

    defp is_unique_employee_id(employee_id) do
      ...
    end
  end

Now the suffix is expectable, I want to use suffix to analysis the behavior of recurring function generate_employee_id/2.

Lastly, I put them together.

I create a EmployeeIdGeneratorTest module

  test "calls generate_employee_id once when there is no duplicated id" do
    assert EmployeeIdGenerator.generate_employee_id(%{first_name: "Haimeng", last_name: "Zhou"}) == "Haimeng_Zhou_1"
  end

  test "calls generate_employee_id twice when there is a duplicated id in the system"  do
    # Create an employee first
    # Employee.create_changeset(%Employee{}, %{...})
    assert EmployeeIdGenerator.generate_employee_id(%{first_name: "Haimeng", last_name: "Zhou"}) == "Haimeng_Zhou_2"
  end

  test "calls generate_employee_id 4 times when it cannot find unique id constantly" do
    # Create 4 employees first
    assert EmployeeIdGenerator.generate_employee_id(%{first_name: "Haimeng", last_name: "Zhou"}) == "Haimeng_Zhou_4"
  end

You can see that the behavior of the recurring function is predictable by checking its returned value. And at the same time, the other function is_unique_employee_id is also covered by the tests.

suffix was hacked so I am not able to test it, but the risk is very low in terms of its complexity.