# 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 Domain sur 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_listing
  • read_single
  • create (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 :

  • key indique 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.
  • token indique l'obligation de présenter un token valide pour accéder à cette route. De même, si ce paramètre vaut true, 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

# crud