How to implement PHP UnitTest using getMockBuilder() for a custom service?

1.9k views Asked by At

I'm trying to write a PHP UnitTest for my AddHandler::class in Mezzio (Zend Expressive) but I'm not sure I've done it right or wrong. Although the Test passes but I'm not really convinced that's the way to do it. The requirement is to basically mock the output of service (new CrmApiService())->getUsers() and (new CustomHydrator())->getHydrated($this->usersJson) which can be saved in a text file for that matter. I've another one ViewHandler::class which also uses a service for data for listing, which I'm sure I can implement if I get a clue for this one.

My AddHandler Class

namespace Note\Handler;

use App\Service\CrmApiService;
use App\Service\CustomHydrator;
use Laminas\Diactoros\Response\RedirectResponse;
use Mezzio\Flash\FlashMessageMiddleware;
use Mezzio\Flash\FlashMessagesInterface;
use Note\Form\NoteForm;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Laminas\Diactoros\Response\HtmlResponse;
use Mezzio\Template\TemplateRendererInterface;

class AddHandler implements MiddlewareInterface
{
    /** @var NoteForm $noteForm */
    private $noteForm;
    /** @var TemplateRendererInterface $renderer */
    private $renderer;
    /** @var string $usersJson */
    private $usersJson;

    /**
     * AddHandler constructor.
     * @param NoteForm $noteForm
     * @param TemplateRendererInterface $renderer
     */
    public function __construct(NoteForm $noteForm, TemplateRendererInterface $renderer)
    {
        $this->noteForm = $noteForm;
        $this->renderer = $renderer;
    }

    /**
     * @param ServerRequestInterface $request
     * @param RequestHandlerInterface $handler
     * @return ResponseInterface
     */
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $this->usersJson = (new CrmApiService())->getUsers();
        $hydratedUsers = (new CustomHydrator())->getHydrated($this->usersJson);

        $userArray = [];
        foreach ($hydratedUsers as $user) {
            $userArray[] = $user;
        }
        $userSelectValueOptions = [];
        foreach ($userArray as $key => $val) {
            $userSelectValueOptions[$val["personReference"]] = $val["givenName"] . " " . $val["additionalName"] . " " . $val["familyName"];
        }

        if ($request->getMethod() === "POST") {
            $this->noteForm->setData(
                $request->withoutAttribute("saveNote")->withoutAttribute("referrerId")->getParsedBody()
            );

            // NB: assignedUserID received by form submission is assigned a dummy User Name and is then
            // appended at the end of formSelect("assignedUserID") for noteForm validation in below code block
            $userSelectValueOptions[$this->noteForm->get("assignedUserID")->getValue()] = "Testing User";
            $userSelect = $this->noteForm->get("assignedUserID");
            $userSelect->setValueOptions($userSelectValueOptions);
            //todo: remove the above code block before production

            $referrerId = $request->getAttribute("referrerId");
            $parent = $request->getAttribute("parent");
            $parentID = $request->getAttribute("parentID");

            if ($this->noteForm->isValid()) {
                (new CrmApiService())->createNote($this->noteForm->getData());
                $successMessage = "Note successfully added.";

                $response = $handler->handle($request);

                /** @var FlashMessagesInterface $flashMessages */
                $flashMessages = $request->getAttribute(FlashMessageMiddleware::FLASH_ATTRIBUTE);

                if ($response->getStatusCode() !== 302) {
                    $flashMessages->flash("success", $successMessage);
                    return new RedirectResponse(
                        (substr(
                            $referrerId,
                            0,
                            3
                        ) == "brk" ? "/broker/" : "/enquiry/") . $referrerId . "/" . $parent . "/" . $parentID
                    );
                }
                return $response;
            }
        }

        $referrerId = $request->getAttribute("referrerId");
        $parentID = $request->getAttribute("parentID");
        $parent = $request->getAttribute("parent");

        $userSelect = $this->noteForm->get("assignedUserID");
        $userSelect->setValueOptions($userSelectValueOptions);

        $noteParent = $this->noteForm->get("parent");
        $noteParent->setValue($parent);
        $noteParentID = $this->noteForm->get("parentID");
        $noteParentID->setValue($parentID);

        return new HtmlResponse(
            $this->renderer->render(
                "note::edit",
                [
                    "form" => $this->noteForm,
                    "parent" => $parent,
                    "parentID" => $parentID,
                    "referrerId" => $referrerId
                ]
            )
        );
    }
}

PHP UnitTest

declare(strict_types=1);

namespace NoteTests\Handler;

use Note\Handler\AddHandler;
use Mezzio\Template\TemplateRendererInterface;
use Note\Form\NoteForm;
use Note\Handler\EditHandler;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;

class NoteAddEditHandlerTest extends TestCase
{
    use ProphecyTrait;

    /** @var NoteForm */
    private $noteForm;
    /** @var TemplateRendererInterface */
    private $renderer;

    public function testRendersAddFormProperly()
    {
        $this->renderer
            ->render("note::edit", Argument::type("array"))
            ->willReturn(true);

        $serverRequest = $this->createMock(ServerRequestInterface::class);
        $requestHandler = $this->createMock(RequestHandlerInterface::class);

        $mock = $this->getMockBuilder(AddHandler::class)
            ->onlyMethods(["process"])
            ->setConstructorArgs([$this->noteForm, $this->renderer->reveal()])
            ->getMock();

        $mock->expects($this->once())
            ->method("process")
            ->with($serverRequest, $requestHandler);

        $mock->process($serverRequest, $requestHandler);
    }

    /**
     *
     */
    protected function setUp(): void
    {
        $this->noteForm = new NoteForm();
        $this->renderer = $this->prophesize(TemplateRendererInterface::class);
    }

}

Edit (Desired Result)

The AddHandler->process() method renders a page and this is what I'd like to see that the UnitTest also test against a response but I'm not sure how to test that. I think there should be some return value at the end of this code block with will()

$mock->expects($this->once())
            ->method("process")
            ->with($serverRequest, $requestHandler);
2

There are 2 answers

0
GoharSahi On BEST ANSWER

Here's my solution. I've mocked ResponseInterface::class as $this->responseInterface and make the process method to return this.

public function testRendersEditFormProperly()
    {
        $this->renderer
            ->render("note::edit", Argument::type("array"))
            ->willReturn(true);

        $mock = $this->getMockBuilder(EditHandler::class)
            ->onlyMethods(["process"])
            ->setConstructorArgs([$this->noteForm, $this->renderer->reveal()])
            ->getMock();

        $mock->method("process")
            ->with($this->serverRequest, $this->requestHandler)
            ->willReturn($this->responseInterface);

        $response = $mock->process($this->serverRequest, $this->requestHandler);
        $this->assertSame($response, $this->responseInterface);
    }
8
hakre On

Although the Test passes but I'm not really convinced that's the way to do it.

If you've written that test and this is your judgement, I suggest you temporarily rewrite the test (e.g. in another test-method) where you test for your expectations in testing to verify they are addressed.

Otherwise it seems the test is not of your benefit as you don't understand what it tests for and therefore is superfluous code and waste (in the agile sense) and you can cleanly just remove it and not let it lurk there open to lying around.

Who needs a test that is unclear in what it tests? Especially in unit tests there should be only one reason why a test fails. Not possible with an unclear test.

Is it already cleanup time and then back to drawing board? Maybe. I'd suggest incremental improvement and some sandboxing personally first. Like adding a much reduced test-case-method for verifying your own expectation of the test-suite-framework and the (two?) mocking library/ies in use.

This will also help you get going with the framework in use and gain a deeper understanding - this normally immediately pays off.

I've another one ViewHandler::class which also uses a service for data for listing, which I'm sure I can implement if I get a clue for this one.

Your code your tests. Only you can say whether or not your tests full-fill your requirements.

And if you allow me a personal comment, I hate to do mocking in tests. Even for code mocking technically works it becomes cumbersome pretty soon and has the tendency that the tests are only testing the mocks that have been written for the test only so entirely needless work.

Instead I try to either have the code under test straight forward and if a certain abstraction requires a lot of set-up upfront, create a factory for it and then that factory can be used in tests, too, reducing the overhead to a minimum.

Then some specialization of the factory can be done for testing automatically inject the testing configuration (e.g. in form of mocks if it must be to set blank other systems the test should not reach into) and then just let it pass. But this is just exemplary.

In a system where you like to test system($request, $response)->assert(diverse on $response afterwards) where system is * of concrete classes you write (your implementation), you may want to have a tester for * so that your test-procedure remains clear on all the interfacing that system offers and * implements and you don't need to set-up internals of all of system for * only to test any *, e.g. a HandlerTester.

Also check if Mezzio itself is not offering a tester if there is a higher level abstraction implementation necessary for handlers. A good library normally ships with good testing utilities (and even in this case not, you can fork it anytime).

Testing should be before development, this is oh so true for libraries, so actually I would personally expect the stuff is already there in 0.0.1. But this can vary.

Enable code coverage also for your tests so you can more easily review if your tests do run the way it's intended and put also all collaborators under test and coverage. This can help to gain more understanding what a test does and maybe already clarifies if it is of use or not.