API Functional Tests
API-functional tests call your REST, SOAP or GraphQL endpoint over HTTP against a running store and assert on the real response.
protected function _webApiCall($serviceInfo, $arguments = [], $webApiAdapterCode = null, $storeCode = null, $integration = null)An API-functional test is the only kind that proves an endpoint is actually reachable. It sends a real HTTP request to a running store, so routing, the webapi.xml declaration, authentication, ACL and serialisation are all exercised. An integration test that calls your service class directly skips every one of them.
How it runs
Tests live in Vendor/Module/Test/Api. Copy dev/tests/api-functional/phpunit_rest.xml.dist to phpunit_rest.xml and set TESTS_BASE_URL to a store you have already installed, plus TESTS_WEBSERVICE_USER and TESTS_WEBSERVICE_APIKEY for an admin account. TESTS_MAGENTO_INSTALLATION is disabled by default: nothing is installed for you, unlike the integration suite.
Making the call
Extend Magento\TestFramework\TestCase\WebapiAbstract and call _webApiCall() with a $serviceInfo array. The rest key gives a resource path and HTTP method; the optional soap key gives a service name and operation. Which one is used depends on TESTS_WEB_API_ADAPTER, so one test can cover both protocols. The return value is the decoded response body, already an array.
GraphQL has its own base class, GraphQlAbstract, and its own config file, phpunit_graphql.xml.dist.
Fixtures and the trap in them
Both #[DataFixture] and the equivalent @magentoApiDataFixture annotation work here. Unlike the integration suite, the API suite does not wrap the test in a database transaction by default, so anything a fixture does not revert itself stays in the store. After editing webapi.xml run bin/magento cache:flush, because the Web Services Configuration cache type holds the old route.
Example
<?php
// app/code/Vendor/Loyalty/Test/Api/PointsBalanceTest.php
declare(strict_types=1);
namespace Vendor\Loyalty\Test\Api;
use Magento\Customer\Test\Fixture\Customer as CustomerFixture;
use Magento\Framework\Webapi\Rest\Request;
use Magento\TestFramework\Fixture\DataFixture;
use Magento\TestFramework\Fixture\DataFixtureStorageManager;
use Magento\TestFramework\TestCase\WebapiAbstract;
class PointsBalanceTest extends WebapiAbstract
{
private const SERVICE_NAME = 'vendorLoyaltyPointsBalanceRepositoryV1';
private const SERVICE_VERSION = 'V1';
private const RESOURCE_PATH = '/V1/loyalty/points';
#[DataFixture(CustomerFixture::class, as: 'customer')]
public function testBalanceOfNewCustomerIsZero(): void
{
$customerId = (int)DataFixtureStorageManager::getStorage()
->get('customer')
->getId();
$serviceInfo = [
'rest' => [
'resourcePath' => self::RESOURCE_PATH . '/' . $customerId,
'httpMethod' => Request::HTTP_METHOD_GET,
],
'soap' => [
'service' => self::SERVICE_NAME,
'serviceVersion' => self::SERVICE_VERSION,
'operation' => self::SERVICE_NAME . 'Get',
],
];
$response = $this->_webApiCall($serviceInfo, ['customerId' => $customerId]);
self::assertSame(0, (int)$response['points']);
}
}
// cd dev/tests/api-functional
// ../../../vendor/bin/phpunit -c phpunit_rest.xml \
// ../../../app/code/Vendor/Loyalty/Test/Api
// OK (1 test, 1 assertion)When to use it
- Verifying a new REST route answers, and with the right status when the token lacks the ACL resource.
- Checking that a response field you added to a data interface is actually serialised.
- Covering a GraphQL resolver, including the error it returns for a guest.
- Catching a webapi.xml change that silently renamed an endpoint before customers' integrations do.
More examples
The declaration the test is really checking
The route URL, the method name and the ACL resource together decide whether your request gets a 200, a 401 or a 404, and none of them are visible to an integration test. After adding or editing this file run bin/magento cache:flush, then bin/magento setup:di:compile if the service interface is new, because the framework generates the request and response processors for it. Reach for an API-functional test immediately after writing one of these routes: the first run usually tells you the ACL resource is wrong.
<!-- app/code/Vendor/Loyalty/etc/webapi.xml -->
<routes xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Webapi:etc/webapi.xsd">
<route url="/V1/loyalty/points/:customerId" method="GET">
<service class="Vendor\Loyalty\Api\PointsBalanceRepositoryInterface" method="get"/>
<resources>
<resource ref="Vendor_Loyalty::points"/>
</resources>
</route>
</routes>The same idea for GraphQL
GraphQlAbstract extends WebapiAbstract and adds graphQlQuery(string $query, array $variables = [], string $operationName = '', array $headers = []) for queries and graphQlMutation() for mutations. The fourth argument is where a customer token header goes when you need an authenticated request. Run these with the GraphQL config: from dev/tests/api-functional, ../../../vendor/bin/phpunit -c phpunit_graphql.xml. The nowdoc (single-quoted heredoc) keeps GraphQL's own $variable syntax from being read as PHP.
<?php
// app/code/Vendor/Loyalty/Test/Api/GraphQl/PointsBalanceQueryTest.php
declare(strict_types=1);
namespace Vendor\Loyalty\Test\Api\GraphQl;
use Magento\Customer\Test\Fixture\Customer as CustomerFixture;
use Magento\TestFramework\Fixture\DataFixture;
use Magento\TestFramework\TestCase\GraphQlAbstract;
class PointsBalanceQueryTest extends GraphQlAbstract
{
#[DataFixture(CustomerFixture::class, as: 'customer')]
public function testGuestQueryReturnsNoPoints(): void
{
$query = <<<'GQL'
{
loyaltyPoints {
balance
}
}
GQL;
$response = $this->graphQlQuery($query);
self::assertSame(0, $response['loyaltyPoints']['balance']);
}
}Pointing the suite at your store
Copy phpunit_rest.xml.dist to phpunit_rest.xml so an upgrade does not overwrite your values. TESTS_BASE_URL must match the store's configured base URL, trailing slash included, or requests land on a redirect instead of the API. Switching TESTS_WEB_API_ADAPTER to soap runs the very same test classes through the SOAP operations named in each $serviceInfo, which is how core covers both protocols from one file.
<!-- dev/tests/api-functional/phpunit_rest.xml (extract) -->
<php>
<includePath>./testsuite</includePath>
<const name="TESTS_INSTALL_CONFIG_FILE" value="config/install-config-mysql.php"/>
<const name="TESTS_GLOBAL_CONFIG_FILE" value="config/config-global.php"/>
<const name="TESTS_WEB_API_ADAPTER" value="rest"/>
<const name="TESTS_BASE_URL" value="http://magento.test/"/>
<const name="TESTS_WEBSERVICE_USER" value="admin"/>
<const name="TESTS_WEBSERVICE_APIKEY" value="123123q"/>
<const name="TESTS_MAGENTO_INSTALLATION" value="disabled"/>
<const name="TESTS_CLEANUP" value="enabled"/>
</php>
Discussion