Bon après-midi Je suis engagé dans l'automatisation des tests. Comme tous les ingénieurs en automatisation, j'ai un ensemble de bibliothèques et d'outils que je choisis généralement pour écrire des tests. Mais périodiquement, il existe des situations où aucune des bibliothèques connues ne peut résoudre le problème avec le risque de rendre les autotests instables ou fragiles. Dans cet article, je voudrais vous dire comment la tâche apparemment standard d'utilisation de mock'ov m'a amené à écrire mon module. Je voudrais également partager ma décision et entendre des commentaires.
App
L'audit est l'un des secteurs nécessaires du secteur financier. Les données doivent être vérifiées régulièrement (rapprochement). À cet égard, l'application que j'ai testée est apparue. Afin de ne pas parler de quelque chose d'abstrait, imaginons que notre équipe développe une application pour le traitement des applications des messageries instantanées. Pour chaque application, un événement approprié doit être créé dans elasticsearch. L'application de vérification sera notre surveillance que les applications ne sont pas ignorées.
Imaginez donc que nous ayons un système qui comprend les composants suivants:
- Serveur de configuration. Pour l'utilisateur, il s'agit d'un point d'entrée unique où il configure non seulement l'application pour vérification, mais également d'autres composants du système.
- Application de vérification.
- Données des applications de traitement des applications stockées dans elasticsearch.
- Données de référence. Le format des données dépend du messager avec lequel l'application est intégrée.
Défi
Le test de l'automatisation dans ce cas semble assez simple:
- Préparation de l'environnement:
- Elasticsearch est installé avec une configuration minimale (en utilisant msi et la ligne de commande).
- Une application de vérification est installée.
- Exécution du test:
- Une application de vérification est configurée.
- Elasticsearch est rempli de données de test pour le test correspondant (combien de demandes ont été traitées).
- L'application reçoit des données "de référence" du messager (combien d'applications étaient censées être réellement).
- Le verdict émis par la demande est vérifié: le nombre de demandes vérifiées avec succès, le nombre de demandes manquantes, etc.
- Nettoyage de l'environnement.
Le problème est que nous testons la surveillance, mais pour la configurer, nous avons besoin des données du serveur de configuration. Tout d'abord, l'installation et la configuration d'un serveur pour chaque exécution est une opération longue (il a sa propre base, par exemple). Deuxièmement, je souhaite isoler les applications afin de simplifier la localisation des problèmes lors de la recherche d'un défaut. En fin de compte, il a été décidé d'utiliser une maquette.
Cela peut soulever la question: "Si nous nous moquons toujours du serveur, nous ne pouvons peut-être pas passer du temps à installer et remplir elasticsearch, mais remplacer mock?". Mais encore, vous devez toujours vous rappeler que l'utilisation de la simulation offre de la flexibilité, mais ajoute une obligation de surveiller la pertinence du comportement de la simulation. J'ai donc refusé de remplacer elasticsearch: c'est assez simple à installer et à remplir.
Première maquette
Le serveur envoie la configuration aux demandes GET de plusieurs manières dans / configuration. Nous sommes intéressés de deux manières. Le premier est /configuration/data_cluster
avec la configuration du cluster
{ "host": "127.0.0.1", "port": 443, "credentials": { "username": "user", "password": "pass" } }
La seconde est /configuration/reconciliation
avec la configuration de l'application de forage
{ "reconciliation_interval": 3600, "configuration_update_interval": 60, "source": { "address": "file:///c:/path", "credentials": { "username": "user", "password": "pass" } } }
La difficulté est que vous devez pouvoir changer la réponse du serveur pendant le test ou entre les tests afin de tester la façon dont l'application répond aux changements de configuration, aux mots de passe incorrects, etc.
Ainsi, les simulations statiques et les outils pour les simulations dans les tests unitaires (simulation, monkeypatch de pytest, etc.) ne fonctionneront pas pour nous. J'ai trouvé une grande bibliothèque de pretenders
que je pensais être la bonne pour moi. Pretenders offre la possibilité de créer un serveur HTTP avec des règles qui déterminent comment le serveur répondra aux demandes. Les règles sont stockées dans des préréglages, ce qui permet d'isoler les simulations pour différentes suites de tests. Les préréglages peuvent être effacés et remplis à nouveau, vous permettant de mettre à jour les réponses au besoin. Il suffit d'élever le serveur lui-même une fois lors de la préparation de l'environnement:
python -m pretenders.server.server --host 127.0.0.1 --port 8000
Et dans les tests, nous devons ajouter l'utilisation du client. Dans le cas le plus simple, lorsque les réponses sont complètement codées en dur dans les tests, cela peut ressembler à ceci:
import json import pytest from pretenders.client.http import HTTPMock from pretenders.common.constants import FOREVER @pytest.fixture def configuration_server_mock(request): mock = HTTPMock(host="127.0.0.1", port=8000, name="server") request.addfinalizer(mock.reset) return mock def test_something(configuration_server_mock): configuration_server_mock.when("GET /configuration/data_cluster").reply( headers={"Content-Type": "application/json"}, body=json.dumps({ "host": "127.0.0.1", "port": 443, "credentials": { "username": "user", "password": "pass", }, }), status=200, times=FOREVER, ) configuration_server_mock.when("GET /configuration/reconciliation").reply( headers={"Content-Type": "application/json"}, body=json.dumps({ "reconciliation_interval": 3600, "configuration_update_interval": 60, "source": { "address": "file:///c:/path", "credentials": { "username": "user", "password": "pass", }, }, }), status=200, times=FOREVER, )
Mais ce n'est pas tout. Avec sa flexibilité, les pretenders
ont deux limites qui doivent être rappelées et doivent être abordées dans notre cas:
- Les règles ne peuvent pas être supprimées une par une. Pour modifier la réponse, vous devez supprimer l'intégralité du préréglage et recréer à nouveau toutes les règles.
- Tous les chemins utilisés dans les règles sont relatifs. Les préréglages ont un chemin unique de la forme / mockhttp / <nom_préset>, et ce chemin est un préfixe commun pour tous les chemins créés dans les règles. L'application testée ne reçoit que le nom d'hôte et ne peut pas connaître le préfixe.
La première limitation est très désagréable, mais peut être résolue en écrivant un module qui encapsule le travail avec la configuration. Par exemple,
configuration.data_cluster.port = 443
ou (pour effectuer des demandes de mise à jour moins fréquemment)
data_cluster_config = get_default_data_cluster_config() data_cluster_config.port = 443 configuration.update_data_cluster(data_cluster_config)
Une telle encapsulation nous permet de mettre à jour tous les chemins presque sans douleur. Vous pouvez également créer un préréglage individuel pour chaque point de terminaison individuel et un préréglage général (principal), redirigeant (via 307 ou 308) vers des points individuels. Ensuite, vous ne pouvez effacer qu'un seul préréglage pour mettre à jour la règle.
Afin de vous débarrasser des préfixes, vous pouvez utiliser la bibliothèque mitmproxy . Il s'agit d'un outil puissant qui permet, entre autres, de rediriger les demandes. Nous supprimerons les préfixes comme suit:
mitmdump --mode reverse:http://127.0.0.1:8000 --replacements :~http:^/:/mockhttp/server/ --listen-host 127.0.01 --listen-port 80
Les paramètres de cette commande procèdent comme suit:
--listen-host 127.0.0.1
et --listen-port 80
évidents. Mitmproxy élève son serveur, et avec ces paramètres, nous déterminons l'interface et le port que ce serveur écoutera.--mode reverse:http://127.0.0.1:8000
signifie que les requêtes vers le serveur mitproxy seront redirigées vers http://127.0.0.1:8000
. Lisez plus ici .--replacements :~http:^/:/mockhttp/server/
définit un modèle par lequel les requêtes seront modifiées. Il se compose de trois parties: un filtre de requête ( ~http
pour les requêtes HTTP), un modèle pour changer ( ^/
pour remplacer le début du chemin), et réellement remplacer ( /mockhttp/server
). Lisez plus ici .
Dans notre cas, nous ajoutons mockhttp/server
à toutes les requêtes HTTP et les redirigeons vers http://127.0.0.1:8000
, c'est-à-dire à nos prétendants serveurs. En conséquence, nous avons réalisé que maintenant la configuration peut être obtenue avec une requête GET à http://127.0.0.1/configuration/data_cluster
.
En général, j'étais satisfait du design avec des pretenders
et mitmproxy
. Malgré la complexité apparente - après tout, 2 serveurs au lieu d'un seul - la préparation consiste à installer 2 packages et à exécuter 2 commandes sur la ligne de commande pour les démarrer. Tout n'est pas aussi simple dans la gestion d'une maquette, mais toute la complexité réside dans un seul endroit (gestion des préréglages) et est résolue de manière assez simple et fiable. Cependant, de nouvelles circonstances sont apparues dans le problème qui m'a fait réfléchir à une nouvelle solution.
Deuxième maquette
Jusqu'à ce moment, je n'ai presque pas dit d'où venaient les données de référence. Un lecteur attentif peut remarquer que dans l'exemple ci-dessus, le chemin d'accès au système de fichiers est utilisé comme adresse de source de données. Et cela fonctionne vraiment quelque chose comme ça, mais seulement pour l'un des fournisseurs. Un autre fournisseur propose une API pour la réception des candidatures, et c'est avec lui qu'un problème est survenu. Il est difficile de faire monter l'API du fournisseur pendant les tests, j'ai donc prévu de la remplacer par une maquette selon le même schéma qu'auparavant. Mais pour recevoir les candidatures, une demande du formulaire
GET /application-history?page=2&size=5&start=1569148012&end=1569148446
Il y a 2 points ici. Tout d'abord, quelques options. Le fait est que les paramètres peuvent être spécifiés dans n'importe quel ordre, ce qui complique considérablement l'expression régulière de la règle des pretenders
. Il est également nécessaire de se rappeler que les paramètres sont facultatifs, mais ce n'est pas un problème tel que l'ordre aléatoire. Deuxièmement, les derniers paramètres (début et fin) spécifient l'intervalle de temps pour filtrer la commande. Et le problème dans ce cas est que nous ne pouvons pas prédire à l'avance quel intervalle (pas l'ampleur, mais l'heure de début) sera utilisé par l'application pour former la réponse factice. Autrement dit, nous devons connaître et utiliser les valeurs des paramètres pour former une réponse «raisonnable». La «raisonnabilité» dans ce cas est importante, par exemple, pour que nous puissions tester que l'application parcourt toutes les pages de la pagination: si nous répondons à toutes les demandes de la même manière, nous ne pourrons pas trouver de défauts car une seule page sur cinq est demandée .
J'ai essayé de chercher des solutions alternatives, mais j'ai finalement décidé d'écrire la mienne. Il y avait donc un serveur lâche . Il s'agit d'une application Flask dans laquelle les chemins et les réponses peuvent être configurés après son démarrage. Hors de la boîte, il sait comment travailler avec des règles pour le type de demande (GET, POST, etc.) et pour le chemin. Cela vous permet de remplacer les pretenders
et mitmproxy
dans la tâche d'origine. Je montrerai également comment il peut être utilisé pour créer une maquette pour l'API du fournisseur.
Une application a besoin de 2 chemins principaux:
- Point final de base. Il s'agit du même préfixe qui sera utilisé pour toutes les règles configurées.
- Point de terminaison de configuration. Il s'agit du préfixe de ces requêtes avec lesquelles vous pouvez configurer le faux serveur lui-même.
python -m looseserver.default.server.run --host 127.0.0.1 --port 80 --base-endpoint / --configuration-endpoint /_mock_configuration/
En général, il est préférable de ne pas configurer le point de terminaison de base et le point de terminaison de configuration de sorte que l'un soit le parent de l'autre. Sinon, il existe un risque de conflit entre les chemins de configuration et de test. Le point de terminaison de configuration aura la priorité, car les règles de flacon sont ajoutées pour la configuration plus tôt que pour les chemins dynamiques. Dans notre cas, nous pourrions utiliser --base-endpoint /configuration/
si nous --base-endpoint /configuration/
pas inclure l'API du fournisseur dans cette maquette.
La version la plus simple des tests ne change pas grand-chose
import json import pytest from looseserver.default.client.http import HTTPClient from looseserver.default.client.rule import PathRule from looseserver.default.client.response import FixedResponse @pytest.fixture def configuration_server_mock(request): class MockFactory: def __init__(self): self._client = HTTPClient(configuration_url="http://127.0.0.1/_mock_configuration/") self._rule_ids = [] def create_rule(self, path, json_response): rule = self._client.create_rule(PathRule(path=path)) self._rule_ids.append(rule.rule_id) response = FixedResponse( headers={"Content-Type": "application/json"}, status=200, body=json.dumps(json_response), ) self._client.set_response(rule_id=rule.rule_id, response=response) def _delete_rules(self): for rule_id in self._rule_ids: self._client.remove_rule(rule_id=rule_id) mock = MockFactory() request.addfinalizer(mock._delete_rules) return mock def test_something(configuration_server_mock): configuration_server_mock.create_rule( path="configuration/data_cluster", json_response={ "host": "127.0.0.1", "port": 443, "credentials": { "username": "user", "password": "pass", }, } ) configuration_server_mock.create_rule( path="configuration/reconciliation", json_response={ "reconciliation_interval": 3600, "configuration_update_interval": 60, "source": { "address": "file:///applications", "credentials": { "username": "user", "password": "pass", }, }, } )
Le montage est devenu plus difficile, mais les règles peuvent maintenant être supprimées une par une, ce qui simplifie le travail avec elles. L'utilisation de mitmproxy
n'est plus nécessaire.
Revenons à l'API du fournisseur. Nous allons créer un nouveau type de règles pour un serveur libre, qui, selon la valeur du paramètre, donnera des réponses différentes. Ensuite, nous utiliserons cette règle pour le paramètre de page.
De nouvelles règles et réponses doivent être créées pour le serveur et le client. Commençons par le serveur:
from looseserver.server.rule import ServerRule class ServerParameterRule(ServerRule): def __init__(self, parameter_name, parameter_value=None, rule_type="PARAMETER"): super(ServerParameterRule, self).__init__(rule_type=rule_type) self._parameter_name = parameter_name self._parameter_value = parameter_value def is_match_found(self, request): if self._parameter_value is None: return self._parameter_name not in request.args return request.args.get(self._parameter_name) == self._parameter_value
Chaque règle doit définir une méthode is_match_found
, qui détermine si elle doit fonctionner pour une demande donnée ou non. Le paramètre d'entrée correspondant est l'objet de requête. Une fois la nouvelle règle créée, il est nécessaire «d'apprendre» au serveur à l'accepter du client. Pour ce faire, utilisez RuleFactory
:
from looseserver.default.server.rule import create_rule_factory from looseserver.default.server.application import configure_application def _create_application(base_endpoint, configuration_endpoint): server_rule_factory = create_rule_factory(base_endpoint) def _parse_param_rule(rule_type, parameters): return ServerParameterRule( rule_type=rule_type, parameter_name=parameters["parameter_name"], parameter_value=parameters["parameter_value"], ) server_rule_factory.register_rule( rule_type="PARAMETER", parser=_parse_param_rule, serializer=lambda rule_type, rule: None, ) return configure_application( rule_factory=server_rule_factory, base_endpoint=base_endpoint, configuration_endpoint=configuration_endpoint, ) if __name__ == "__main__": application = _create_application(base_endpoint="/", configuration_endpoint="/_mock_configuration") application.run(host="127.0.0.1", port=80)
Ici, nous créons une fabrique pour les règles par défaut, afin qu'elle contienne les règles que nous avons utilisées auparavant, et enregistrons un nouveau type. Dans ce cas, le client n'a pas besoin des informations de règle, le serializer
ne fait donc rien. De plus, cette usine est transférée à l'application. Et il peut déjà être exécuté comme une application Flask standard.
La situation avec le client est similaire: nous créons une règle et une usine. Mais pour le client, d'une part, il n'est pas nécessaire de définir la méthode is_match_found
, et d'autre part, le sérialiseur dans ce cas est nécessaire pour envoyer la règle au serveur.
from looseserver.client.rule import ClientRule from looseserver.default.client.rule import create_rule_factory class ClientParameterRule(ClientRule): def __init__(self, parameter_name, parameter_value=None, rule_type="PARAMETER", rule_id=None): super(ClientParameterRule, self).__init__(rule_type=rule_type, rule_id=rule_id) self.parameter_name = parameter_name self.parameter_value = parameter_value def _create_client(configuration_url): def _serialize_param_rule(rule): return { "parameter_name": rule.parameter_name, "parameter_value": rule.parameter_value, } client_rule_factory = create_rule_factory() client_rule_factory.register_rule( rule_type="PARAMETER", parser=lambda rule_type, parameters: ClientParameterRule(rule_type=rule_type, parameter_name=None), serializer=_serialize_param_rule, ) return HTTPClient(configuration_url=configuration_url, rule_factory=client_rule_factory)
Il reste à utiliser _create_client
pour créer le client, et les règles peuvent être utilisées dans les tests. Dans l'exemple ci-dessous, j'ai ajouté l'utilisation d'une autre règle par défaut: CompositeRule
. Il vous permet de combiner plusieurs règles en une seule pour qu'elles ne fonctionnent que si chacune d'elles renvoie True pour avoir appelé is_match_found
.
@pytest.fixture def configuration_server_mock(request): class MockFactory: def __init__(self): self._client = _create_client("http://127.0.0.1/_mock_configuration/") self._rule_ids = [] def create_paged_rule(self, path, page, json_response): rule_prototype = CompositeRule( children=[ PathRule(path=path), ClientParameterRule(parameter_name="page", parameter_value=page), ] ) rule = self._client.create_rule(rule_prototype) self._rule_ids.append(rule.rule_id) response = FixedResponse( headers={"Content-Type": "application/json"}, status=200, body=json.dumps(json_response), ) self._client.set_response(rule_id=rule.rule_id, response=response) ... mock = MockFactory() request.addfinalizer(mock._delete_rules) return mock def test_something(configuration_server_mock): ... configuration_server_mock.create_paged_rule( path="application-history", page=None, json_response=["1", "2", "3"], ) configuration_server_mock.create_paged_rule( path="application-history", page="1", json_response=["1", "2", "3"], ) configuration_server_mock.create_paged_rule( path="application-history", page="2", json_response=["4", "5"], )
Conclusion
Une mitmproxy
pretenders
et de mitmproxy
fournit un outil suffisamment puissant et flexible pour créer des simulations. Ses avantages:
- Configuration facile.
- Possibilité d'isoler des ensembles de requêtes à l'aide de préréglages.
- Supprime un ensemble isolé entier à la fois.
Par contre inclure:
- La nécessité de créer des expressions régulières pour les règles.
- L'incapacité de changer les règles individuellement.
- La présence d'un préfixe pour tous les chemins créés ou l'utilisation de la redirection à l'aide de
mitmproxy
.
Liens de documentation:
Prétendants
Mitmproxy
Serveur lâche