Testing en Elixir: ExUnit, Mox y doctests que verifican tu documentación

El ecosistema de testing de Elixir está incluido en el lenguaje sin necesidad de librerías externas. ExUnit viene con la instalación estándar, los doctests son tests que viven en los comentarios de documentación, y Mox resuelve el problema de los mocks de una forma que encaja perfectamente con el modelo de behaviours de OTP. Aquí está lo que necesitas para testear bien en Elixir.

ExUnit: el framework de base

ExUnit es el framework de testing de Elixir. No hay que instalarlo, viene con el lenguaje. Para correr los tests basta con mix test.

defmodule MiApp.CalculadoraTest do
  use ExUnit.Case

  test "suma dos enteros positivos" do
    assert Calculadora.suma(2, 3) == 5
  end

  test "suma con cero devuelve el mismo número" do
    assert Calculadora.suma(0, 42) == 42
  end

  test "falla con tipos incorrectos" do
    assert_raise FunctionClauseError, fn ->
      Calculadora.suma("a", 1)
    end
  end
end

La macro assert usa el pattern matching internamente: si la expresión es truthy, el test pasa; si no, muestra el valor real y el esperado. No necesitas matchers separados para la mayoría de casos.

Aserciones más específicas

# Verificar que lanza una excepción concreta
assert_raise ArgumentError, "mensaje esperado", fn ->
  funcion_que_falla()
end

# Verificar que algo NO se cumple
refute usuario.activo

# Verificar con pattern matching
assert {:ok, %{nombre: nombre}} = Cuentas.crear_usuario(params)
assert nombre == "Ana"

# Capturar logs en los tests
import ExUnit.CaptureLog

log = capture_log(fn ->
  Logger.warning("algo sospechoso")
end)
assert log =~ "algo sospechoso"

Doctests: la documentación que se verifica sola

Los doctests son uno de los detalles más elegantes de Elixir. Cualquier ejemplo que pongas en la documentación con el prefijo iex> se convierte automáticamente en un test cuando usas doctest:

defmodule MiApp.Formateador do
  @moduledoc """
  Funciones de formato para mostrar datos al usuario.
  """

  @doc """
  Formatea un número como moneda en euros.

  ## Ejemplos

      iex> MiApp.Formateador.euros(42.5)
      "42,50 €"

      iex> MiApp.Formateador.euros(1000)
      "1.000,00 €"

  """
  def euros(cantidad) do
    # implementación
  end
end

# En el archivo de test:
defmodule MiApp.FormateadorTest do
  use ExUnit.Case, async: true
  doctest MiApp.Formateador  # ejecuta todos los iex> del módulo
end

Si cambias la implementación y el output ya no coincide con el ejemplo del docstring, el test falla. Esto mantiene la documentación y el código sincronizados sin esfuerzo extra.

Setup y contexto compartido

defmodule MiApp.UsuarioTest do
  use ExUnit.Case

  setup do
    usuario = %{nombre: "Ana", email: "[email protected]", activo: true}
    {:ok, usuario: usuario}
  end

  test "el usuario está activo por defecto", %{usuario: usuario} do
    assert usuario.activo
  end

  test "el nombre está presente", %{usuario: usuario} do
    refute String.trim(usuario.nombre) == ""
  end
end

El bloque setup se ejecuta antes de cada test y puede devolver datos en el contexto. Cada test los recibe como segundo argumento. Para setup compartido entre varios tests, setup_all se ejecuta una sola vez para todo el módulo.

Tests asíncronos

Con async: true, los tests de ese módulo corren en paralelo con los de otros módulos. Elixir lo aprovecha al máximo porque los tests bien escritos no comparten estado:

defmodule MiApp.PuroTest do
  use ExUnit.Case, async: true  # corre en paralelo

  test "función pura sin efectos secundarios" do
    assert transformar([1, 2, 3]) == [2, 4, 6]
  end
end

Los tests que acceden a base de datos no deben usar async: true sin la configuración de sandbox de Ecto, que aísla cada test en su propia transacción.

Mox: mocks basados en behaviours

Mox es la librería de mocks de Elixir, creada por José Valim. Su enfoque es diferente al de las librerías de mocking habituales: no intercepta llamadas a funciones en tiempo de ejecución, sino que usa el sistema de behaviours de Elixir para definir interfaces explícitas.

# 1. Definir el behaviour (la interfaz)
defmodule MiApp.NotificacionesComportamiento do
  @callback enviar_email(destinatario :: String.t(), asunto :: String.t()) ::
    {:ok, String.t()} | {:error, term()}
end

# 2. Implementación real
defmodule MiApp.Notificaciones do
  @behaviour MiApp.NotificacionesComportamiento

  def enviar_email(destinatario, asunto) do
    # llamada real al servicio de email
    EmailService.send(destinatario, asunto)
  end
end

# 3. En test/support/mocks.ex
Mox.defmock(MiApp.NotificacionesMock,
  for: MiApp.NotificacionesComportamiento)

# 4. En el test
defmodule MiApp.RegistroTest do
  use ExUnit.Case, async: true
  import Mox

  setup :verify_on_exit!

  test "registrar usuario envía email de bienvenida" do
    expect(MiApp.NotificacionesMock, :enviar_email, fn email, _asunto ->
      assert email == "[email protected]"
      {:ok, "enviado"}
    end)

    assert {:ok, _} = MiApp.Cuentas.registrar(%{
      email: "[email protected]",
      nombre: "Nuevo"
    })
  end
end

verify_on_exit! comprueba al final del test que todas las expectativas definidas con expect se llamaron el número de veces esperado. Si defines un expect y no se llama, el test falla.

Por qué behaviours y no magia de runtime

La ventaja de este enfoque es que te obliga a definir interfaces explícitas en tu código de producción. El módulo que envía emails tiene que declarar su behaviour. El módulo que lo usa tiene que recibir la implementación como dependencia. Esto hace que el código sea más modular y que los tests sean una consecuencia natural de la arquitectura, no un parche añadido después.

# El módulo que usa notificaciones recibe la implementación como config
defmodule MiApp.Cuentas do
  def registrar(attrs) do
    notificaciones = Application.get_env(:mi_app, :notificaciones, MiApp.Notificaciones)

    with {:ok, usuario} <- crear_usuario(attrs),
         {:ok, _} <- notificaciones.enviar_email(usuario.email, "Bienvenido") do
      {:ok, usuario}
    end
  end
end

En tests, configuras la aplicación para usar el mock. En producción, usa la implementación real. Sin magia, sin monkey-patching.

El sistema de testing de Elixir encaja bien con la arquitectura OTP porque las abstracciones de OTP (behaviours, GenServers, supervisores) se prestan naturalmente a ser mockeadas e instrumentadas. Para ver cómo se estructura el código que luego se testea, el artículo sobre GenServer y el de Ecto de esta serie son el complemento directo.

Imagen: Pexels / Daniil Komov

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP