Do you want to pick up from where you left of?
Take me there

Testowanie kodu

Testowanie kodu jest ważną częścią procesu tworzenia oprogramowania. W tej lekcji przyjrzymy się zagadnieniu testowania kodu w Elixirze z wykorzystaniem ExUnit i poznamy kilka dobrych praktyk z tym związanych.

ExUnit

Elixir posiada wbudowaną bibliotekę ExUnit, która zawiera wszystko, czego potrzebujemy, by dokładnie przetestować nasz kod. Zanim zagłębimy się w ten temat, musimy wspomnieć, że testy są w Elixirze tworzone w postaci skryptów w plikach .exs. Przed uruchomieniem naszych testów musimy wystartować ExUnit za pomocą ExUnit.start(), co jest zazwyczaj wykonywane w skrypcie test/test_helper.exs.

Generując projekt w poprzedniej lekcji, Mix był na tyle miły, że utworzył plik test/example_test.exs, zawierający prosty test:

defmodule ExampleTest do
  use ExUnit.Case
  doctest Example

  test "greets the world" do
    assert Example.hello() == :world
  end
end

Możemy uruchomić nasz test za pomocą polecenia mix test. Jeśli to zrobimy, powinniśmy zobaczyć mniej więcej coś takiego:

..

Finished in 0.03 seconds
2 tests, 0 failures

Dlaczego w zwróconym wyniku widzimy dwie kropki? Oprócz testu w test/example_test.exs, Mix utworzył również test dokumentacyjny — doctest — w pliku lib/example.ex.

defmodule Example do
  @moduledoc """
  Documentation for Example.
  """

  @doc """
  Hello world.

  ## Examples

      iex> Example.hello
      :world

  """
  def hello do
    :world
  end
end

assert

Jeżeli kiedyś pisałeś już testy, to zapewne znasz pojęcie assert; niektóre biblioteki używają should lub expect zamiast assert.

Makra assert używamy do sprawdzania, czy wyrażenie jest prawdziwe. Jeżeli nie jest, zostanie zwrócony błąd, a nasz test nie powiedzie się. By to sprawdzić, spróbujmy zmienić nasz przykładowy test i ponownie uruchomić polecenie mix test:

defmodule ExampleTest do
  use ExUnit.Case
  doctest Example

  test "greets the world" do
    assert Example.hello() == :word
  end
end

Teraz powinniśmy zobaczyć zupełnie inny rezultat:

  1) test greets the world (ExampleTest)
     test/example_test.exs:5
     Assertion with == failed
     code:  assert Example.hello() == :word
     left:  :world
     right: :word
     stacktrace:
       test/example_test.exs:6 (test)

.

Finished in 0.03 seconds
2 tests, 1 failures

ExUnit dokładnie wskazuje miejsca, w których testy się nie powiodły, jakie były wartości oczekiwane, a jakie zostały faktycznie zwrócone.

refute

refute jest tym dla assert czym unless dla if. Możesz użyć refute, jeżeli chcesz sprawdzić wyrażenie, które zawsze jest nieprawdziwe.

assert_raise

Czasami ważne jest sprawdzenie, czy został zwrócony wyjątek. Możemy to zrobić za pomocą assert_raise. W lekcji poświęconej Plugowi zobaczymy przykłady zastosowania assert_raise.

assert_receive

Typowa Elixirowa aplikacja zawiera wiele aktorów/procesów, którzy komunikują się między sobą za pomocą wiadomości, dlatego też w testach będziemy chcieli sprawdzać, czy wiadomości są wysyłane. Ponieważ ExUnit działa jako niezależny proces, może on być adresatem takich wiadomości, tak jak dowolny inny proces, a do testów możemy użyć w tym przypadku makra assert_received:

defmodule SendingProcess do
  def run(pid) do
    send(pid, :ping)
  end
end

defmodule TestReceive do
  use ExUnit.Case

  test "receives ping" do
    SendingProcess.run(self())
    assert_received :ping
  end
end

assert_received nie czeka na wiadomość, a wykorzystując assert_receive możemy określić maksymalny czas oczekiwania na wiadomość (timeout).

capture_io i capture_log

Przechwytywanie informacji produkowanych przez aplikację jest możliwe za pomocą ExUnit.CaptureIO bez konieczności ingerowania w jej kod. Wystarczy przekazać jako argument funkcję, która wypisuje informacje na standardowe wyjście:

defmodule OutputTest do
  use ExUnit.Case
  import ExUnit.CaptureIO

  test "outputs Hello World" do
    assert capture_io(fn -> IO.puts("Hello World") end) == "Hello World\n"
  end
end

ExUnit.CaptureLog jest odpowiednikiem przechwytywania informacji ze standardowego wyjścia przez Logger.

Konfiguracja testów

W pewnych sytuacjach musimy przygotować środowisko przed uruchomieniem testów. W tym celu możemy użyć makr setup i setup_all. Makro setup będzie uruchamiane przed każdym testem, a setup_all zostanie uruchomione jednorazowo przed wszystkimi testami w module. Makra te powinny zwracać krotkę {:ok, state}, gdzie state będzie dostępny dla naszych testów.

Dla przykładu zmieńmy nasz kod tak, by korzystał z setup_all:

defmodule ExampleTest do
  use ExUnit.Case
  doctest Example

  setup_all do
    {:ok, recipient: :world}
  end

  test "greets", state do
    assert Example.hello() == state[:recipient]
  end
end

Mockowanie

Bądźmy ostrożni w myśleniu o mockowaniu. Kiedy tworzymy mockupy dla poszczególnych interakcji poprzez tworzenie okrojonych wersji funkcji (ang. stubs), ustanawiamy niebezpieczny wzorzec. Łączymy przebieg naszych testów z zachowaniem konkretnej zależności, takiej jak klient API. Unikamy definiowania współdzielonych zachowań między tymi funkcjami. Czynimy w ten sposób trudniejszym powtarzanie naszych testów.

Zamiast tego społeczność Elixira zachęca, by zmienić myślenie na ten temat w testach: myśleć o mockupach (rzeczowniku), a nie o mockowaniu (czasowniku).

By poznać szerszą dyskusję w tej sprawie, przeczytaj ten znakomity artykuł.

Mówiąc w dużym skrócie, zamiast mockowania zależności dla testów (mockowania jako czasownika), istotnie lepszym podejściem jest jawne definiowanie interfejsów (zachowań) dla kodu znajdującego się poza naszą aplikacją i użycie w testach mockupów (jako rzeczownika).

By lepiej zrozumieć wzorzec „mocków jako rzeczowników”, możesz:

Jeśli chcesz nieco bardziej zagłębić się w temat mockupów w Elixirze i poznać bibliotekę Mox, która pozwala na definiowanie równoległych mockupów, zajrzyj do lekcji na ten temat w tym miejscu.

Caught a mistake or want to contribute to the lesson? Edit this lesson on GitHub!