Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

## v1.1.0

### Added

- Add trait `Spawnia\Sailor\Testing\RequiresSailorMocks`
- Add method `Spawnia\Sailor\Operation::requireMocks()`

## v1.0.0

### Added
Expand Down
45 changes: 29 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,22 @@ using the server schema to generate typesafe operations and results.

Install Sailor through composer by running:

composer require spawnia/sailor
```shell
composer require spawnia/sailor
```

If you want to use the built-in default Client (see [Client implementations](#client-implementations)):

composer require guzzlehttp/guzzle
```shell
composer require guzzlehttp/guzzle
```

If you want to use the PSR-18 Client and don't have
PSR-17 Request and Stream factory implementations (see [Client implementations](#client-implementations)):

composer require nyholm/psr7
```shell
composer require nyholm/psr7
```

## Configuration

Expand Down Expand Up @@ -265,9 +271,9 @@ Consider the following input:

```graphql
input SomeInput {
requiredID: Int!,
firstOptional: Int,
secondOptional: Int,
requiredID: Int!
firstOptional: Int
secondOptional: Int
}
```

Expand Down Expand Up @@ -400,23 +406,26 @@ Sailor provides first class support for testing by allowing you to mock operatio

It is assumed you are using [PHPUnit](https://phpunit.de) and [Mockery](https://docs.mockery.io/en/latest).

composer require --dev phpunit/phpunit mockery/mockery
```shell
composer require --dev phpunit/phpunit mockery/mockery
```

Make sure your test class - or one of its parents - uses the following traits:

```php
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use PHPUnit\Framework\TestCase as PHPUnitTestCase;
use Spawnia\Sailor\Testing\UsesSailorMocks;
use Spawnia\Sailor\Testing\RequiresSailorMocks;

abstract class TestCase extends PHPUnitTestCase
{
use MockeryPHPUnitIntegration;
use UsesSailorMocks;
use MockeryPHPUnitIntegration; // Makes Mockery assertions work
use RequiresSailorMocks; // Prevents stray requests and resets mocks between tests
}
```

Otherwise, mocks are not reset between test methods, you might run into very confusing bugs.
If you want to perform some kind of integration test where mocks are not required,
you may replace `RequiresSailorMocks` with `UsesSailorMocks`.

### Mock results

Expand All @@ -433,17 +442,21 @@ When registered, the mock captures all calls to `HelloSailor::execute()`.
Use it to build up expectations for what calls it should receive and mock returned results:

```php
$hello = 'Hello, Sailor!';
$name = 'Sailor';
$hello = "Hello, {$name}!";

$mock
->expects('execute')
->once()
->with('Sailor')
->with($name)
->andReturn(HelloSailor\HelloSailorResult::fromData(
HelloSailor\HelloSailor::make($hello),
data: HelloSailor\HelloSailor::make(
hello: $hello,
),
));

$result = HelloSailor::execute('Sailor')->errorFree();
$result = HelloSailor::execute(name: $name)
->errorFree();

self::assertSame($hello, $result->data->hello);
```
Expand All @@ -470,7 +483,7 @@ For PHP 8 users, it is recommended to use named arguments to build complex mocke

```php
HelloSailor\HelloSailorResult::fromData(
HelloSailor\HelloSailor::make(
data: HelloSailor\HelloSailor::make(
hello: 'Hello, Sailor!',
nested: HelloSailor\HelloSailor\Nested::make(
hello: 'Hello again!',
Expand Down
2 changes: 1 addition & 1 deletion phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ parameters:
# Due to different versions of bensampo/laravel-enum
- '#extends generic class BenSampo\\Enum\\Enum but does not specify its types: TValue#'
# Due to different versions of PHPUnit, attributes are backwards-compatible though
- '#Attribute class PHPUnit\\Framework\\Attributes\\After does not exist\.#'
- '#Attribute class PHPUnit\\Framework\\Attributes\\(Before|After) does not exist\.#'
# Alternative unclear
- '#Call to deprecated method getNamespace\(\) of class Nette\\PhpGenerator\\Class.+#'
# TODO remove when we require nette/php-generator:^4
Expand Down
2 changes: 1 addition & 1 deletion src/Client/Log.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public function requests(): iterable
{
$file = \Safe\fopen($this->filename, 'r');

while ($line = fgets($file)) {
while ($line = fgets($file)) { // @phpstan-ignore while.condNotBoolean
yield \Safe\json_decode($line, true); // @phpstan-ignore generator.valueType (we know the data in the log matches the defined array shape)
}

Expand Down
26 changes: 20 additions & 6 deletions src/Operation.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ abstract class Operation implements BelongsToEndpoint
*/
protected static array $mocks = [];

/** If true, no operations can execute without being mocked. */
protected static bool $requireMocks = false;

/**
* A client to use over the client from the endpoint config.
*
Expand All @@ -46,20 +49,26 @@ abstract protected static function converters(): array;
*/
protected static function executeOperation(...$args): Result
{
$mock = self::$mocks[static::class] ?? null;
$childClass = static::class;

$mock = self::$mocks[$childClass] ?? null;
if ($mock !== null) {
// @phpstan-ignore staticMethod.notFound,return.type (only present on child classes)
return $mock::execute(...$args);
}

if (self::$requireMocks) {
$endpoint = static::endpoint();
throw new \Exception("Tried to execute a Sailor operation on endpoint {$endpoint}, but no mock for was registered for {$childClass}.");
}

$response = static::fetchResponse($args);

$child = static::class;
$parts = explode('\\', $child);
$basename = end($parts);
$childClassParts = explode('\\', $childClass);
$childClassBasename = end($childClassParts);

/** @var class-string<TResult> $resultClass */
$resultClass = $child . '\\' . $basename . 'Result';
$resultClass = "{$childClass}\\{$childClassBasename}Result";
assert(class_exists($resultClass));

return $resultClass::fromResponse($response);
Expand Down Expand Up @@ -107,7 +116,7 @@ protected static function variables(array $args): \stdClass
/** @return static&MockInterface */
public static function mock(): MockInterface
{
// @phpstan-ignore-next-line I solemnly swear the type of MockInterface matches
// @phpstan-ignore return.type,assign.propertyType (I solemnly swear the type of MockInterface matches)
return self::$mocks[static::class] ??= \Mockery::mock(static::class);
}

Expand All @@ -116,6 +125,11 @@ public static function clearMocks(): void
self::$mocks = [];
}

public static function requireMocks(bool $value): void
{
self::$requireMocks = $value;
}

public static function setClient(?Client $client): void
{
self::$clients[static::class] = $client;
Expand Down
25 changes: 25 additions & 0 deletions src/Testing/RequiresSailorMocks.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php declare(strict_types=1);

namespace Spawnia\Sailor\Testing;

use PHPUnit\Framework\Attributes\After;
use PHPUnit\Framework\Attributes\Before;
use Spawnia\Sailor\Operation;

trait RequiresSailorMocks
{
/** @before */
#[Before]
protected function setUpRequiresSailorMocks(): void
{
Operation::requireMocks(true);
}

/** @after */
#[After]
protected function tearDownRequiresSailorMocks(): void
{
Operation::requireMocks(false);
Operation::clearMocks();
}
}
2 changes: 1 addition & 1 deletion src/Testing/UsesSailorMocks.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ trait UsesSailorMocks
{
/** @after */
#[After]
protected function tearDownSailorMocks(): void
protected function tearDownUsesSailorMocks(): void
{
Operation::clearMocks();
}
Expand Down
18 changes: 10 additions & 8 deletions tests/Integration/CustomTypesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ public function testDefaultDateAcceptsMixedScalarValues($value): void
)
));

$result = MyDefaultDateQuery::execute($value)->errorFree();
$result = MyDefaultDateQuery::execute($value)
->errorFree();
self::assertSame($value, $result->data->withDefaultDate);
}

Expand Down Expand Up @@ -67,8 +68,8 @@ public function testCarbonDate(): void
)
));

$result = MyCarbonDateQuery::execute($value)->errorFree();

$result = MyCarbonDateQuery::execute($value)
->errorFree();
$carbonDate = $result->data->withCarbonDate;
self::assertSame($value->toDateString(), $carbonDate->toDateString());
}
Expand All @@ -88,7 +89,8 @@ public function testDefaultEnum(): void
)
));

$result = MyDefaultEnumQuery::execute($value)->errorFree();
$result = MyDefaultEnumQuery::execute($value)
->errorFree();
self::assertSame($value, $result->data->withDefaultEnum);
}

Expand All @@ -107,8 +109,8 @@ public function testCustomEnum(): void
)
));

$result = MyCustomEnumQuery::execute($value)->errorFree();

$result = MyCustomEnumQuery::execute($value)
->errorFree();
$customEnum = $result->data->withCustomEnum;
self::assertInstanceOf(CustomEnum::class, $customEnum);
self::assertSame($value->value, $customEnum->value);
Expand Down Expand Up @@ -157,8 +159,8 @@ public function testBenSampoEnum(): void
),
));

$result = MyBenSampoEnumQuery::execute($value)->errorFree();

$result = MyBenSampoEnumQuery::execute($value)
->errorFree();
$benSampoEnum = $result->data->withBenSampoEnum;
self::assertInstanceOf(BenSampoEnum::class, $benSampoEnum);
self::assertSame($value->value, $benSampoEnum->value);
Expand Down
6 changes: 4 additions & 2 deletions tests/Integration/InputTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ public function testSomeInput(): void
)
));

$result = TakeSomeInput::execute($someInput)->errorFree();
$result = TakeSomeInput::execute($someInput)
->errorFree();
self::assertSame($answer, $result->data->takeSomeInput);
}

Expand Down Expand Up @@ -67,7 +68,8 @@ public function testList(): void

Configuration::setEndpointFor(TakeList::class, $endpoint);

self::assertNull(TakeList::execute($values)->data);
$result = TakeList::execute($values);
self::assertNull($result->data);
}

public function testMake(): void
Expand Down
1 change: 0 additions & 1 deletion tests/Integration/PolymorphicTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ public function testUserOrPost(): void

$result = UserOrPost::execute($id)->errorFree();
$user = $result->data->node;

self::assertInstanceOf(UserOrPost\Node\User::class, $user);
self::assertSame($id, $user->id);
self::assertSame($name, $user->name);
Expand Down
3 changes: 2 additions & 1 deletion tests/Integration/SimpleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ public function testRequestWithVariable(): void

Configuration::setEndpointFor(MyScalarQuery::class, $endpoint);

self::assertNull(MyScalarQuery::execute($value)->data);
$result = MyScalarQuery::execute($value);
self::assertNull($result->data);
}

public function testRequestWithClient(): void
Expand Down
18 changes: 18 additions & 0 deletions tests/Unit/Testing/RequiresSailorMocksTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php declare(strict_types=1);

namespace Spawnia\Sailor\Tests\Unit\Testing;

use Spawnia\Sailor\Simple\Operations\MyScalarQuery;
use Spawnia\Sailor\Testing\RequiresSailorMocks;
use Spawnia\Sailor\Tests\TestCase;

final class RequiresSailorMocksTest extends TestCase
{
use RequiresSailorMocks;

public function testThrowsOnMissingCalls(): void
{
$this->expectExceptionObject(new \Exception('Tried to execute a Sailor operation on endpoint simple, but no mock for was registered for Spawnia\\Sailor\\Simple\\Operations\\MyScalarQuery.'));
MyScalarQuery::execute();
}
}