A Whirlwind Tour of Testing in Elixir 🚂

Daniel Caixinha

Software Engineer @ onfido.png

gitlab.png github.png elixir_forum.png slack.png @dcaixinha

Why?

  • Tests are an integral part of any application
  • Carelessly built test suites will slow you down
  • Redundant coverage will kill morale
  • Testing isn't just about catching bugs
  • Different types of testing complement each other
  • Help build better test suites

The testing pyramid!

testing_pyramid_bar_low.png

The testing pyramid!

testing_pyramid_bar_high.png

The testing pyramid!

testing_pyramid_curved.png

The testing pyramid!

testing_pyramid_unit.png

Some "universal" rules

It's not a unit test if:

  • it talks to the database
  • it communicates across the network
  • it touches the file system
  • it can't run at the same time as any of your other unit tests
  • you have to do special things to your environment to run it.
  • – Michael Feathers

Some "universal" rules

It's not a unit test if:

  • it talks to the database
  • it communicates across the network
  • it touches the file system
  • it can't run at the same time as any of your other unit tests
  • you have to do special things to your environment to run it.
  • – Michael Feathers

Detroit-school TDD (a.k.a. classical)
vs.
London-school TDD (a.k.a. mockist)

unit_testing.png

unit_testing2.png

defmodule Demo.Validators.AddressValidator do
  @callback validation_level() :: String.p
  @callback set_validation_level(String.p) :: :ok
  @callback validate(String.p) :: boolean

  def validation_level() do
    Application.get_env(:demo, :validation_level)
  end

  def set_validation_level(level) do
    Application.put_env(:demo, :validation_level, level)
  end

  def validate(address) do
    :ok = emit_event("address_validated")

    address
    |> String.length()
    |> validate_length()
  end

  defp validate_length(address_length) when address_length < 32, do: true
  defp validate_length(_address_length), do: false

  defp emit_event(event_description), do: Demo.Events.EventEmitter.emit(event_description)
end
defmodule Demo.Validators.AddressValidatorTest do
  use ExUnit.Case, async: true
  alias Demo.Validators.AddressValidator, as: Subject

  describe "when the address is fewer than 32 chars" do
    test "it validates the address" do
      assert Subject.validate("valid address") == true
    end
  end

  describe "when the address is longer than 32 chars" do
    test "it invalidates the address" do
      assert Subject.validate("some very very very long address") == false
    end
  end
end

unit_testing3.png

defmodule Demo.Validators.AddressValidator do
  @callback validation_level() :: String.p
  @callback set_validation_level(String.p) :: :ok
  @callback validate(String.p) :: boolean

  def validation_level() do
    Application.get_env(:demo, :validation_level)
  end

  def set_validation_level(level) do
    Application.put_env(:demo, :validation_level, level)
  end

  def validate(address) do
    :ok = emit_event("address_validated")

    address
    |> String.length()
    |> validate_length()
  end

  defp validate_length(address_length) when address_length < 32, do: true
  defp validate_length(_address_length), do: false

  defp emit_event(event_description), do: Demo.Events.EventEmitter.emit(event_description)
end
test "it sets the validation level to the provided parameter" do
  Subject.set_validation_level("some_validation_level")

  assert Subject.validation_level() == "some_validation_level"
end

unit_testing4.png

defmodule Demo.Validators.AddressValidator do
  @callback validation_level() :: String.p
  @callback set_validation_level(String.p) :: :ok
  @callback validate(String.p) :: boolean

  def validation_level() do
    Application.get_env(:demo, :validation_level)
  end

  def set_validation_level(level) do
    Application.put_env(:demo, :validation_level, level)
  end

  def validate(address) do
    :ok = emit_event("address_validated")

    address
    |> String.length()
    |> validate_length()
  end

  defp validate_length(address_length) when address_length < 32, do: true
  defp validate_length(_address_length), do: false

  defp emit_event(event_description), do: Demo.Events.EventEmitter.emit(event_description)
end
defmodule Demo.Validators.AddressValidator do
  @callback validation_level() :: String.p
  @callback set_validation_level(String.p) :: :ok
  @callback validate(String.p) :: boolean

  @event_emitter Application.get_env(:demo, :event_emitter)

  def validation_level() do
    Application.get_env(:demo, :validation_level)
  end

  def set_validation_level(level) do
    Application.put_env(:demo, :validation_level, level)
  end

  def validate(address) do
    :ok = emit_event("address_validated")

    address
    |> String.length()
    |> validate_length()
  end

  defp validate_length(address_length) when address_length < 32, do: true
  defp validate_length(_address_length), do: false

  defp emit_event(event_description), do: @event_emitter.emit(event_description)
end
# config/config.exs
use Mix.Config

config :demo,
  event_emitter: Demo.Events.EventEmitter
# config/test.exs
use Mix.Config

config :demo,
  event_emitter: Demo.Events.EventEmitterMock

defmodule Demo.Events.EventEmitterMock do
  @callback emit(String.t) :: :ok

  def emit("address_validated") do
    send(self(), :address_validated_event_emitted)
    :ok
  end
end

test "it emits the :address_validated event" do
  Subject.validate("test address")

  assert_receive :address_validated_event_emitted
end

Over time, the mock may (will?) diverge from the real thing

Meanwhile in production…

this_is_fine.jpg

Enter Mox! 🕺

mox.png

mocks_blogpost.png

# mix.exs
defp deps do
  [
    # ...
    {:mox, "~> 0.5", only: :test}
  ]
end
# test/test_helper.exs
Mox.defmock(Demo.Events.EventEmitterMock, for: Demo.Events.EventEmitter)

ExUnit.start()

defmodule Demo.Validators.AddressValidatorTest do
  use ExUnit.Case, async: true
  alias Demo.Validators.AddressValidator, as: Subject

  import Mox

  setup :verify_on_exit!

  test "it emits the :address_validated event" do
    Demo.Events.EventEmitterMock
    |> expect(:emit, fn "validate_address" -> :ok end)

    Subject.validate("test address")
  end
end

Two testing principles

Don't Mock What You Don't Own

dont_mock_dont_own.png

dont_mock_dont_own_1.png

dont_mock_dont_own_2.png

DAMP not DRY

describe "when the address was already validated and the feature flag is enabled" do
  setup [:address_already_validated, :enabled_feature_flag]

  test "it runs the validation again" do
    # ...
  end

end

describe "when the address was already validated and the feature flag is disabled" do
  setup [:address_already_validated, :disable_feature_flag]

  test "it does not run the validation again" do
    # ...
  end

end

The testing pyramid!

testing_pyramid_service_level.png

The testing pyramid!

testing_pyramid_think.png

microservices_arch.png

microservices_arch_calls.png

bypass.png


# mix.exs
defp deps do
  [
    # ...
    {:mox, "~> 0.5", only: :test},
    {:bypass, "~> 1.0", only: :service_level_test}
  ]
end
# service_level_test/test_helper.exs
Ecto.Adapters.SQL.Sandbox.mode(Demo.Repo, :manual)
Application.ensure_all_started(:bypass)

ExUnit.start()

defmodule Demo.ServiceLevelTest do
  use Demo.DataCase

  @address_url "localhost:4000/api/address"

  setup do
    bypass = Bypass.open()
    {:ok, bypass: bypass}
  end

  test "valid address from Service X renders a successful response", %{bypass: bypass} do
    expected_response = %{"data" => %{"address" => %{
      "street" => "Sesame St.",
      "town" => "Manhattan"
    }}}

    Bypass.expect bypass, "GET", "/service_x/api/some_endpoint", fn conn ->
      Plug.Conn.resp(conn, 200, Poison.encode!(%{status: "ok", valid: true}))
    end

    {:ok, response} =
      HTTPoison.post!(@address_url, Poison.encode!(%{street: "Sesame St.", town: "Manhattan"}))
      |> Map.get(:body)
      |> Poison.decode()

    assert response == expected_response
  end
end

Over time, the mock may (will?) diverge from the real thing

Meanwhile in production…

this_is_fine.jpg

Enter Contract Testing! 🕺

pact_elixir.png

pact.png

pact_diagram.png

pact.png

pact_diagram_first_step.png

defmodule Demo.PactElixir.ServiceX do
  alias PactElixir.PactMockServer
  import PactElixir.Dsl

  setup do
    provider = new_service_provider()
    {:ok, mock_server_pid} = start_supervised({PactMockServer, provider})
    port = PactMockServer.port(mock_server_pid)
    {:ok, mock_server_pid: mock_server_pid, port: port}
  end

  describe "Demo talks to ServiceX" do
    test "check address is valid", %{mock_server_pid: mock_server_pid, port: port} do
      expected_response = %{"status" => "ok", "valid" => true}
      path = "/api/some_endpoint"

      {:ok, response} =
        HTTPoison.get!("http://localhost:#{port}#{path}")
        |> Map.get(:body)
        |> Poison.decode()

      assert response == expected_response
      assert {:ok} == PactMockServer.write_pact_file(mock_server_pid)
    end
  end
  # ...
end
defp new_service_provider do
  %{provider: "ServiceX", consumer: "Demo"}
  |> PactElixir.Dsl.service_provider()
  |> add_interaction(
    "Check if address is valid",
    given("Valid address is available to Demo"),
    with_request(method: :get, path: "/api/some_endpoint"),
    will_respond_with(status: 200, body: %{status: "ok", valid: true})
  )
end
defmodule Demo.PactElixir.ServiceX do
  alias PactElixir.PactMockServer
  import PactElixir.Dsl

  setup do
    provider = new_service_provider()
    {:ok, mock_server_pid} = start_supervised({PactMockServer, provider})
    port = PactMockServer.port(mock_server_pid)
    {:ok, mock_server_pid: mock_server_pid, port: port}
  end

  describe "Demo talks to ServiceX" do
    test "check address is valid", %{mock_server_pid: mock_server_pid, port: port} do
      expected_response = %{"status" => "ok", "valid" => true}
      path = "/api/some_endpoint"

      {:ok, response} =
        HTTPoison.get!("http://localhost:#{port}#{path}")
        |> Map.get(:body)
        |> Poison.decode()

      assert response == expected_response
      assert {:ok} == PactMockServer.write_pact_file(mock_server_pid)
    end
  end
end

pact.png

pact_diagram_second_step.png

{
  "consumer": {
    "name": "Demo"
  },
  "provider": {
    "name": "ServiceX"
  },
  "interactions": [
    {
      "description": "Check if address is valid",
      "providerStates": [
        {"name": "Valid address is available to Demo"}
      ],
      "request": {
        "body": "",
        "headers": {},
        "method": "GET",
        "path": "/api/some_endpoint"
      },
      "response": {
        "body": {
          "status": "ok",
          "valid": true
        },
        "headers": {},
        "status": 200
      }
    // ...

pact.png

pact_diagram_final_step.png

The testing pyramid!

testing_pyramid_e2e.png

🤷‍♂️

cucumber.png

microservices_arch.png

microservices_arch_user.png

microservices_arch_user_call.png

microservices_arch_report.png

microservices_arch_report_call.png

microservices_arch_our_hl.png

For Reference:

Where does this leave us?

$ MIX_ENV=test mix test
.....................................................................
.....................................................................
.............................
Finished in 6.3 seconds
826 tests, 0 failures, 0 skipped

Randomized with seed 687577
$ MIX_ENV=service_level_test mix test
.....................................................................
.....................................................................
.......................................................
Finished in 28.2 seconds
196 tests, 0 failures

Randomized with seed 283663

Wrapping Up

  • Tests are not just about regression safety
  • Write your tests as simple and straightforward as possible
  • When creating mocks, do so based on behaviours
  • Explore different kinds of testing
  • Pick the ones right for your context

  • andre.png & daniel.png
  • mastering_elixir.png

lisbon_elixir.png

Thank you! 🙇

Questions?

caixinha.pt

gitlab.png github.png elixir_forum.png slack.png @dcaixinha