# Tester l'application grâce aux fichiers de définition yaml
Les tests de l'application sont très redondants d'une route à l'autre :
- Les droits pour accéder à la route sont-ils les bons ?
- Le retour de l'API est-il au bon format ?
- Rejette-t-elle une mauvaise clef API ?
- Le retour dépend-il bien du
Domainsur certains champs ? - Les filtres sont-ils fonctionnels ?
- ...
Afin de tester les controllers un maximum, un système de tests reposant sur une configuration yaml pour chaque route a été développé.
Les tests se trouvent dans le dossier tests, prenons l'exemple de tests/Controller/Pub/CouponControllerTest.php pour comprendre.
class CouponControllerTest extends APITestController
{
public function testFromDefinitionFile(): void
{
$this->fromDefinition(__DIR__ . DIRECTORY_SEPARATOR . static::getClassBaseName($this) . '.def.yaml');
}
}
Les tests de Controller peuvent déclarer autant de public function qu'ils le souhaitent. Chacune de ces fonctions sera appelée dans le cadre des tests, et la fonction majeure dont nous allons parler ici est héritée de l'APITestController, fromDefinition.
Celle-ci prend en paramètre le nom du fichier de définition du controller. Il est convenu de nommer le fichier {NOM_DU_CONTROLLER_DE_TEST}.def.yaml, et le code du testFromDefinitionFile reste toujours le même.
TIP
Il pourrait d'ailleurs être appelé directement dans l'APITestController sans devoir le redéclarer dans le Controller ? Si un fichier ayant le bon nom est trouvé, fromDefinition() est appelé automatiquement ? À tester.
Voyons à quoi ressemble un fichier de définition de tests de Controller :
tests/Controller/Pub/CouponControllerTest.def.yaml
model:
professional: '@...@'
entity_type: 'Coupon'
post_type: 'coupon'
id: '@integer@'
title: '@string@'
description: '@string@'
allow_comments: '@boolean@'
image: '@integer@||@null@'
created_at: '@datetime@'
updated_at: '@datetime@'
publication_date: '@datetime@'
comments: '@array@'
comments_count: '@integer@'
likes_count: '@integer@'
code: '@string@'
price: '@number@'
type: '%||€'
date_start: '@date@'
date_end: '@date@||@null@'
url: '@url@||@null@'
url_title: '@string@||@null@'
category:
entity_type: 'CouponCategory'
id: '@integer@'
name: '@string@'
parent_id: '@integer@||@null@'
children_ids: '@array@'
crud:
read_listing:
route:
url: '/public/coupon'
method: GET
security:
key: true # Is this route key protected
token: false # Is this route token protected
public_filters: # List of publicly available filters on this route
limit: ~
rand: ~
locale: ~
order_by:
- id
- price
- dateStart
id: ~
page: ~
category:
category_id: 'category.id' # default value
listing:
url: '/public/coupon-category'
method: GET
security:
key: true
strict_category:
category_id: 'category.id'
followed:
professional_id: 'professional.id'
offer: ~
city:
city_id: 'professional.address.city.id'
professional: ~ # will target professional.id
distance:
address: professional.address
is_open: ~
amenities:
amenities_array: professional.amenities
available:
date_start: date_start
date_end: date_end
enforced_filters:
domain: ~
is_active: ~
has_service:
value:
- COUPON
professional_id: 'professional.id'
read_single:
route:
url: '/public/coupon/{ID}'
method: GET
security:
key: true # Is this route key protected
token: false # Is this route token protected
enforced_filters:
domain: ~
is_active: ~
has_service:
value:
- COUPON
professional_id: 'professional.id'
# Définition du model
Une première partie du fichier est consacrée au model.
On y définit à quoi ressemble le retour de l'API pour une entité donnée sur ce Controller. C'est à dire que si un champ n'est pas supposé être visible sur ce Controller (le groupe de sérialization ne doit pas le permettre), le champ ne doit pas apparaître dans le model.
TIP
La déclaration des types dans le model est ensuite validée par le package https://github.com/coduo/php-matcher (opens new window), et doit donc correspondre à la définition de PHPMatcher.
De ce modèle ne découle aucun test pour le moment : il s'agit purement d'une déclaration du modèle qu'on devra retrouver en testant les routes.
En revanche, pour chaque route définie, le modèle sera testé intégralement.
# Définition du crud
La deuxième partie, crud, est celle dans laquelle les tests sont exécutés.
Elle est composée de plusieurs types de routes :
read_listingread_singlecreate(non présent dans ce fichier)update(non présent dans ce fichier)delete(non présent dans ce fichier)
Pour l'instant, concentrons-nous sur les tests read_*.
# route
Chaque type de route déclare la route à laquelle elle est attachée.
Ici,
crud:
read_listing:
route:
url: '/public/coupon'
method: GET
security:
key: true # Is this route key protected
token: false # Is this route token protected
La définition d'une route est toujours identique, quelque soit le type de route auquel elle est attachée. Elle peut devenir un peu plus complexe que celle présentée ici, mais une définition de route est toujours "une définition de route".
La seule chose à expliciter ici sont les paramètres de route.security :
keyindique l'obligation de présenter une clef API valide pour accéder à cette route. Il sera donc tester d'accéder à la route sans clef, ou avec une clef invalide.tokenindique l'obligation de présenter un token valide pour accéder à cette route. De même, si ce paramètre vauttrue, des tests sans token et avec des tokens invalides seront conduits.
Prenons un exemple plus complexe d'une définition de route :
read_single:
route:
url: '/public/coupon/{ID}'
method: GET
security:
key: true # Is this route key protected
token: false # Is this route token protected
Ici, le paramètre {ID} permet d'intégrer automatiquement un identifiant trouvé lors d'un appel à la route de listing associée.
WARNING
Attention, la présence d'un {ID} oblige la présence de read_listing.route.
D'autres mots-clefs peuvent être présentés :
route:
url: '/pro/{OWNED_PRO_ID}/coupon'
custom:
OWNED_PRO_ID:
has_service: COUPON
method: POST
security:
key: true # Is this route key protected
token: true # Is this route token protected
OWNED_PRO_ID sera remplacé par l'id d'un établissement (professionel) lié au compte du token fourni.
WARNING
OWNED_PRO_ID oblige la présence de security.token: true.
De même {UNOWNED_PRO_ID} permet de remplacer dans l'URL le paramètre avec l'identifiant d'un établissement non-possédé par le compte.
# public_filters (read_listing)
Ensuite vient la définition des public_filters : il s'agit des filtres disponibles sur la route donnée, permettant de filtrer les données reçues.
public_filters: # List of publicly available filters on this route
limit: ~
rand: ~
locale: ~
order_by:
- id
- price
- dateStart
id: ~
page: ~
category:
category_id: 'category.id' # default value
listing:
url: '/public/coupon-category'
method: GET
security:
key: true
strict_category:
category_id: 'category.id'
followed:
professional_id: 'professional.id'
offer: ~
city:
city_id: 'professional.address.city.id'
professional: ~ # will target professional.id
distance:
address: professional.address
is_open: ~
amenities:
amenities_array: professional.amenities
available:
date_start: date_start
date_end: date_end
Chacun de ces filtres donnera lieu à des tests poussés afin de déterminer si les résultats varient bien en fonction des filtres.
Les filtres sont communs à tous les tests : par exemple, la présente du filtre public_filters.limit: ~ (~ indique la valeur par défaut du filtre) déclenchera le test du filtre tests/Tests/Filters/LimitFilterTest.php :
class LimitFilterTest implements TestInterface
{
public static function test(TestClient $client, TestRoute $route, array $options = [])
{
$type = $options['filter_type'];
Output::blue(" → `limit` (filter) | $type");
$client->request($route->method, $route->url, [
'limit' => 10,
]);
Assert::assertLessThanOrEqual(10, count($client->data()), $client->buildErrorMessage('Filter `limit` : value 10 should not return more than 10 results'));
$client->request($route->method, $route->url, [
'limit' => 1,
]);
Assert::assertLessThanOrEqual(1, count($client->data()), $client->buildErrorMessage('Filter `limit` : value 1 should not return more than 1 results'));
$client->request($route->method, $route->url, [
'limit' => 1000,
]);
Assert::assertLessThanOrEqual(1000, count($client->data()), $client->buildErrorMessage('Filter `limit` : value 1000 should not return more than 1000 results'));
Output::success(" ✔ `limit` properly limits the number of results");
$client->request($route->method, $route->url, [
'limit' => -1,
]);
Assert::assertEquals(400, $client->getResponse()->getStatusCode(), $client->buildErrorMessage('Filter `limit` : value -1 should return a 400 status'));
$client->request($route->method, $route->url, [
'limit' => 'invalid',
]);
Assert::assertEquals(400, $client->getResponse()->getStatusCode(), $client->buildErrorMessage('Filter `limit` : value invalid should return a 400 status'));
Output::success(" ✔ invalid `limit` properly triggers a 400");
}
}
On voit ici que la présence de la clef public_filters.limit déclenche à elle seule un grand nombre de tests sur la route :
- Essai de plusieurs valeurs de filtres, en essayant de déterminer si la limite est bien respectée ;
- Essai d'envoi d'une valeur négative, doit retourner un status code 400 ;
- Essai d'envoi d'une valeur non numérique, doit retourner un status code 400.
La déclaration d'un public_filter peut être soumise à un peu plus de configuration, c'est par exemple le cas du public_filter.category :
category:
category_id: 'category.id' # default value
listing:
url: '/public/coupon-category'
method: GET
security:
key: true
Ici, on retrouve la définition d'une route dans category.listing : il s'agit de la route à laquelle aller récupérer les catégories valides, afin de pouvoir conduire des tests en envoyant des catégories valides & invalides et pouvoir en tester les résultats.
De plus, category_id y définit le champ à tester dans le model afin de s'assurer de la variance des résultats.
Autre exemple :
city:
city_id: 'professional.address.city.id'
# enforced_filters (read_listing)
Les enforced_filters sont les filtres obligatoirement activés sur une route : ce sont ceux sur lesquels le client n'a aucun pouvoir, mais dont les résultats doivent forcément correspondre au filtre.
enforced_filters:
domain: ~
is_active: ~
has_service:
value:
- COUPON
professional_id: 'professional.id'
# crud.create, crud.update
Les tests de création et modifications d'entités fonctionnent en général de la même manière : on envoie un JSON au endpoint configuré, puis on s'assure que le résultat correspond aux attentes.
Cela passe par la définition de JSON valide pour la route (doit renvoyer un statut 2XX) et invalide pour la route (doit renvoyer un statut 4XX).
De plus, dans le cas des tests valides, pour chaque valeur envoyée dans le JSON, on va s'assurer que la même valeur est présente en retour dans l'entité créée/modifiée.
Exemple :
Simple case:
title: 'test' # On va vérifier que `title` dans la réponse vaut bien `test`.
description: 'Test description'
image: # Ici, l'image est un objet plus complexe. On va utiliser le `type` de la variable pour modifier le JSON.
value: 'tests/assets/testimage.jpg'
type: 'image' # 'type' peut valoir 'image' ou 'induced' : cela aura pour effet de modifier le JSON envoyé. (cf. tests/Tools::parseValue())
Type induced:
category:
value: '%id%'
type: induced
source:
url: /public/coupon-category
method: GET
expected: '@*@'
# Références
# crud
| name | definition |
|---|---|
| read_listing | crud.read_listing, définit un endpoint de listing de ressources |
| read_single | crud.read_single, définit un endpoint de récupération de ressource |
| create | crud.create, définit un endpoint de création de ressource |
| update | crud.update, définit un endpoint de modification de ressource |
| delete | crud.delete, définit un endpoint de suppression de ressource |
# crud.read_listing
| name | definition |
|---|---|
| route | Route, définition de la route |
| public_filters | Filters, liste des filtres disponibles sur cette route (modifiables par la requête) |
| enforced_filters | Filters, liste des filtres forcés sur cette route (non modifiables par la requête) |
# crud.read_single
| name | definition |
|---|---|
| route | Route, définition de la route |
| enforced_filters | Filters, liste des filtres forcés sur cette route (non modifiables par la requête) |
# Filters
| name | options |
|---|---|
| amenity | amenity.amenities_array, pointant l'attribut où trouver le tableau d'amenities sur la ressource. |
| available | Actuellement non implémenté (génèrera un warning) |
| category | category.listing, définition de Route permettant de savoir comment lister les catégories disponibles. category_id, pointant l'attribut où trouver l'id de la catégorie liée à l'entité (default: category). Peut même fonctionner avec des attributs plus complexes à cibler : professional.categories.[].id |
| city | city.city_id : Attribut sur lequel tester la valeur de l'identifiant de la ville. Par défaut, address.city.id |
| distance | distance.address, pointant l'attribut Address de la ressource. (default : address) |
| domain | Pas d'options |
| followed | professional_id, pointant l'attribut où trouver l'id de l'établissement lié à l'entité (default: professional.id) |
| has_service | professional_id, pointant l'attribut où trouver l'id de l'établissement lié à l'entité (default: professional.id) |
| id | Pas d'options |
| is_active | Actuellement non implémenté (génèrera un warning) |
| is_open | Actuellement non implémenté (génèrera un warning) |
| limit | Pas d'options |
| locale | Pas d'options |
| offer | Pas d'options |
| order_by | Tableau d'attributs sur lesquels tester le order_by. Attention avec les dates : par exemple, createdAt est autogénéré lors de l'exécution des fixtures, et toutes les ressources partageront la même : order_by n'aura aucun effet sur createdAt et va faire échouer les tests pour des fausses raisons. |
| owned | professional_id, pointant l'attribut où trouver l'id de l'établissement lié à l'entité (default: professional.id) |
| page | Pas d'options |
| professional | professional_id, pointant l'attribut où trouver l'id de l'établissement lié à l'entité (default: professional.id) |
| rand | Pas d'options |
| strict_category | category_id, pointant l'attribut où trouver l'id de la catégorie liée à l'entité (default: category). Peut même fonctionner avec des attributs plus complexes à cibler : professional.categories.[].id |
# crud.create
| name | definition |
|---|---|
| route | Route, définition de la route |
| enforced_filters | Filters, liste des filtres forcés sur cette route (non modifiables par la requête) |
| valid | Liste d'entités à créer, dont la création doit être valide. |
| invalid | Liste d'entités à créer, dont la création doit échouer. |
# Route
| name | definition |
|---|---|
| url | string, peut contenir des variables |
| method | string, GET/POST/PUT/PATCH/DELETE/... |
| security | route.security |
| listing | Route |
| custom | route.custom |
# Route variables
| name | definition |
|---|---|
| {ID} | Prend la valeur d'un ID tiré d'une route de listing. La route de listing peut etre fournie explicitement via route.listing, ou déduite de crud.read_listing (default). |
| {OWNED_PRO_ID} | Prend la valeur d'un ID d'établissement appartenant au token utilisé. L'établissement peut etre filtré en utilisant route.custom. |
| {UNOWNED_PRO_ID} | Prend la valeur d'un ID d'établissement n'appartenant pas au token utilisé. (déprécié ?) |
# route.security
| name | definition |
|---|---|
| key | boolean, true si la route est accessible uniquement avec une clef API (default: false) |
| token | boolean, is route token protected (default: false) |
# route.custom
| name | definition |
|---|---|
| OWNED_PRO_ID | Paramètres à envoyer à la route de listing d'établissements. Par exemple : route.custom.OWNED_PRO_ID.has_service: COUPON |