Pourquoi et comment Discord utilise Patch pour tester Elixir image de couverture

Pourquoi et comment Discord utilise Patch pour tester Elixir

Anagram • Publié le 17 December 2021

Traductions

Traduction de l'article de blog: Why and How Discord Uses Patch to Test Elixir

L'équipe d'infrastructure en temps réel de Discord est chargée du bon fonctionnement des événements dans le chat textuel de Discord. Ces systèmes sont créés dans Elixir, un langage dynamique et fonctionnel pour construire des applications évolutives et faciles à maintenir.

Donc, parlons de tests à propos de code Elixir.

Elixir arrive avec ExUnit et c'est ce que nous utilisons pour tester notre code. ExUnit a toutes les fonctionnalités basiques que l'on peut attendre d'une bibliothèque de test, avec une exception notable, le “mocking”.

Mocking est quelque chose que les développeurs d'Elixir doivent comprendre d'eux-mêmes, c'est ce dont parle cet article.

Étape 1 : Faire Discord

Pour tester du code, nous avons besoin de code à tester.

defmodule Discord do
    def send_message(%Message{} = message) do
        with :ok <- validate_message(message),
             :ok <- save_message(message) do
          broadcast_message(message)
        end
    end

    defp validate_message(message) do
        with :ok <- validate_author_is_member(message),
             :ok <- validate_message_length(message),
             :ok <- validate_slowmode(message) do
          :ok
        end
    end

    defp save_message(message) do
        DurableStorage.save(message)
    end

    defp broadcast_message(message) do
        message.channel
        |> Channel.recipients()
        |> Enum.each(&send(&1, {:message, message}))
    end
end

Étape 2 : Tester Discord

Bien, nous avons un beau Discord, c'est tout ce qu'il faut pour faire un test !

Écrivons quelques tests pour des send_message/1.

defmodule Discord.Test do
    use ExUnit.Case

    describe "send_message/1" do
        test "errors on invalid messages" do
            # TODO
        end

        test "errors when message can't be durably stored" do
            # TODO
        end

        test "broadcasts message on success" do
            # TODO
        end
    end
end

Ok, comment devrions-nous écrire ce premier test ? Nous pourrions créer un message dans notre test qui échoue à l’un des contrôles de validation. Cela fonctionnerait mais aurait un certain nombre d'inconvénients. Quelle validation dans validate_message/1 devrions-nous créer ? De combien de données de fixation supplémentaires avons-nous besoin pour causer cette erreur ? Avons-nous besoin d'une liste de membres pour que la vérification de validate_author_is_member/1 puisse échouer ? Que se passe-t-il si quelqu'un ajoute une nouvelle vérification avant celle de validate_author_is_member/1 et que notre test de send_message/1 échoue parce qu'il renvoie {:error, :new_check_failed} au lieu de {:error, :not_a_member} ?

Nous pouvons prendre du recul et réaliser que nous n'avons même pas testé la fonction validate_message/1 ou n'importe quelle fonction de validate_* à l’intérieur. Nous voulons simplement que validate_message/1 retourne une erreur et affirme que si cela arrive, cela retourne un send_message/1. Mocking serait un bon ajustement pour ce problème.

Ce serait bien si nous pouvions écrire un peu de code qui ressemblerait à cela :

test "errors on invalid messages" do
    make_validate_message_return({:error, :bad})
    assert {:error, :bad} == Discord.send_message(%Message{})
end

Ajoutons une librairie de Mocking et testons cela.

Si vous cherchez Elixir Mock sur Google, vous tomberez sûrement sur Mock. Installons cette librairie et testons-la.

defmodule Discord.Test do
  use ExUnit.Case
  import Mock

  describe "send_message/1" do
    test "errors on invalid messages" do
      with_mock Discord, [validate_message: fn _ -> {:error, :bad} end] do
        assert {:error, :bad} == Discord.send_message(%Message{})
      end
    end
  end
end

Pas si compliqué, essayons de l'exécuter.

~/src/discord > mix test

1) test send_message/1 errors on invalid messages (Discord.Test)
     test/discord_test.exs:7
     ** (ErlangError) Erlang error: {:undefined_function, {Discord, :validate_message, 1}}
     code: with_mock Discord, [validate_message: fn(_message) -> {:error, :bad} end] do

3 tests, 1 failure

Euh, c'est quoi cette fonction undefined ? Eh bien c'est une fonction privée, donc on ne peut pas l'utiliser avec Mock. Maintenant, rendons-la publique, pas idéal, mais c'est ce que ça vaut.

~/src/discord > mix test

  1) test send_message/1 errors on invalid messages (Discord.Test)
     test/discord_test.exs:7
     ** (UndefinedFunctionError) function Discord.send_message/1 is undefined 
        (module Discord is not available)
     code: assert {:error, :bad} == Discord.send_message(%Message{})

3 tests, 1 failure

Ok, donc maintenant Discord n'est plus disponible. Il est temps d'en apprendre davantage sur l'option passthrough et le mocking. Changeons un petit peu notre code pour ajouter un peu de Mock à notre module.

defmodule Discord.Test do
  use ExUnit.Case

  import Mock

  describe "send_message/1" do
    test "errors on invalid messages" do
      with_mock Discord, [:passthrough], [validate_message: fn _ -> {:error, :bad} end] do
        assert {:error, :bad} == Discord.send_message(%Message{})
      end
    end
  end
end

Un essai de plus !

~/src/discord > mix test

1) test send_message/1 errors on invalid messages (Discord.Test)
     test/discord_test.exs:7
     Assertion with == failed
     code:  assert {:error, :bad} == Discord.send_message(%Message{})
     left:  {:error, :bad}
     right: :ok
     stacktrace:
       test/discord_test.exs:9: (test)

3 tests, 1 failure

Qu'est-ce qui est faux maintenant ? Oh, la section NOT SUPPORTED de la documentation vous éclairera le fait que c'est un appel de fonction interne (aussi connu sous le nom d'appel de fonction locale) et celles-là ne fonctionnent pas avec Mock.

Étape 3 : Demander de l'aide pendant que nous gigotons partout

À ce moment-là, beaucoup de nos ingénieurs, surtout ceux qui ne travaillent pas principalement avec Elixir, lèvent les bras au ciel et demandent de l'aide. Ils se retrouvent généralement devant ma porte virtuelle, se demandant pourquoi l'utilisation de Mock est tellement plus difficile avec Elixir que dans leur langage préféré. Pendant un certain temps, j'ai simplement essayé de les aider à refactoriser leur code afin de le tester et de les mettre sur la voie avec ce que Mock avait à offrir. Cette expérience a semé une graine, une petite pensée au fond de mon esprit, que cela ne devrait pas être si difficile.

Il a également fourni le principe directeur du projet dont il est question dans le reste de ce billet.

Les fonctions corrigées doivent toujours retourner la valeur de Mock qui leur est donnée.

Étape 4 : Poncer les bords tranchants

En partie pour permettre à mes collègues de mieux tester et en partie pour éviter d'avoir une énième conversation à propos de Mock, j'ai écrit une petite bibliothèque simple appelée Patch. Cette bibliothèque a commencé avec un objectif relativement modeste : rendre plus facile l'utilisation des fonctionnalités fournies par Mock.

Les ingénieurs trébuchaient constamment sur des choses comme les Mocks partiels et utilisaient rarement la partie attentes de Mock, j'ai donc construit un encapsuleur pour définir ce que je considérais comme des valeurs par défaut plus raisonnables. Une autre plainte était que les tests utilisant Mock étaient très bruyants et que le mocking était difficile à composer. Mock pense au mocking de modules, donc si vous voulez mocker quelques fonctions dans un module ou plusieurs modules, vous vous retrouvez avec un code comme celui-ci.

Voyez-vous ? C’est cette ligne entourée de ponctuation. Il y a beaucoup de code à propos de Mock dans le test lui-même, et la façon dont le test est structuré rend l'abstraction et l'encapsulation de cette préoccupation plutôt difficiles.

test "something" do
  with_mocks([
    {
      ModuleA,
      [:passthrough],
      function_a: fn _ -> :ok end,
      function_b: fn arg ->
        {:ok, arg}
      end
    },
    {
      ModuleB,
      [:passthrough],
      function_c: fn _ -> false end,
      function_d: fn _ -> true end
    }
  ]) do
    assert Something.here()
  end
end

Ci-dessous, l’équivalent du code avec Patch.

test "something" do
  patch(ModuleA, :function_a, :ok)
  patch(ModuleA, :function_b, fn arg -> {:ok, arg} end)
  patch(ModuleB, :function_c, false)
  patch(ModuleB, :function_d, true)

  assert Something.here()
end

Depuis que les appels de Patch sont des appels de fonction, vous pouvez faire une fonction patch_module_a qui encapsule ce comportement. Elle peut prendre en entrée des arguments, la puissance du langage est à votre entière disposition.

C'était une expérience beaucoup plus agréable pour nos ingénieurs, l'adoption de Patch n'a cessé de croître et les demandes de fonctionnalités ont commencé à affluer.

Étape 5 : Concevoir un plan assez fou pour fonctionner.

Patch rendait le marteau plus facile à tenir, mais ce n'était toujours qu'un marteau. Au début de notre aventure, nous voulions simplement corriger un appel de fonction local et nos ingénieurs voulaient toujours le faire. Maintenant qu'ils avaient une ligne directe avec la personne qui écrivait leur bibliothèque de tests, ils avaient toutes sortes d'autres choses intéressantes à faire.

Pour des raisons techniques inintéressantes, Patch a été initialement construit sur Meck, la bibliothèque Erlang qui alimente Mock. Meck avait exactement les mêmes limitations quand il s'agissait d'appels de fonctions locales. Eh bien, quand les outils que vous avez ne peuvent pas faire le travail, vous pouvez soit abandonner, soit créer de meilleurs outils et je n'ai jamais été du genre à abandonner.

Meck a une base de code Erlang open source très agréable et facile à lire qui m'a permis de comprendre pourquoi la limitation existe et de formuler un plan pour la remplacer par une nouvelle stratégie qui n'avait pas ces limitations.

Mock et Meck fonctionnent en créant une copie du module que vous voulez mocker et en redirigeant les appels vers un GenServer qui peut enregistrer les appels et répondre avec des valeurs simulées pour les fonctions simulées. Patch adopte une approche similaire, mais avec quelques différences essentielles.

Continuons à utiliser notre module Discord comme exemple et voyons ce que Patch nous réserve pour permettre aux appels de fonctions locales d'être simulés.

Lorsque vous simulez un module avec Patch, trois nouveaux modules sont générés dynamiquement, le Facade, le Delegate et l'Original, ainsi qu'un GenServer pour le module.

Ces modules sont tous générés à partir du fichier BEAM, et donc aucun code Elixir n'est réellement généré, mais pour des raisons de simplicité, nous allons examiner le code Elixir équivalent au lieu du format abstrait BEAM.

Voici à quoi ressemblerait notre module Facade pour Discord.

defmodule Discord do
  alias Patch.Mock.Delegate.For.Discord, as: Delegate

  def send_message(%Message{} = message) do
    Delegate.send_message(message)
  end
end

Pas très excitant, ça encapsule juste le module Delegate, voyons voir le prochain.

defmodule Patch.Mock.Delegate.For.Discord do
  alias Patch.Mock.Server

  def send_message(%Message{} = message) do
    Server.delegate(Discord, :send_message, [message])
  end

  def validate_message(message) do
    Server.delegate(Discord, :validate_message, [message])
  end

  def save_message(message) do
    Server.delegate(Discord, :save_message, [message])
  end

  def broadcast_message(message) do
    Server.delegate(Discord, :broadcast_message, [message])
  end
end

Encore une fois, ce n'est pas très excitant, mais c'est un peu différent. Le module Delegate possède toutes les fonctions, tant publiques que privées, et se contente de transmettre l'appel au GenServer du module. Ce GenServer contient en fait les valeurs de Patch et l'historique des appels, et nous pouvons donc voir comment quelqu'un qui appelle Discord.send_message/1 finit par appeler le GenServer.

Jetons un coup d'œil à la fonction delegate/3 de Patch.Mock.Server pour voir ce qu'elle fait exactement (il s'agit d'une version simplifiée de cette fonction pour plus de clarté).

def delegate(module, name, arguments) do
  server = Naming.server(module)

  case GenServer.call(server, {:delegate, name, arguments}) do
    {:ok, reply} ->
      reply

    :error ->
      original = Naming.original(module)
      apply(original, name, arguments)
  end
end

C'est assez simple, elle appelle le GenServer qui renvoie soit {:ok, reply} si la fonction a été simulée et :error si ce n'est pas le cas. Si elle renvoie :error, elle appelle simplement la fonction du module d'origine.

Voyons voir le module original.

defmodule Patch.Mock.Original.For.Discord do
  alias Patch.Mock.Delegate.For.Discord, as: Delegate

  def send_message(%Message{} = message) do
    with :ok <- Delegate.validate_message(message),
         :ok <- Delegate.save_message(message) do
      Delegate.broadcast_message(message)
    end
  end

  def validate_message(message) do
    with :ok <- Delegate.validate_author_is_member(message),
         :ok <- Delegate.validate_message_length(message),
         :ok <- Delegate.validate_slowmode(message) do
      :ok
    end
  end

  def save_message(message) do
    DurableStorage.save(message)
  end

  def broadcast_message(message) do
    message.channel
    |> Channel.recipients()
    |> Enum.each(&send(&1, {:message, message}))
  end
end

Ce module semble très familier, il ressemble presque exactement au module Discord avec lequel nous avons commencé. Il y a cependant deux grandes différences : toutes les fonctions sont désormais publiques et chaque appel de fonction locale a été transformé en appel de fonction distante vers le module Delegate que nous avons déjà vu.

C'est ici que Patch devient capable de faire quelque chose qu'aucune autre bibliothèque de mocking Elixir ne peut faire, à savoir mocker des appels de fonctions locales. Maintenant, nous pouvons revoir notre exemple et voir si nous pouvons faire fonctionner notre premier test.

defmodule Discord.Test do
  use ExUnit.Case
  use Patch

  describe "send_message/1" do
    test "errors on invalid messages" do
      patch(Discord, :validate_message, {:error, :bad})
      assert {:error, :bad} == Discord.send_message(%Message{})
    end
  end
end

Exécutons ce programme pour voir ce qu’il fait.

~/src/discord > mix test

...

3 tests, 0 failures

Cool.

Comment cela a-t-il fonctionné ? Nous pouvons suivre l’appel du graphique pour voir exactement ce qu’il s’est passé.

assert {:bad, :error} == Discord.send_message(%Message{})

# We start at the Facade
Discord.send_message(%Message{}) {
  # The Facade calls the Delegate
  Delegate.send_message(message) {
    # The Delegate checks if the Server has a patch for send_message/1
    Server.delegate(Discord, :send_message, [message]) {
      # The Server doesn't, so it calls the Original
      Original.send_message(message) {
        # The Original calls the Delegate's validate_message/1 function
        Delegate.validate_message(message) {
          # The Delegate checks if the Server has a patch for validate_message/1
          Server.delegate(Discord, :validate_message, [message]) {
            # The Server does have a patch for validate_message/1 so it returns it
          } -> {:bad, :error}
        # The Delegate returns the patched value to the Original
        } -> {:bad, :error}
      # The Original's with statement fails to match, so it returns the value
      } -> {:bad, :error}
    # The Server returns the Original's result
    } -> {:bad, :error}
  # The Delegate returns the Server's result
  } -> {:bad, :error}
# The Facade returns the Delete's result
} -> {:bad, :error}

Le graphe d'appel est un peu compliqué, mais la partie la plus importante est ce qui se passe dans le send_message/1 du module Original. Comme l'appel de la fonction locale à validate_message/1 a été transformé en un appel de la fonction distante à validate_message/1 du module Delegate, le Delegate et le Serveur peuvent intercepter cet appel et renvoyer la valeur corrigée.

La stratégie fonctionne, elle permet à l'auteur du test de corriger une fonction et cette fonction renvoie toujours la valeur corrigée. S'il s'agit d'un appel de fonction distant, le chemin d'appel va de Facade à Delegate puis au Serveur, qui peut renvoyer la valeur Mock. S'il s'agit d'un appel de fonction locale, le chemin d'appel va de Delegate à Server, qui peut retourner la valeur Mock.

Étape 6 : Exploiter les nouveaux pouvoirs découverts pour le bien de tous

Une fois que vous commencez à générer dynamiquement des modules, il est difficile de s'arrêter. La façon dont Patch fonctionne signifie que vous pouvez déjà corriger des fonctions privées sans aucune étape supplémentaire, c'était une bonne surprise. Le fait de devoir changer la visibilité de vos fonctions juste pour les tester a toujours semblé être un “hack”.

Il y a une autre occasion où nous changeons la visibilité d'une fonction pour les tests, les tests de fonctions privées. Ce n'est pas toujours une bonne idée, mais de temps en temps, une logique importante ou un bug est découvert dans une fonction privée, et pour éviter de futures régressions, les ingénieurs veulent tester cette fonction. Ceci est souvent accompagné d'un commentaire tel que "La fonction est uniquement publique pour les tests, ne l'appelez PAS directement !!!".

Puisque Patch génère le Facade, il peut simplement exposer certaines fonctions privées comme publiques à des fins de test. Cela vous permet de tester directement des éléments de code importants tout en garantissant que le code non-test de votre projet ne peut pas les appeler directement.

Cette nouvelle possibilité d'exposer des fonctions privées comme publiques a également été implémentée dans la fonction bien nommée expose/2 de Patch.

Étape 7 : utiliser Patch

Une fois que nos ingénieurs ont eu un aperçu de la possibilité de tester des choses d'une manière ergonomique et puissante, ils ont commencé à tester de plus en plus de choses. Beaucoup de nos tests utilisent encore Mock, mais de nouveaux tests sont écrits avec Patch et, à mesure que les tests sont mis à jour, les ingénieurs passent volontairement de Mock à Patch. C'est le meilleur soutien que l'on puisse espérer lorsque l’on construit un outil de développement.

Patch est parti d'une idée simple : lorsque vous corrigez une fonction, elle doit retourner la valeur corrigée. Il est devenu plus performant, mais cette idée simple reste au cœur de son fonctionnement. Tout comme Elixir lui-même, Patch s'efforce de fournir des garanties simples et faciles à comprendre.

Il existe d'autres fonctionnalités intéressantes dans Patch, sur lesquelles je pourrais écrire toute une série d'articles de blog. Des assertions d'appel qui fonctionnent comme assert_receive d'ExUnit et qui ont la capacité de lier des arguments pour une inspection supplémentaire. Des fonctions pour travailler avec des processus qui vous permettent d'écouter tous les messages envoyés à un processus ou de changer facilement l'état d'un GenServer en cours d'exécution. Valeurs Mock qui vont au-delà des fonctions et des simples valeurs de retour statiques, séquences, cycles, levée d'exceptions, valeurs de lancement. Le Quickstart fournit une référence pratique à toutes les fonctionnalités de Patch.

Patch est open source, sous licence MIT et disponible dès maintenant sur Hex ; il est livré avec une suite de tests complète, une documentation abondante et un guide. L'installation est aussi simple que l'ajout de la dépendance à votre mix.exs et, à partir de là, vous pourrez prendre encore plus de plaisir à écrire des tests unitaires en Elixir.

Si vous voulez travailler avec des personnes déterminées à rendre des choses comme les tests unitaires efficaces, faciles et amusantes, nous embauchons. Si vous êtes le genre de personne qui voit que quelque chose ne fonctionne pas bien et qui veut le faire fonctionner mieux, nous embauchons. Si vous voulez simplement travailler quelque part avec des tests, nous embauchons.