jasonmccreary / laravel-test-assertions

A set of helpful assertions when testing Laravel applications.
321 stars 34 forks source link

Testing FormRequest #21

Closed gofish543 closed 4 years ago

gofish543 commented 4 years ago

Let me know your thoughts on this concept. I like the form creation, withUser, and expects gates.

I want to be able to do something like validateOn(...) and pass the data in, and list the items I expect to fail / pass, but I'm not sold yet on my implementation. Thoughts?

Usage

$this->formRequest(StoreRequest::class, route('api.employees.store'), 'POST')
    ->withUser($this->getAuthUser())
    ->expectsGate('create', null, null, [User::class, UserPolicy::EMPLOYEE])
    ->validateOn([], [], [], [], ['username']) // Fail on username
    ->validateOn([], [
        'username' => $employee->username
    ], [], [], ['username']) // Fails on username already existing
    ->execute();

code

<?php

namespace App\Testing;

use Exception;
use Illuminate\Auth\AuthManager;
use Illuminate\Contracts\Auth\Access\Gate;
use Illuminate\Contracts\Container\Container;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Routing\Redirector;
use Illuminate\Routing\Router;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Validator;
use Mockery;
use PHPUnit\Framework\TestCase as PHPUnitTestCase;

class PendingFormRequest {

    /**
     * Whether the gate callback needs to be applied
     *
     * @var bool
     */
    public static $appliedGateCallback = false;

    /**
     * The test being run
     *
     * @var \Illuminate\Foundation\Testing\TestCase
     */
    public $test;

    /**
     * The application instance
     *
     * @var \Illuminate\Contracts\Container\Container
     */
    protected $app;

    /**
     * The gate instance
     *
     * @var \Illuminate\Contracts\Auth\Access\Gate
     */
    protected $gate;

    /**
     * The underlying form request to be tested
     *
     * @var \Illuminate\Foundation\Http\FormRequest
     */
    protected $formRequest;

    /**
     * The list of gates executed
     *
     * @var array
     */
    protected $gatesExecuted = [];

    /**
     * List of expected gates executed
     *
     * @var array
     */
    protected $expectedGates = [];

    /**
     * List of expected validation lists
     *
     * @var array
     */
    protected $validateOn = [];

    /**
     * Determine if command has executed.
     *
     * @var bool
     */
    protected $hasExecuted = false;

    /**
     * Create a new pending console command run
     *
     * @param \PHPUnit\Framework\TestCase $test
     * @param \Illuminate\Contracts\Container\Container $app
     * @param \Illuminate\Foundation\Http\FormRequest|string $formRequest
     * @param string $route
     * @param string $method
     */
    public function __construct(PHPUnitTestCase $test, Container $app, $formRequest, $route, $method) {
        $this->app = $app;
        $this->test = $test;

        // Set up gate checking
        $this->prepGates($app->get(Gate::class));
        $this->prepFormRequest($formRequest, $route, $method);
    }

    /**
     * Destroy the pending console command
     */
    public function __destruct() {
        $this::$appliedGateCallback = false;
    }

    /**
     * Prepare the pending form request's gate
     *
     * @param \Illuminate\Contracts\Auth\Access\Gate $gate
     * @return self
     */
    private function prepGates(Gate $gate) {
        $this->expectedGates = [];
        $this->gatesExecuted = [];

        $this->gate = $gate;

        if (!$this::$appliedGateCallback) {
            $this->gate->after(function ($user, $ability, $result, $arguments = []) {
                $this->gatesExecuted[] = [
                    'user' => $user,
                    'ability' => $ability,
                    'result' => $result,
                    'arguments' => $arguments,
                ];
            });
            $this::$appliedGateCallback = true;
        }

        return $this;
    }

    /**
     * Prepare the pending form request's form request
     *
     * @param \Illuminate\Foundation\Http\FormRequest|string $formRequest
     * @param string $route
     * @param string $method
     * @return self
     */
    private function prepFormRequest($formRequest, $route, $method) {
        // Build the form request
        $this->formRequest = tap($formRequest::create($route, $method)
            ->setContainer($this->app)
            ->setRedirector(tap(Mockery::mock(Redirector::class), function ($redirector) {
                $fakeUrlGenerator = Mockery::mock();
                $fakeUrlGenerator->shouldReceive('to', 'route', 'action', 'previous')->withAnyArgs()->andReturn(null);

                $redirector->shouldReceive('getUrlGenerator')->andReturn($fakeUrlGenerator);
            }))
            ->setUserResolver(function () {
                return $this->app->get(AuthManager::class)->user();
            })->setRouteResolver(function () {
                $router = $this->app->get(Router::class);
                $routes = Route::getRoutes();
                $route = null;
                try {
                    $route = $routes->match($this->formRequest);

                    // Resolve bindings
                    $router->substituteBindings($route);
                    $router->substituteImplicitBindings($route);
                } catch (Exception $e) {
                } finally {
                    return $route;
                }
            }), function (FormRequest $formRequest) {
            $formRequest->files->replace([]);
            $formRequest->query->replace([]);
            $formRequest->request->replace([]);
        });

        $this->app->bind('request', $this->formRequest);

        return $this;
    }

    /**
     * Set the user for the form request
     *
     * @param \Illuminate\Database\Eloquent\Model $user
     * @return self
     */
    public function withUser($user) {
        $this->formRequest->setUserResolver(function () use ($user) {
            return $user;
        });

        return $this;
    }

    /**
     * Execute the command
     *
     * @return int
     */
    public function execute() {
        return $this->run();
    }

    /**
     * Execute the command
     *
     * @return int
     */
    public function run() {
        $this->hasExecuted = true;

        // Trigger authorize and check if all gates were executed
        if (count($this->expectedGates) > 0) {
            $this->formRequest->authorize();

            $this->verifyExpectedGates();
        }

        foreach ($this->validateOn as $key => $run) {
            // "Reset" all of the form requests data to this run's data
            $this->formRequest->files->replace($run['files'] ?? []);
            $this->formRequest->query->replace($run['get'] ?? []);
            $this->formRequest->request->replace($run['post'] ?? []);

            // Grab merged files and input data
            $data = array_merge_recursive(
                $this->formRequest->all(),
                $this->formRequest->allFiles()
            );
            $rules = $this->formRequest->rules();
            $messages = $this->formRequest->messages();

            $validator = Validator::make($data, $rules, $messages);
            $validator->passes();

            $errors = $validator->errors()->toArray();

            // Validate passed fields
            foreach ($run['passesOn'] as $key) {
                $this->test->assertArrayNotHasKey($key, $errors, "Failed to assert the field {$key} passed validation");
            }

            // Validate failed fields
            foreach ($run['failsOn'] as $key) {
                $this->test->assertArrayHasKey($key, $errors, "Failed to assert the field {$key} failed validation");
            }
        }

        return 0;
    }

    /**
     * Determine if expected gates were called
     *
     * @return void
     */
    protected function verifyExpectedGates() {
        $actualGates = collect($this->gatesExecuted);
        foreach ($this->expectedGates as $expectedGate) {
            $matched = $actualGates->filter(function ($actualGate) use ($expectedGate) {
                $result = true;

                if ($expectedGate['ability']) {
                    $result &= ($expectedGate['ability'] == $actualGate['ability']);
                }

                if ($expectedGate['user']) {
                    $result &= ($expectedGate['user'] == $actualGate['user']);
                }

                if ($expectedGate['result']) {
                    $result &= ($expectedGate['result'] == $actualGate['result']);
                }

                if ($expectedGate['arguments']) {
                    $result &= ($expectedGate['arguments'] == $actualGate['arguments']);
                }

                return $result;
            });

            $this->test->assertGreaterThan(0, $matched->count(), "Failed to assert that the gate {$expectedGate['ability']} resulted in {$expectedGate['result']}");
        }
    }

    /**
     * Add an expected gate to the pending list of gates
     *
     * @param string $ability
     * @param boolean|null $result
     * @param \Illuminate\Database\Eloquent\Model|null $user
     * @param array $arguments
     * @return $this
     */
    public function expectsGate($ability, $result = null, $user = null, $arguments = []) {
        $this->expectedGates[] = [
            'ability' => $ability,
            'user' => $user,
            'result' => $result,
            'arguments' => $arguments,
        ];

        return $this;
    }

    /**
     * Add an expected validation item to the list
     *
     * @param array $GET
     * @param array $POST
     * @param array $files
     * @param array $validFields
     * @param array $invalidFields
     * @return $this
     */
    public function validateOn($GET = [], $POST = [], $files = [], $validFields = [], $invalidFields = []) {
        $this->validateOn[] = [
            'get' => $GET,
            'post' => $POST,
            'files' => $files,
            'passesOn' => $validFields,
            'failsOn' => $invalidFields,
        ];

        return $this;
    }
}
gofish543 commented 4 years ago

Additionally, I need a better way to apply the afterGates callback as the way I do it now feels like a janky solution rather than a good one. If I don't do what is currently there though, each call to new PendingFormRequest() will result in another afterGates callback being applied.

JeffreyDavidson commented 4 years ago

@gofish543 Did you come up with a solution. I'm in a situation where on an UpdateRequest I need access to a route for updating a model and inside the UpdateRequest I do the following Rule::unique('table')->ignore($this->route('model')->id)] so I need to create a value for this param so that when I unit test the request class it will see it.