BDD Guía práctica: Testar APIs securizadas con Oauth2 usando BDD (Behat)
Búsqueda de calidad en el código
- 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.
US Title: This is the user story title As a <user role> I want <goal> So that <benefit>
As a manager I want a sales report of daily sales So that I can monitor the correct evolution of the store sales
Acceptance criteria Scenario n: <title> Given <context> And <morecontext> When <event> Then <result> And <otherresult>
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}/"
- 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
oAnd
. - 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 lenguajegherkin
, son ficheros*.feature
. - Una clase
tests/Behat/FeatureContext.php
: es el contexto que crea por defecto para rellenarlo con nuestras operativasGiven
,When
yThen
que mejor se adapten a nuestro negocio y casos de uso. Al haber instalado la libreríabehatch/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 │...