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

Mox

Η Mox είναι μια βιβλιοθήκη που μας επιτρέπει να σχεδιάζουμε ταυτόχρονες εκτελέσεις κώδικα προσομοίωσης λειτουργίας (στο εξής θα αποκαλούμε αυτά τα κομμάτια κώδικα mocks για αποφυγή σύγχυσης) στην Elixir.

Γράφοντας κώδικα που μπορεί να δοκιμαστεί

Οι δοκιμές και τα mocks που τις φιλοξενούν δεν είναι συνήθως κάτι που θα μας τραβήξει την προσοχή σε οποιαδήποτε γλώσσα, οπότε δεν είναι απίθανο να συναντήσουμε λιγότερες αναφορές σε αυτά. Ωστόσο μπορείτε σίγουρα να χρησιμοποιήσετε mocks στην Elixir! Η ακριβής μεθοδολογία μπορεί να διαφέρει από οτιδήποτε έχετε συνηθήσει σε άλλες γλώσσες, αλλά ο τελικός στόχος παραμένει ο ίδιος: τα mocks μπορούν να προσομοιώνουν το αποτέλεσμα εσωτερικών συναρτήσεων και συνεπώς μας επιτρέπουν να βεβαιώνουμε ισότητες που σχετίζονται με κάθε πιθανό αποτέλεσμα εκτέλεσης του κώδικά μας.

Πριν περάσουμε σε πιο περίπλοκες περιπτώσεις, ας μιλήσουμε για κάποιες τεχνικές που μπορούν να μας βοηθήσουν να φτίαξουμε κώδικα που θα δοκιμάζεται πιο εύκολα. Μια απλή τακτική είναι να περάσουμε μια ενότητα σε μια συνάρτηση αντί να γράψουμε την ενότητα μέσα στην συνάρτηση (hard-coded module).

Για παράδειγμα, αν είχαμε γράψει έναν πελάτη HTTP μέσα σε μια συνάρτηση:

def get_username(username) do
  HTTPoison.get("https://elixirschool.com/users/#{username}")
end

Θα μπορούσαμε αντ’ αυτού να περάσουμε την ενότητα του πελάτη HTTP σαν όρισμα ως εξής:

def get_username(username, http_client) do
  http_client.get("https://elixirschool.com/users/#{username}")
end

Ή θα μπορούσαμε να χρησιμοποιήσουμε την συνάρτηση apply/3 για να έχουμε το ίδιο αποτέλεσμα:

def get_username(username, http_client) do
  apply(http_client, :get, ["https://elixirschool.com/users/#{username}"])
end

Το να περάσουμε την ενότητα ως όρισμα, μας βοηθά να διασπάσουμε τις ανησυχίες μας και αν δεν μπερδευτούμε απο την πολυλογία του αντικειμενοστραφή προσδιορισμού, μπορεί να αναγνωρίσουμε αυτή την αναστροφή ελέγχου ως Dependency Injection. Για να δοκιμάσουμε την ενότητα get_username/2, θα χρειαστούμε μόνο μια ενότητα της οποίας η συνάρτηση get θα επέστρεφε την τιμή που χρειαζόμαστε για τις βεβαιώσεις ισότητας μας.

Αυτή η δομή είναι πολύ απλή, οπότε είναι χρήσιμη όταν η συνάρτηση έχει υψηλή πρόσβαση (και δεν είναι, για παράδειγμα, ξεχασμένη κάπου σε μια ιδιωτική συνάρτηση).

Μια πιο ευέλικτη τακτική έχει να κάνει με την διαμόρφωση της εφαρμογής. Ίσως να μην το έχετε συνειδητοποιήσει, αλλά μια εφαρμογή Elixir διατηρεί κάποια κατάσταση στην παραμετροποίησή της. Αντί να γράψουμε μια ενότητα ή να την περάσουμε ως όρισμα, μπορούμε να την προσπελάσουμε από το αρχείο παραμετροποίησης της εφαρμογής.

def get_username(username) do
  http_client().get("https://elixirschool.com/users/#{username}")
end

defp http_client do
  Application.get_env(:my_app, :http_client)
end

Έπειτα, στο αρχείο σας config :

config :my_app, :http_client, HTTPoison

Αυτή η δομή και ο συσχετισμός της με το αρχείο διαμόρφωσης της εφαρμογής αποτελεί την βάση για ότι θα ακολουθήσει.

Αν είστε επιρρεπής στο να υπεραναλύετε, ναι, θα μπορούσατε να παραλείψετε την συνάρτηση http_client/0 και να καλέσετε απευθείας την Application.get_env/2, και ναι, θα μπορούσατε να προκαθορίσετε ένα τρίτο όρισμα στην Application.get_env/3 και να έχετε το ίδιο αποτέλεσμα.

Η αξιοποίηση του αρχείου διαμόρφωσης της εφαρμογής, μας επιτρέπει να έχουμε συγκεκριμένες εφαρμογές της ενότητας για κάθε περιβάλλον, μπορείτε να αναφέρετε μία ενότητα sandbox για το περιβάλλον dev ενώ για το περιβάλλον test μπορούμε να χρησιμοποιήσουμε μια ενότητα μνήμης.

Παρ’ όλα αυτά, το να έχουμε μόνο μια καθορισμένη ενότητα ανά περιβάλλον μπορεί να μην παρέχει αρκετή ευελιξία: εξαρτάται από την χρήση της συνάρτησής σας, μπορεί να χρειάζεται να επιστρέψετε διαφορετικές απαντήσεις ώστε να είστε σε θέση να δοκιμάσετε όλες τις πιθανές εκβάσεις της εκτέλεσης. Αυτό που δεν γνωρίζουν οι περισσότεροι είναι ότι μπορείτε να αλλάξετε το αρχείο διαμόρφωσης εφαρμογής στο περιβάλλον εκτέλεσης! Ας ρίξουμε μια ματία στην Application.put_env/4.

Φανταστείτε ότι η εφαρμογή σας θα έπρεπε να δρα διαφορετικά ανάλογα με το αν το αίτημα HTTP ήταν επιτυχές ή όχι. Θα μπρούσαμε να δημιουργήσουμε πολλαπλές ενότητες, όπου η κάθε μια θα είχε μια συνάρτηση get/1. Μια ενότητα θα επέστρεφε μια τούπλα :ok, ενώ η άλλη θα επέστρεφε μια τούπλα :error. Στη συνεχεια θα μπορούσαμε να χρησιμοποιήσουμε την Application.put_env/4 για να ορίσουμε την διαμόρφωση πριν καλέσουμε την συνάρτησή μας get_username/1. Η ενότητα δοκιμής μας θα ήταν κάπως έτσι:

# Don't do this!
defmodule MyAppTest do
  use ExUnit.Case

  setup do
    http_client = Application.get_env(:my_app, :http_client)
    on_exit(
      fn ->
        Application.put_env(:my_app, :http_client, http_client)
      end
    )
  end

  test ":ok on 200" do
    Application.put_env(:my_app, :http_client, HTTP200Mock)
    assert {:ok, _} = MyModule.get_username("twinkie")
  end

  test ":error on 404" do
    Application.put_env(:my_app, :http_client, HTTP404Mock)
    assert {:error, _} = MyModule.get_username("does-not-exist")
  end
end

Υποθέτουμε ότι έχετε δημιουργήσει κάπου τις απαιτούμενες ενότητες (HTTP200Mock και HTTP404Mock). Προσθέσαμε μια ανάκληση on_exit στο βασικό κομμάτι setup ώστε να διασφαλίσουμε ότι το :http_client επιστρέφει στην αρχική του κατάσταση μετά από κάθε εκτέλεση δοκιμής.

Παρ’ όλα αυτά, μια ακολουθία όπως αυτή παραπάνω είναι κάτι που ΔΕΝ θα έπρεπε να ακολουθείτε! Οι λόγοι μπορεί να μην είναι προφανείς.

Αρχικά, δεν υπάρχει τίποτα που να εγγυάται πως οι ενότητες που προσδιορίζουμε για την :http_client μπορούν να κάνουν αυτό που προορίζονται: δεν υπάρχει κάποια απαίτηση από τις ενότητες να συμπεριλαμβάνουν μια συνάρτηση get/1.

Δεύτερον, δοκιμές όπως η ανωτέρω δεν μπορούν να τρέξουν ασύγχρονα με ασφάλεια. Επειδή η κατάσταση της εφαρμογής μοιράζεται σε όλη την εφαρμογή, είναι πολύ πιθανό όταν παρακάμψουμε την :http_client σε μια δοκιμή, κάποια άλλη δοκιμή (που τρέχει παράλληλα) να περιμένει διαφορετικό αποτέλεσμα. Μπορεί να έχετε συναντήσει προβλήματα σαν και αυτό όταν κάποιες δοκιμές συνήθως περνάνε, αλλά άλλοτε αποτυγχάνουν οικτρά. Προσέξτε!

Τρίτον, αυτή η προσέγγιση μπορεί να δημιουργήσει ακαταστασία στον κώδικά σας γιατί μπορεί να καταλήξετε με κάποιες ενότητες mock στοιβαγμένες κάπου στην εφαρμογή σας. Μπλιαχ.

Παρουσιάζουμε την δομή αυτή επειδή περιγράφει την προσέγγιση με έναν τρόπο που μας βοηθά να καταλάβουμε λίγα περισσότερα για το πως δουλεύει η πραγματική λύση.

Mox: Η Λύση σε όλα τα Προβλήματα

Το πακέτο που θα ανατρέξουμε για να δουλέψουμε με mocks στην Elixir είναι το Mox, το οποίο έχει συντάξει ο ίδιος ο José Valim, και λύνει όλα τα προβλήματα που παρουσιάζονται πιο πάνω.

Θυμηθείτε: ως προαπαιτούμενο, ο κώδικάς μας θα πρέπει να ψάχνει στο αρχείο διαμόρφωσης της εφαρμογής μας, για να πάρει την διαμορφωμένη ενότητα:

def get_username(username) do
  http_client().get("https://elixirschool.com/users/#{username}")
end

defp http_client do
  Application.get_env(:my_app, :http_client)
end

Έπειτα μπορείτε να συμπεριλάβετε το mox στις εξαρτήσεις σας:

# mix.exs
defp deps do
  [
    # ...
    {:mox, "~> 0.5.2", only: [:test], runtime: false}
  ]
end

Εγκαταστήστε το με την εντολή mix deps.get.

Στη συνέχεια, μετατρέψτε το αρχείο test_helper.exs ώστε να κάνει τα ακόλουθα δυο πράγματα:

  1. θα πρέπει να προσδιορίζει ένα ή περισσότερα mocks
  2. θα πρέπει να διαμορφώνει το αρχείο διαμόρφωσης της εφαρμογής με το mock
# test_helper.exs
ExUnit.start()

# 1. define dynamic mocks

Mox.defmock(HTTPoison.BaseMock, for: HTTPoison.Base)
# ... etc...

# 2. Override the config settings (similar to adding these to config/test.exs)
Application.put_env(:my_app, :http_client, HTTPoison.BaseMock)
# ... etc...

Μερικά σημαντικά πράγματα που θα πρέπει να προσέξουμε με το Mox.defmock: το όνομα στην αριστερή πλευρά είναι αυθαίρετο. Τα ονόματα των ενοτήτων στην Elixir είναι απλά άτομα – δεν χρειάζεται να δημιουργήσετε καποια ενότητα πουθενά, το μόνο που κάνετε είναι να “δεσμεύετε” ένα όνομα για την ενότητα mock. Στο παρασκήνιο, η Mox θα δημιουργήσει μια ενότητα μέσα στο BEAM την ώρα της εκτέλεσης.

Το δεύτερο ζόρικο κομμάτι είναι ότι η ενότητα στην οποία αναφερόμαστε με το for: πρέπει να είναι συμπεριφορά (behavior): πρέπει να προσδιορίζει ανακλήσεις. Η Mox χρησιμοποιεί ενδοσκόπηση σε αυτή την ενότητα και μπορείτε να προσδιορίσετε συναρτήσεις mock μόνο αφού έχει οριστεί μια @callback. Έτσι η Mox επιβάλει κάποιο συμβόλαιο. Mερικές φορές μπορεί να είναι δύσκολο να βρείτε την ενότητα συμπεριφοράς: η HTTPoison για παράδειγμα, βασίζεται στην HTTPoison.Base, αλλά μπορεί να μην το γνωρίζετε αυτό αν δεν δείτε τον πηγαίο κώδικά της. Αν προσπαθείτε να δημιουργήσετε ένα mock για ένα εξωτερικό πακέτο, μπορεί να ανακαλύψετε ότι δεν υπάρχει συμπεριφορά! Σε αυτές τις περιπτώσεις μπορεί να χρειάζεται να ορίσετε τις δικές σας συμπεριφορές και ανακλήσεις ώστε να χρειάζεται κάποιο συμβόλαιο.

Αυτό αναδεικνύει ένα σημαντικό σημείο: μπορεί να θέλετε να χρησιμοποιήσετε αφαιρετικά στρώματα (γνωστά και ως πλάγια μέσα) ώστε η εφαρμογή σας να μην βασίζεται απευθείας σε εξωτερικά πακέτα, αλλα αντιθέτως θα χρησιμοποιείτε δική σας ενότητα η οποία με την σειρά της θα χρησιμοποιεί τα πακέτα. Είναι σημαντικό για μια καλογραμμένη εφαρμογή να θεσπίσετε τα σωστα “όρια”, αλλά ο τρόπος λειτουργίας των mocks να μην αλλάξει, οπότε μην το αφήσετε αυτό να σας μπερδέψει.

Τελικά, στις ενότητες δοκιμών σας, μπορείτε να χρησιμοποιήσετε τα mock σας, εισάγοντας την Mox και καλώντας την :verify_on_exit! συνάρτησή της. Τότε είστε έτοιμοι να ορίσετε επιστρεφόμενες τιμές στις ενότητες mock σας χρησιμοποιώντας μια ή περισσότερες κλήσεις της συνάρτησης expect.

defmodule MyAppTest do
  use ExUnit.Case, async: true
  # 1. Import Mox
  import Mox
  # 2. setup fixtures
  setup :verify_on_exit!

  test ":ok on 200" do
    expect(HTTPoison.BaseMock, :get, fn _ -> {:ok, "What a guy!"} end)

    assert {:ok, _} = MyModule.get_username("twinkie")
  end

  test ":error on 404" do
    expect(HTTPoison.BaseMock, :get, fn _ -> {:error, "Sorry!"} end)
    assert {:error, _} = MyModule.get_username("does-not-exist")
  end
end

Για κάθε δοκιμή, αναφερόμαστε στην ίδια ενότητα mock (σε αυτό το παράδειγμα την HTTPoison.BaseMock), και χρησιμοποιούμε την συνάρτηση expect για να ορίσουμε τις επιστρεφόμενες τιμές για κάθε κλήση συνάρτησης.

Η χρήση της Mox είναι ασφαλής για ασύγχρονες εκτελέσεις, και απαιτεί από το κάθε mock να ακολουθεί ένα συμβόλαιο. Από την στιγμή που αυτά τα mocks είναι “εικονικά”, δεν υπάρχει η ανάγκη να ορίσουμε πραγματικές ενότητες οι οποίες θα φορτώνανε την εφαρμογή μας.

Καλώς ήρθατε στα mocks στην Elixir!

Έπιασες λάθος ή θέλεις να συνεισφέρεις στο μάθημα; Επεξεργαστείτε αυτό το μάθημα στο GitHub!