Well, we've had enough of the helpers; let's start with the tests. The difficult part here is how to play with mocks. When you create one, you can add some expectations and return values. The methods are:
expects
: This specifies the amount of times the mock's method is invoked. You can send$this->never()
,$this->once()
, or$this->any()
as an argument to specify 0, 1, or any invocations.method
: This is used to specify the method we are talking about. The argument that it expects is just the name of the method.with
: This is a method used to set the expectations of the arguments that the mock will receive when it is invoked. For example, if the mocked method is expected to getbasic
as the first argument and123
as the second, thewith
method will be invoked aswith("basic", 123)
. This method is optional, but if we set it, PHPUnit will throw an error in case the mocked method does not get the expected arguments, so it works as an assertion.will
: This is used to define what the mock will return. The two most common usages are$this->returnValue($value)
or$this->throwException($exception)
. This method is also optional, and if not invoked, the mock will always return null.
Let's add the first test to see how it would
work. Add the following code to the tests/Controllers/BookControllerTest.php
file:
<?php namespace Bookstore\Tests\Controllers; use Bookstore\Controllers\BookController; use Bookstore\Core\Request; use Bookstore\Exceptions\NotFoundException; use Bookstore\Models\BookModel; use Bookstore\Tests\ControllerTestCase; use Twig_Template; class BookControllerTest extends ControllerTestCase { private function getController( Request $request = null ): BookController { if ($request === null) { $request = $this->mock('Core\Request'); } return new BookController($this->di, $request); } public function testBookNotFound() { $bookModel = $this->mock(BookModel::class); $bookModel ->expects($this->once()) ->method('get') ->with(123) ->will( $this->throwException( new NotFoundException() ) ); $this->di->set('BookModel', $bookModel); $response = "Rendered template"; $template = $this->mock(Twig_Template::class); $template ->expects($this->once()) ->method('render') ->with(['errorMessage' => 'Book not found.']) ->will($this->returnValue($response)); $this->di->get('Twig_Environment') ->expects($this->once()) ->method('loadTemplate') ->with('error.twig') ->will($this->returnValue($template)); $result = $this->getController()->borrow(123); $this->assertSame( $result, $response, 'Response object is not the expected one.' ); } }
The first thing the test does is to create a mock of the BookModel
class. Then, it adds an expectation that
goes like this: the get
method will be
called once with one argument, 123
, and
it will throw NotFoundException
. This
makes sense as the test tries to emulate a scenario in which we
cannot find the book in the database.
The second part of the test consists of adding
the expectations of the template engine. This is a bit more complex
as there are two mocks involved. The loadTemplate
method of Twig_Environment
is expected to be called once with
the error.twig
argument as the template
name. This mock should return Twig_Template
, which is another mock. The
render
method of this second mock is
expected to be called once with the correct error message,
returning the response, which is a hardcoded string. After all the
dependencies are defined, we just need to invoke the borrow
method of the controller and expect a
response.
Remember that this test does not have only one
assertion, but four: the assertSame
method and the three mock expectations. If any of them are not
accomplished, the test will fail, so we can say that this method is
quite robust.
With our first test, we verified that the scenario in which the book is not
found works. There are two more scenarios that fail as well: when
there are not enough copies of the book to borrow and when there is
a database error when trying to save the borrowed book. However,
you can see now that all of them share a piece of code that mocks
the template. Let's extract this code to a protected
method that generates the mocks when it is
given the template name, the parameters are sent to the template,
and the expected response is received. Run the following:
protected function mockTemplate( string $templateName, array $params, $response ) { $template = $this->mock(Twig_Template::class); $template ->expects($this->once()) ->method('render') ->with($params) ->will($this->returnValue($response)); $this->di->get('Twig_Environment') ->expects($this->once()) ->method('loadTemplate') ->with($templateName) ->will($this->returnValue($template)); } public function testNotEnoughCopies() { $bookModel = $this->mock(BookModel::class); $bookModel ->expects($this->once()) ->method('get') ->with(123) ->will($this->returnValue(new Book())); $bookModel ->expects($this->never()) ->method('borrow'); $this->di->set('BookModel', $bookModel); $response = "Rendered template"; $this->mockTemplate( 'error.twig', ['errorMessage' => 'There are no copies left.'], $response ); $result = $this->getController()->borrow(123); $this->assertSame( $result, $response, 'Response object is not the expected one.' ); } public function testErrorSaving() { $controller = $this->getController(); $controller->setCustomerId(9); $book = new Book(); $book->addCopy(); $bookModel = $this->mock(BookModel::class); $bookModel ->expects($this->once()) ->method('get') ->with(123) ->will($this->returnValue($book)); $bookModel ->expects($this->once()) ->method('borrow') ->with(new Book(), 9) ->will($this->throwException(new DbException())); $this->di->set('BookModel', $bookModel); $response = "Rendered template"; $this->mockTemplate( 'error.twig', ['errorMessage' => 'Error borrowing book.'], $response ); $result = $controller->borrow(123); $this->assertSame( $result, $response, 'Response object is not the expected one.' ); }
The only novelty here is when we expect that
the borrow
method is never invoked. As
we do not expect it to be invoked, there is no reason to use the
with
nor will
method. If the code actually invokes this method, PHPUnit will mark
the test as failed.
We already tested and found that all the
scenarios that can fail have failed. Let's add a test now where a
user can successfully borrow a book, which means that we will
return valid books and customers from the database, the
save
method will be invoked correctly,
and the template will get all the correct parameters. The test looks as follows:
public function testBorrowingBook() { $controller = $this->getController(); $controller->setCustomerId(9); $book = new Book(); $book->addCopy(); $bookModel = $this->mock(BookModel::class); $bookModel ->expects($this->once()) ->method('get') ->with(123) ->will($this->returnValue($book)); $bookModel ->expects($this->once()) ->method('borrow') ->with(new Book(), 9); $bookModel ->expects($this->once()) ->method('getByUser') ->with(9) ->will($this->returnValue(['book1', 'book2'])); $this->di->set('BookModel', $bookModel); $response = "Rendered template"; $this->mockTemplate( 'books.twig', [ 'books' => ['book1', 'book2'], 'currentPage' => 1, 'lastPage' => true ], $response ); $result = $controller->borrow(123); $this->assertSame( $result, $response, 'Response object is not the expected one.' ); }
So this is it. You have written one of the most complex tests you will need to write during this book. What do you think of it? Well, as you do not have much experience with tests, you might be quite satisfied with the result, but let's try to analyze it a bit further.