blog

BDD Guía práctica: Testar APIs securizadas con Oauth2 usando BDD (Behat)

Seguramente hayas llegado aquí curioseando qué es eso de BDD. Pues bien, las siglas BDD, TDD… hacen referencia al mismo concepto: la búsqueda de calidad en el software que se entrega en producción. Los desarrollos de software son cada vez más complejos y eso hace que llevar un control de que tu aplicación no sufra fallos inesperados se torna complicado. De ahí nace la automatización de las pruebas, es decir, mecanismos y herramientas que permiten comprobar que el software que escribimos hace lo que tiene que hacer. Ni más, ni menos.

Existen muchos tipos de test automáticos: test unitarios, funcionales, de integración, e2e, de regresión, tests mutantes, de aceptación, de rendimiento… pero todos ellos tienen algo en común: se ejecutan por máquinas y siempre prueban lo que se les ha pedido que prueben, eliminamos por tanto el factor fallo humano de las pruebas manuales.

Búsqueda de calidad en el código

La búsqueda de calidad en el código entregado en producción nos trae tecnología como la que vamos a ver hoy: Behat. Behat es un framework de testing basado en comportamiento, o Behavior-Driven Development, una herramienta que permite entregar software a través de automatización de los tests. Existe otra sigla, TDD, que hace referencia al concepto de Testing Driven Development. Ambos conceptos son perfectamente compatibles entre sí, veamos en qué se diferencian:
  • TDD: El TDD es una metodología de desarrollo de software basada en pruebas. En lugar del desarrollo tradicional, donde primero se genera código que produce una determinada salida y luego se escribe un test que cubra ese caso de uso, en el TDD la forma de desarrollo es distinta. Antes de escribir código, se escribe un test que cubra el caso de uso que se quiere desarrollar. Obviamente la primera vez que ejecutemos ese test va a fallar, pero es completamente necesario que así sea. Una vez que el test ha fallado se hace la mínima implementación necesaria para que el test pase, y, solo una vez que el test pasa, se procede a refactorizar la funcionalidad que cubre el caso de uso para poder lanzarla a producción. Es una metodología de desarrollo que se basa en microaportes al repositorio de código, step by step, granito a granito, se va aumentando la funcionalidad desarrollada siempre con test-cases que cubren dicha funcionalidad.
  • BDD: una vuelta de tuerca al TDD. El TDD cubre todo lo relacionado con tests unitarios, mientras que BDD va más allá, son test de integración, de comportamiento o de aceptación, es decir, más allá de cómo se haya implementado la funcionalidad, el BDD intenta cubrir el caso de uso o historia de usuario que va a aportar valor a nuestro proyecto. Vienen siendo los criterios de aceptación que van adjuntos a nuestra historia de usuario.
Para escribir todos estos criterios de aceptación se usa un metalenguaje llamado Gherkin. Gherkin es un DSL (Domain-Specific Language), es decir, está diseñado para resolver un determinado problema. Es un lenguaje que es legible tanto por humanos como por máquinas, de forma que un usuario no-técnico puede llegar a entender e incluso escribir este tipo de test-escenarios. Existe una diferencia con los test unitarios/funcionales que escribimos con TDD (por ejemplo sobre la herramienta de testing PHPUnit) donde el lenguaje no es fácilmente legible por los que no son desarrolladores ya que solemos trabajar con conceptos como instanciación de objetos, mocks, aserciones… conceptos que son lenguaje puramente de desarrollo de software.
Las historias de usuario suelen escribirse de la siguiente forma:
US Title: This is the user story title

As a <user role>
I want <goal>
So that <benefit>
O con un ejemplo real:
As a manager
I want a sales report of daily sales
So that I can monitor the correct evolution of the store sales
A continuación de la historia de usuario escribiríamos los criterios de aceptación, que son todos los condicionantes que se tienen que dar para que la historia de usuario se considere como buena. Para ello partimos de la siguiente plantilla:
Acceptance criteria

Scenario n: <title>
Given <context>
 And <morecontext>
When <event>
Then <result>
 And <otherresult>
Esto se ve mejor sobre un ejemplo real. Imaginémonos que queremos comprobar que el registro de usuario en nuestra aplicación se hace de forma correcta. Podríamos escribir un escenario como este:
Scenario: We can create api users if we have the correct role
When I add "Content-Type" header equal to "application/json"
And I send an authenticated "POST" request to "/api/user" as a "ROLE_SUPER_ADMIN" with body:
  """
  {
      "email": "newuser@localhost.com",
      "firstName": "Foo",
      "lastName": "Bar",
      "password": "Thefoobar1",
      "passwordConfirmation": "Thefoobar1",
      "roles": [
          "ROLE_LEAD"
      ],
      "capabilitiesIds": [],
      "active": "true"
  }
  """
Then the response status code should be 200
And the JSON node "email" should match string "newuser@localhost.com"
And the JSON node "id" should match "/[a-f0-9]{8}\-[a-f0-9]{4}\-4[a-f0-9]{3}\-(8|9|a|b)[a-f0-9]{3}\-[a-f0-9]{12}/"
Lo bueno de behat y gherkin es que los scenarios son autoexplicativos, cualquiera con una mínima noción de peticiones HTTP y navegación web puede entender y escribir este tipo de tests.
A la hora de escribir los distintos features en behat nos encontramos con distintos conceptos:
  • Feature: todos los ficheros .feature se corresponden con una funcionalidad concreta, y normalmente contienen una lista de scenarios. Por resumirlo de alguna manera, Feature es la historia de usuario y scenarios son todos los criterios de aceptación de esa historia.
  • Scenario: una lista de pasos, cada paso de la lista debe empezar por Given, When, Then, But o And.
  • Background: permite añadir contexto a todos los scenarios de una misma feature.
  • Tags: permiten organizar features y scenarios, cada feature o scenario puede tener tantos tags como se necesite. Deben empezar por @.
  • Hooks: permiten ejecutar código justo antes o después de cada acción. Existen estos hooks: BeforeSuite, AfterSuite, BeforeFeature, AfterFeature, BeforeScenario, AfterScenario, BeforeStep, AfterStep. Son muy útiles para, por ejemplo, preparar la BBDD entre scenario y scenario.

Requisitos

Llegados a este punto vamos a asumir que tienes conocimientos suficientes de composer, php, symfony y phpunit. El primer requisito es instalar las dependencias necesarias:

Behat: la herramienta de testing.
Behatch: contextos para behat que aceleran y mejora la escritura de escenarios (posteriormente en este artículo extenderemos algunos de estos contextos).
Mink: librería que permite testar el comportamiento de nuestra aplicación en un navegador web. http://mink.behat.org/en/latest/
friends-of-behat/symfony-extension: extensión para symfony con behat, permite usar el inyector de dependencias del contenedor de symfony en los contextos.
coduo/php-matcher: librería para testar JSON/XML/TXT/Escalares contra patrones regex.

Ejecutando el siguiente comando composer instalaremos estas dependencias:

composer require --dev behat/behat behatch/contexts coduo/php-matcher friends-of-behat/mink friends-of-behat/mink-browserkit-driver friends-of-behat/mink-extension friends-of-behat/symfony-extension

Una vez instalado, si ejecutamos el comando:

vendor/bin/behat --init

Ejecutará todo lo necesario para establecer la base de nuestro entorno de testing con behat. Nos habrá creado principalmente dos cosas:

  • Una carpeta /feature: aquí es donde escribiremos nuestros escenarios en lenguaje gherkin, son ficheros *.feature.
  • Una clase tests/Behat/FeatureContext.php: es el contexto que crea por defecto para rellenarlo con nuestras operativas Given, When y Then que mejor se adapten a nuestro negocio y casos de uso. Al haber instalado la librería behatch/context dispondremos de un montón de contextos ya cocinados y listos para ser usados: https://github.com/Behatch/contexts
  • Un fichero de configuración behat.yml.dist: aquí es donde configuraremos el comportamiento de behat en nuestra aplicación, que contextos usará, etc. Más adelante veremos como configurarlo para usarlo en una aplicación symfony5 API REST sobre Oauth2.

Queremos chicha: suite de test con behat sobre app API REST +Oauth2 en Symfony5

A continuación procederemos a explicar como configurar behat para permitir testar aplicaciones API REST sobre Symfony5 securizadas con OAuth2. Detallaremos configuración y contexto necesarias para permitir escribir nuestras features de la forma más sencilla posible.

 

1. Configuración, behat.yml.dist

Editamos el fichero behat.yml.dist con la siguiente información:

default:
    suites: # cuando escribimos tests sobre BDD con Behat, es posible separar funcionalidades por suites, de forma que queden los contextos más acotados a cada caso de uso.
        default:
            contexts: # Listado de contextos disponibles en los .feature de nuestra suite de pruebas 
                - App\Tests\Behat\JsonContext # extensión del JsonContext de behatch
                - App\Tests\Behat\AuthenticationContext # contexto para peticiones REST autenticadas con OAuth2
                - Behatch\Context\TableContext 
                - Behat\MinkExtension\Context\MinkContext

    extensions:
        FriendsOfBehat\SymfonyExtension:
            kernel:
                class: App\Kernel
                environment: test
            bootstrap: ./tests/bootstrap.php
        Behatch\Extension: ~
        Behat\MinkExtension:
            sessions:
                default:
                    symfony: ~

2. Creando los contextos

Haremos uso de dos contextos personalizados: AuthenticationContext y JsonContext. Estos contextos proporcionan comandos Gherkin tales como And I send an authenticated "GET" request to "/api/user" as a "ROLE_SUPER_ADMIN" o And the JSON node "id" should match "/[a-f0-9]{8}\-[a-f0-9]{4}\-4[a-f0-9]{3}\-(8|9|a|b)[a-f0-9]{3}\-[a-f0-9]{12}/"

 

AuthenticationContext.php

namespace App\Tests\Behat;

use Behat\Gherkin\Node\PyStringNode;
use Behat\Gherkin\Node\TableNode;
use Behat\Mink\Element\DocumentElement;
use Behatch\Context\RestContext as BehatchRestContext;
use Behatch\HttpCall\Request;
use Doctrine\ORM\EntityManager;
use FOS\OAuthServerBundle\Model\ClientInterface;
use FOS\OAuthServerBundle\Model\ClientManagerInterface;
use App\Authorization\Application\UseCase\User\Create\CreateUserRequest;
use App\Authorization\Infrastructure\Validation\Constraint\UserConstraints;
use App\Shared\Infrastructure\Bus\SynchronousBus;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Stamp\ValidationStamp;

class AuthenticationContext extends BehatchRestContext
{
    private ClientManagerInterface $clientManager;
    private ClientInterface        $client;
    private EntityManager          $em;
    private string                 $accessToken = '';
    private array                  $users       = [];

    public function __construct(
        Request $request,
        ClientManagerInterface $clientManager,
        EntityManager $em,
        private SynchronousBus $synchronousBus)
    {
        parent::__construct($request);
        $this->clientManager = $clientManager;
        $this->em            = $em;
    }

    public function buildOauthClient(): void
    {
        $client = $this->clientManager->createClient();

        $client->setRedirectUris(['http://localhost:8080']);
        $client->setAllowedGrantTypes(['password']);

        // Save the client
        $this->clientManager->updateClient($client);

        $this->client = $client;
    }

    public function buildUsersWithDifferentRoles(): void
    {
        foreach (['ROLE_SUPER_ADMIN', 'ROLE_MANAGER', 'ROLE_OWNER', 'ROLE_TENANT', 'ROLE_LEAD'] as $role) {
            $create             = new CreateUserRequest(
                'dummyuser'.strtolower($role).'@localhost.com',
                'Temporal1',
                'Temporal1',
                'DummyUser',
                $role,
                true,
                [],
                [$role]
            );
            $envelope           = new Envelope($create, [new ValidationStamp([UserConstraints::CREATE_COMMAND_GROUP])]);
            $this->users[$role] = $this->synchronousBus->dispatch($envelope);
        }
    }

    /** @Given we have some context */
    public function prepareContext(): void
    {
        $this->clearTestDB();
        $this->buildOauthClient();
        $this->buildUsersWithDifferentRoles();
    }

    public function clearTestDB(): void
    {
        $this->em->getConnection()->prepare('SET FOREIGN_KEY_CHECKS = 0;')->executeStatement();

        foreach ($this->em->getConnection()->getSchemaManager()->listTableNames() as $tableName) {
            $sql = 'TRUNCATE TABLE '.$tableName;
            $this->em->getConnection()->prepare($sql)->executeStatement();
        }

        $this->em->getConnection()->prepare('SET FOREIGN_KEY_CHECKS = 1;')->executeStatement();
    }

    /**
     * Sends an Authentication HTTP request.
     *
     * @Given I send an authenticated :method request to :url as a :role
     */
    public function iSendAnAuthenticatedRequestToAsA(
        string $method,
        string $url,
        string $role,
        PyStringNode $body = null,
        array $files = []): DocumentElement
    {
        $this->request->setHttpHeader('Authorization', 'Bearer '.$this->getAccessToken($role));
        $this->request->setHttpHeader('Content-Type', 'application/json');

        $request = $this->iSendARequestTo($method, $url, $body, $files);
        $content = $request->getContent();

        return $request;
    }

    /**
     * Sends an Authentication HTTP request with custom role and custom body.
     *
     * @Given I send an authenticated :method request to :url as a :role with body:
     */
    public function iSendAnAuthenticatedRequestToAsAWithBody(
        string $method,
        string $url,
        string $role,
        PyStringNode $body = null,
        array $files = []): DocumentElement
    {
        $this->request->setHttpHeader('Authorization', 'Bearer '.$this->getAccessToken($role));
        $this->request->setHttpHeader('Content-Type', 'application/json');

        $request = $this->iSendARequestTo($method, $url, $body, $files);
        $content = $request->getContent();

        return $request;
    }

    /**
     * @Given I send a token request as a :role
     */
    public function iSendATokenRequestAsA(string $role): DocumentElement
    {
        $params    = [
            ['key', 'value'],
            ['client_secret', $this->client->getSecret()],
            ['client_id', $this->client->getPublicId()],
            ['grant_type', 'password'],
            ['username', 'dummyuser'.strtolower($role).'@localhost.com'],
            ['password', 'Temporal1'],
            ['scope', ''],
        ];
        $tableNode = new TableNode($params);

        return $this->iSendARequestToWithParameters('POST', '/oauth/v2/token', $tableNode);
    }

    public function getAccessToken(string $role = 'ROLE_SUPER_ADMIN'): string
    {
        if ('' == $this->accessToken) {
            $request           = $this->iSendATokenRequestAsA($role);
            $content           = json_decode($request->getContent(), false);
            $this->accessToken = $content->access_token;
        }

        return $this->accessToken;
    }
}

JsonContext.php

namespace App\Tests\Behat;

use Behat\Gherkin\Node\PyStringNode;
use Behat\Gherkin\Node\TableNode;
use Behatch\Context\JsonContext as BehatchJsonContext;
use Coduo\PHPMatcher\PHPUnit\PHPMatcherAssertions;
use App\Tests\Common\Support\Helper\PHPUnitHelper;

class JsonContext extends BehatchJsonContext
{
    use PHPMatcherAssertions;

    public function theJsonShouldBeEqualTo(PyStringNode $content): void
    {
        $this->assertJson($content->getRaw(), $this->getJson());
    }

    public function assertJson(string $expected, string $actual): void
    {
        $expected = preg_replace('/\s(?=([^"]*"[^"]*")*[^"]*$)/', '', $expected);

        $this->match($expected, $actual);
    }

    /**
     * Checks, that given JSON nodes match values.
     *
     * @Then the JSON nodes should match:
     */
    public function theJsonNodesShouldMatch(TableNode $nodes): void
    {
        foreach ($nodes->getRowsHash() as $node => $text) {
            $this->theJsonNodeShouldMatch($node, $text);
        }
    }

    /**
     * Checks, that given JSON node matches given value.
     *
     * @Then the JSON node :node should match string :text
     */
    public function theJsonNodeShouldMatchText(string $node, string $text): void
    {
        $actual = $this->inspector->evaluate($this->getJson(), $node);

        $this->match($text, \is_bool($actual) ? json_encode($actual) : (string) $actual);
    }

    /**
     * Checks, that given JSON node matches given value.
     *
     * @Then the JSON node :node should match integer :text
     */
    public function theJsonNodeShouldMatchInteger(string $node, int $text): void
    {
        $actual = $this->inspector->evaluate($this->getJson(), $node);

        $this->assertEquals($text, \is_bool($actual) ? json_encode($actual) : (int) $actual);
    }

    /**
     * @Then /^the JSON should be a superset of:$/
     */
    public function theJsonIsASupersetOf(PyStringNode $content): void
    {
        $actual = json_decode($this->httpCallResultPool->getResult()->getValue(), true);
        PHPUnitHelper::assertArraySubset(json_decode($content->getRaw(), true), $actual);
    }

    protected function match(string | null $expected, string | null $actual): void
    {
        $this->assertMatchesPattern($expected, $actual);
    }
}

PHPUnitHelper

namespace App\Tests\Common\Support\Helper;

use PHPUnit\Framework\Assert;

class PHPUnitHelper
{
    public static function assertArraySubset(array $expected, array $actual): void
    {
        foreach ($expected as $key => $value) {
            Assert::assertArrayHasKey($key, $actual);

            if (\is_array($value)) {
                self::assertArraySubset($value, $actual[$key]);

                return;
            }

            Assert::assertSame($value, $actual[$key]);
        }
    }
}

3. Creando los scenarios

Auth.feature

Feature: relativa a la autenticación OAUTH2 de los usuarios del aplicativo

  Background:
    Given we have some context

  Scenario: Call a not found route
    When I add "Content-Type" header equal to "application/json"
    And I send a "GET" request to "/api/not-found-route"
    Then the response status code should be 404

  Scenario: Client credentials authentication
    Given I add "Accept" header equal to "application/json"
    When I send a token request as a "ROLE_SUPER_ADMIN"
    Then the response status code should be 200
    And the response should be in JSON
    And the JSON should be equal to:
        """
        {
          "token_type":"bearer",
          "expires_in":@integer@,
          "access_token":"@string@",
          "scope":null,
          "refresh_token": "@string@"
        }
        """

User.feature

Feature: Todo lo relacionado con usuarios

  Background:
    Given we have some context

  Scenario: Get all users
    When I add "Content-Type" header equal to "application/json"
    And I send an authenticated "GET" request to "/api/user" as a "ROLE_SUPER_ADMIN"
    Then the response status code should be 200

  Scenario: Get all users filtered
    When I add "Content-Type" header equal to "application/json"
    And I send an authenticated "GET" request to "/api/user?itemsPerPage=1" as a "ROLE_SUPER_ADMIN"
    Then the response status code should be 200
    And the JSON node "totalItems" should match integer "5"


  Scenario: We can create users if we have the correct role
    When I add "Content-Type" header equal to "application/json"
    And I send an authenticated "POST" request to "/api/user" as a "ROLE_SUPER_ADMIN" with body:
      """
      {
          "email": "newuser@localhost.com",
          "firstName": "Foo",
          "lastName": "Bar",
          "password": "Thefoobar1",
          "passwordConfirmation": "Thefoobar1",
          "roles": [
              "ROLE_LEAD"
          ],
          "capabilitiesIds": [],
          "active": "true"
      }
      """
    Then the response status code should be 200
    And the JSON node "email" should match string "newuser@localhost.com"
    And the JSON node "id" should match "/[a-f0-9]{8}\-[a-f0-9]{4}\-4[a-f0-9]{3}\-(8|9|a|b)[a-f0-9]{3}\-[a-f0-9]{12}/"

El árbol de directorios debería quedar más o menos así:

...
features
├── Auth.feature
├── User.feature
│...
tests
├── Behat
│   ├── AuthenticationContext.php
│   └── JsonContext.php
├── bootstrap.php
├── common
│   └── Support
│       ├── Helper
│       │   └── PHPUnitHelper.php # Helper para JsonContext
behat.yml.dist
│...