Advertisement
  1. Code
  2. PHP

Тестирование контроллеров в Laravel

Scroll to top
Read Time: 20 min

Russian (Pусский) translation by Sergey Zhuk (you can also view the original English article)

Тестирование контроллеров - не самая легкая штука в мире. Или если перефразировать, то тестировать их трудно до тех пор, пока не станет понятно, что нужно тестировать.

Нужно ли в тестах контроллеров проверять текст на странице? Стоит ли затрагивать базу данных? Стоит ли проверять наличие переменных в файлах отображений? Если это ваш первый раз, когда вы собираетесь тестировать контроллеры, то все эти вопросы могут легко возникнуть у вас. Разрешите вам помочь.

Тесты контроллеров должны проверять ответы, проверять что были сделаны корректные запросы к базе данных, а так же что определенные экземпляры переменных были переданы в файл отображения.

Процесс тестирования контроллеров может быть разделен на три части.

  • Изоляция: мокаем все зависимости (возможно и включая View).
  • Вызов: выполняем нужный метод контроллера.
  • Проверка: Выполнение утверждений, что состояние было установлено должным образом.

Hello World в тестировании контроллеров

Лучше всего разобраться в этих вещая, изучая примеры. Рассмотрим "hello world" в тестировании контроллеров Laravel.

1
2
<?php
3
4
# app/tests/controllers/PostsControllerTest.php

5
6
class PostsControllerTest extends TestCase {
7
8
  public function testIndex()
9
  {
10
      $this->client->request('GET', 'posts');
11
  }
12
13
}

Laravel использует несколько компонентов Symfony, чтобы облегчить процесс тестирования маршрутов и представлений, включая HttpKernel, DomCrawler и BrowserKit. Вот почему нужно, чтобы ваши PHPUnit тесты были унаследованы не от PHPUnit\_Framework\_TestCase, а от TestCase. Не беспокойтесь, Laravel попревшему их использует, но при этом расширяет, чтобы можно было настроить приложение Laravel для тестирования, а так же предоставить большое разнообразие методов-хелперов для проверки утверждений. Подробнее об этом в ближайшее время.

В примере кода выше мы делаем GET запрос к /posts, или localhost:8000/posts. Предполагая, что эта строчка кода была использована на свежей установке Laravel, Symfony генерирует исключение NotFoundHttpException. Попробуйте сами, выполнив phpunit из командной строки.

1
2
$ phpunit
3
1) PostsControllerTest::testIndex
4
Symfony\Component\HttpKernel\Exception\NotFoundHttpException:

На человеческом языке это означает: "Эй, я попробовал сделать запрос к этому маршруту, но у тебя, дурак, там ничего не зарегистрировано!".

Как вы можете себе представить, такой тип запроса будет очень распространенным, так что имеет смысл предоставить для него собственный метод, такой как $this-> call(). И в самом деле у Laravel есть такой! Это означает, что предыдущий пример можно переписать следующим образом:

1
2
#app/tests/controllers/PostsControllerTest.php

3
4
public function testIndex()
5
{
6
    $this->call('GET', 'posts');
7
}

Перегрузка — это ваш друг

Хотя мы и будем придерживаться базового функционала в этой главе, но в моих личных проектов я делаю шаг дальше, и определяю такие методы, как $this-> get(), $this-> post(), и др. Благодаря перегрузки PHP для этого требуется добавить один единственный метод в класс app/tests/TestCase.php.

1
2
# app/tests/TestCase.php

3
4
public function __call($method, $args)
5
{
6
    if (in_array($method, ['get', 'post', 'put', 'patch', 'delete']))
7
    {
8
        return $this->call($method, $args[0]);
9
    }
10
11
    throw new BadMethodCallException;
12
}

Теперь вы можете использовать $this->get('posts') и получить точно такой же результат, как и в предыдущих двух примерах. Как было замечено выше, продолжим работать дальше с базовым функционалом фреймворка.

Чтобы заставить эти тесты выполняться, нам всего лишь нужно подготовить нужный маршрут.

1
2
<?php
3
4
# app/routes.php

5
6
Route::get('posts', function()
7
{
8
    return 'all posts';
9
});

Повторный запуск phpunit в этот раз вернет нам зеленый цвет.


Хелперы подтверждений Laravel

Чаще всего вы будете писать тесты, которые будут проверять, что контролер передает определенные переменные в файл отображения. Например, метод index контроллера PostsController должен передать переменную $posts в соответствующее отображение, верно? Чтобы таким образом в отображении можно было перебрать все посты и отобразить их на странице. Это очень важный тест, который следовало бы написать!

Если это является довольно распространенной задачей, то почему бы Laravel не предоставить для этого соответствующую проверку утверждения? Конечно, это так. И конечно же Laravel предоставит.

Illuminate\Foundation\Testing\TestCase включает в себя набор методов, которые значительно уменьшат количество кода, который вам нужно будет написать для проверки таких базовых утверждений. Этот список включает следующие проверки:

  • assertViewHas
  • assertResponseOk
  • assertRedirectedTo
  • assertRedirectedToRoute
  • assertRedirectedToAction
  • assertSessionHas
  • assertSessionHasErrors

Следующие примеры вызывают GET /posts и проверяют, что файл отображения получает переменную $posts.

1
2
# app/tests/controllers/PostsControllerTest.php

3
4
public function testIndex()
5
{
6
    $this->call('GET', 'posts');
7
8
    $this->assertViewHas('posts');
9
}

Совет: когда заходит вопрос о форматировании, я предпочитаю оставлять пустую строку между проверкой утверждения теста и кодом, который подготавливает состояние.

assertViewHas является просто синтаксическим сахаром и анализирует объект ответа, который был возвращен из метода $this->call(), проверяя, что данные, связанные с отображением, содержат переменную posts.

При исследовании содержимого объекта ответа, есть два основных варианта.

  • $response->getOriginalContent(): возвращает оригинальный контент, или возвращенный из View. Так же можно воспользоваться свойством original напрямую, вместо вызова метода getOriginalContent.
  • $response->getContent(): возвращает отрисованный вывод. Если из маршрута возвращается экземпляр View, то getContent() вернет HTML. Это может быть полезно для проверок структуры DOM, таких как "в отображении должна содержаться строка".

Представим, что маршрут posts имеет следующее содержимое:

1
2
<?php
3
4
# app/routes.php

5
6
Route::get('posts', function()
7
{
8
    return View::make('posts.index');
9
});

Если мы выполним phpunit, то получим полезное сообщение о нашем следующем шаге.

1
2
1) PostsControllerTest::testIndex
3
Failed asserting that an array has the key 'posts'.

Чтобы тесты выполнились, нам просто нужно получить посты и передать их в отображение.

1
2
3
# app/routes.php

4
5
Route::get('posts', function()
6
{
7
    $posts = Post::all();
8
9
    return View::make('posts.index', ['posts', $posts]);
10
});

Важно заметить одну вещь, что этот код проверяет лишь только то, что переменная $posts была передана в отображение. Он не проверяет само ее значение. Метод assertViewHas принимает дополнительный второй аргумент, чтобы проверить как значение переменной, так и ее существование.

1
2
3
# app/tests/controllers/PostsControllerTest.php

4
5
public function testIndex()
6
{
7
    $this->call('GET', 'posts');
8
9
    $this->assertViewHas('posts', 'foo');
10
}

В этом модифицированном примере, несмотря на то что, переменная $posts была передана в отображение, она равна foo, соответственно тесты не выполнятся. В такой ситуации лучше не проверять на определенное значение, а вместо этого проверять что объект является экземпляром класса Illuminate\Database\Eloquent\Collection. Как нам это реализовать? У PHPUnit есть полезный метод assertInstanceOf, как раз подходящий под наши нужды.

1
2
3
# app/tests/controllers/PostsControllerTest.php

4
5
public function testIndex()
6
{
7
    $response = $this->call('GET', 'posts');
8
9
    $this->assertViewHas('posts');
10
11
    // getData() returns all vars attached to the response.

12
    $posts = $response->original->getData()['posts'];
13
14
    $this->assertInstanceOf('Illuminate\Database\Eloquent\Collection', $posts);
15
}

Теперь мы проверяем, что переменная $posts, переданная контроллером должна обязательно быть экземпляром класса Illuminate\Database\Eloquent\Collection. Отлично.


Мокаем базу данных.

Однако до сих пор есть одна вопиющая проблема с нашими тестами. Вы уже поняли какая?

Для каждого теста выполняется SQL запрос к базе данных. Хотя для некоторых видов тестирования (приемочное и интеграционное) это может быть и нужно, но для простого тестирования контроллеров, это только снизит производительность.

На данный момент я уже наверно просверлил вам череп. Мы не заинтересованы в тестировании возможности Eloquent доставать записи из базы. У него есть свои собственные тесты. Тэйлор знает свою работу! Давайте не будем тратить время и силы, повторяя те же тесты.

Вместо этого лучше замокать базу данных, и просто убедиться, что соответствующие методы вызываются с нужными аргументами. Или другими словами, нужно убедиться что метод Post::all() никогда не будет вызван и не затронет базу данных. Мы знаем, что он работает и он не нуждается в тестах.

Эта секция будет полностью зависеть от библиотеки Mockery. Пожалуйста, просмотрите эту главу из моей книги, если вы еще не знакомы с ней.

Необходимый рефакторинг

К сожалению мы написали свой код так, что его практически невозможно тестировать.

1
2
# app/routes.php

3
4
Route::get('posts', function()
5
{
6
    // Ouch. We can't test this!!

7
    $posts = Post::all();
8
9
    return View::make('posts.index')
10
        ->with('posts', $posts);
11
});

Именно поэтому считается плохой практикой делать прямые вызовы к методам Eloquent прямо из контроллеров. Не путайте фасады в Laravel, которые в целях тестирования могут быть заменены на моки (Queue::shouldReceive()), с моделями Eloquent. Решением является инъекция уровня абстракции базы данных в контроллер через конструктор. А это потребует небольшого рефакторинга.

Внимание: расположение логики внутри колбэков маршрутов может быть полезно для мелких проектов и API, но при этом создает большие проблемы при тестировании. Для приложений более-менее серьезных размеров использоуйте контроллеры.

Зарегистрируем новый ресурс, заменив маршрут posts на следующий:

1
2
# app/routes.php

3
4
Route::resource('posts', 'PostsController');

... создадим необходимый для работы ресурса контроллер с помощью Artisan.

1
2
$ php artisan controller:make PostsController
3
Controller created successfully!

Теперь вместо того, чтобы напрямую ссылаться на модель Post, встроем эту зависимость через конструктор контроллер. Вот сокращенный пример, в котором пропущены все restful методы за исключением одного, который мы в настоящее время рассматриваем в тестировании.

1
2
<?php
3
4
# app/controllers/PostsController.php

5
6
class PostsController extends BaseController {
7
8
  protected $post;
9
10
  public function __construct(Post $post)
11
  {
12
      $this->post = $post;
13
  }
14
15
  public function index()
16
  {
17
      $posts = $this->post->all();
18
19
      return View::make('posts.index')
20
          ->with('posts', $posts);
21
  }
22
23
}

Обратите внимание что лучше указать тип интерфейса, чем ссылаться на саму модель Eloquent. Но всему свое время! Давайте работать дальше.

Это гораздо лучший способ организации кода. Так как модель теперь встроена как зависимость, мы теперь можем подменить ее замоканной версией для тестирования. Вот пример того, как это можно сделать:

1
2
<?php
3
4
# app/tests/controllers/PostsControllerTest.php

5
6
class PostsControllerTest extends TestCase {
7
8
  public function __construct()
9
  {
10
      // We have no interest in testing Eloquent

11
      $this->mock = Mockery::mock('Eloquent', 'Post');
12
  }
13
14
  public function tearDown()
15
  {
16
      Mockery::close();
17
  }
18
19
  public function testIndex()
20
  {
21
      $this->mock
22
           ->shouldReceive('all')
23
           ->once()
24
           ->andReturn('foo');
25
26
      $this->app->instance('Post', $this->mock);
27
28
      $this->call('GET', 'posts');
29
30
      $this->assertViewHas('posts');
31
  }
32
33
}

Ключевое преимущество такого подхода в том, что к базе данных не будет сделано ни одного запроса. Вместо этого, используя Mockery, мы просто проверяем что метод all у модели был вызван.

1
2
$this->mock
3
    ->shouldReceive('all')
4
    ->once();

К сожалению если вы решили отказаться от кодирования на основе интерфейсов, и вместо этого встроете модель Post в контроллер, то из-за статики, могут быть проблемы при использовании Mockery. Именно поэтому мы заменяем как Post, так и Eloquent классы в конструкторе теста, до загрузки их оригинальных верси1. Таким образом у нас появляется возможность задать ожидания в тестах. Минусом такого подхода является то, что мы не можем обратится к любому из существующим методов с помощью Mockery, используя makePartial().

IoC контейнер

Контейнер зависимостей Laravel во много раз упрощает процесс встраивания зависимостей в ваши классы. Каждый раз, когда запрашивается контроллер, он достается из контейнера зависимостей. Таким образом когда нам нужно объявить, что мок-версию Post следует использовать для тестирования, мы всего лишь должны предоставить Laravel соответствующий экземпляр Post, который следует использовать.

1
2
$this->app->instance('Post', $this->mock);

Рассматривайте этот код как, "Эй Laravel, когда тебе нужен экземпляр Post, используй мою замоканную версию". Так как app наследуется от Container, то у нас есть доступ ко всем методам контейнера.

После создания экземпляра контроллера Laravel использует возможности отражения PHP для чтения подсказок типов и встраивает все зависимости для вас сам. Именно так! Не нужно делать для этого привязку в самом контейнере, он все сделает автоматически!


Редиректы

Еще одна проверка, которую вы постоянно будете использовать, это проверка что пользователь был перенаправлен в нужное место, возможно с добавлением нового поста в базу данных. Как мы можем это сделать?

1
2
# app/tests/controllers/PostsControllerTest.php

3
4
public function testStore()
5
{
6
    $this->mock
7
         ->shouldReceive('create')
8
         ->once();
9
10
    $this->app->instance('Post', $this->mock);
11
12
    $this->call('POST', 'posts');
13
14
    $this->assertRedirectedToRoute('posts.index');
15
16
}

Предполагая, что мы следуем restfull, то чтобы добавить новый пост, мы должны сделать POST запрос к коллекции или posts (не путать метод запроса POST с именем ресурса).

1
2
$this->call('POST', 'posts');

Затем нам нужно всего лишь воспользоваться другой проверкой утверждения Laravel - assertRedirectedToRoute.

Совет: когда в Laravel регистрируется ресурс (Route::resource()), то фреймворк автоматически регистрируется все необходимые маршруты. Можете выполнить php artisan routes, если вы вдруг забыли имена этих маршрутов.

Вы так же возможно захотите проверить, что супреглобальная переменная $_POST была передана методу create. Хотя в действительности мы физически и не отправляем форму, мы попрежнему можем реализовать это с помощью метода Input::replace(), который позволяет подделать этот массив. Вот пример измененного теста, который использует метод Mockery with(), чтобы проверить аргументы, переданные в метод с помощью shouldReceive.

1
2
# app/tests/controllers/PostsControllerTest.php

3
4
public function testStore()
5
{
6
    Input::replace($input = ['title' => 'My Title']);</p>
7
8
    $this->mock
9
         ->shouldReceive('create')
10
         ->once()
11
         ->with($input);
12
13
    $this->app->instance('Post', $this->mock);
14
15
    $this->call('POST', 'posts');
16
17
    $this->assertRedirectedToRoute('posts.index');
18
}

Пути

Еще одна вещь, которую мы не рассмотрели в тестах - это валиация. Есть два различных сценария в методе store, в зависимости от того, прошла валидация успешно или нет:

  1. Возвращаемся назад к форме "Create Post" и показываем ошибки валидации.
  2. Перенаправляем на коллекцию или на одноименный маршрут, posts.index.

Лучше всего, когда каждый тест затрагивает свой отдельный сценарий.

Первый будет для неуспешной валидации.

1
2
# app/tests/controllers/PostsControllerTest.php

3
4
public function testStoreFails()
5
{
6
    // Set stage for a failed validation

7
    Input::replace(['title' => '']);
8
9
    $this->app->instance('Post', $this->mock);
10
11
    $this->call('POST', 'posts');
12
13
    // Failed validation should reload the create form

14
    $this->assertRedirectedToRoute('posts.create');
15
16
    // The errors should be sent to the view

17
    $this->assertSessionHasErrors(['title']);
18
}

Код выше явно определяет какие ошибки должны существовать. Кроме того, можно опустить аргумент assertSessionHasErrors, в этом случае будет просто проверка, что пресутствовали сообщения (что редирект включает в себя withErrors($errors)).

Теперь тест, который проверяет успешную валидацию.

1
2
# app/tests/controllers/PostsControllerTest.php

3
4
public function testStoreSuccess()
5
{
6
    // Set stage for successful validation

7
    Input::replace(['title' => 'Foo Title']);</p>
8
9
    $this->mock
10
         ->shouldReceive('create')
11
         ->once();
12
13
    $this->app->instance('Post', $this->mock);
14
15
    $this->call('POST', 'posts');
16
17
    // Should redirect to collection, with a success flash message

18
    $this->assertRedirectedToRoute('posts.index', ['flash']);
19
}

Код для этих обоих тестов может выглядеть следующим образом:

1
2
# app/controllers/PostsController.php

3
4
public function store()
5
{
6
    $input = Input::all();
7
8
    // We'll run validation in the controller for convenience

9
    // You should export this to the model, or a service

10
    $v = Validator::make($input, ['title' => 'required']);
11
12
    if ($v->fails())
13
    {
14
        return Redirect::route('posts.create')
15
            ->withInput()
16
            ->withErrors($v->messages());
17
    }
18
19
    $this->post->create($input);
20
21
    return Redirect::route('posts.index')
22
        ->with('flash', 'Your post has been created!');
23
}

Обратите внимание, как Validator встраивается прямо в контроллер? В общем рекомендовал бы вынести это в абстракцию в виде сервиса. Таким образом, вы сможете тестировать валидацию изолированно от любых контроллеров или маршрутов. Тем не менее давайте оставим для простоты все как есть. Единственное на что стоит обратить внимание, что в действительности мы не мокаем Validator, хотя безусловно можно сделать и это. Так как этот класс является фасадом, то его легко можно подменить замоканной версией, через метод shouldReceive без необходимости инъекции через конструктор. Win!

1
2
# app/controllers/PostsController.php

3
4
Validator::shouldReceive('make')
5
    ->once()
6
    ->andReturn(Mockery::mock(['fails' => 'true']));

Время от время вы станете замечать, то метод, который нужно замокать, должен возвращать объект, самого себя. К счастью, с Mockery это легко сделать, нужно только лишь создать анонимную заглушку, и передать массив, который представляет собой имя метода и значение ответа соответственно. Например так:

1
2
Mockery::mock(['fails' => 'true'])

мы подготовляем объект, который содержит метод fails() и возвращает true.


Репозитории

Для оптимальной гибкости, вместо того, чтобы создать прямую связь между контроллер и ORM, как Eloquent, лучше работать с интерфейсами. Значительным преимуществом такого подхода является, что вам возможно необходимо будет сменить Eloquent на Mongo или Redis, что при таком подходе потребует изменения лишь одной строчки кода. И более того, контроллер даже не будет затронут.

Репозитории представляют уровень доступа к данным в вашем приложении.

Как может выглядеть интерфейс для работы с уровнем базы данных для модели Post? Начнем.

1
2
<?php
3
4
# app/repositories/PostRepositoryInterface.php

5
6
interface PostRepositoryInterface {
7
8
    public function all();
9
10
    public function find($id);
11
12
    public function create($input);
13
14
}

Его конечно же можно будет расширить, но мы добавим необходимый минимум методов: all, find и create. Обратите внимание что интерфейсы репозиториев располагаются в app/repositories. Так как эта папка по умолчанию не используется при автозагрузке, то нам необходимо обновить файл composer.json.

1
2
// composer.json

3
4
"autoload": {
5
  "classmap": [
6
    // ....

7
    "app/repositories"
8
  ]
9
}

Когда новый класс добавляется в директорию, не забывайте запускать composer dump-autoload -o. Флаг -o (optimize) является необязательным, но рекомендуется его постоянно использовать,

Если вы попытаетесь встроить этот интерфейс в ваш контроллер, то Laravel этого не позволит сделать. Продолжаем; попробуем и посмотрим, что получится. Вот измененный PostController, который был обновлен на использование инъекции интерфейса, вместо простой Eloquent модели Post.

1
2
<?php
3
4
# app/controllers/PostsController.php

5
6
use Repositories\PostRepositoryInterface as Post;
7
8
class PostsController extends BaseController {
9
10
    protected $post;
11
12
    public function __construct(Post $post)
13
    {
14
        $this->post = $post;
15
    }
16
17
    public function index()
18
    {
19
        $posts = $this->post->all();
20
21
        return View::make('posts.index', ['posts' => $posts]);
22
    }
23
24
}

Если запустите сервер и посмотрите вывод, то встретите страницу с ошибкой, которая сообщает что "PostRepositoryInterface is not instantiable."

Not InstantiableNot InstantiableNot Instantiable

Если задуматься, то понятно почему фреймворк ругается на это! Laravel умен, но не умеет читать мысли. Ему нужно указать какую именно реализацию интереса следует использовать внутри контроллера.

Сейчас давайте добавим эту привязку в app/routes.php. Затем воспользуемся сервис провайдерами для расположения подобного рода логики.

1
2
# app/routes.php

3
4
App::bind(
5
    'Repositories\PostRepositoryInterface',
6
    'Repositories\EloquentPostRepository'
7
);

Перефразируем этот вызов функции как "Laravel, детка, когда тебе потребуется экземпляр PostRepositoryInterface, я хочу, чтобы ты использовал EloquentPostRepository."

app/repositories/EloquentPostRepository просто будет оберткой над Eloquent, которая реализует PostRepositoryInterface. Таким образом, мы не ограничиваем API (или любую другую реализацию) на интерпретацию Eloquent; мы можем назвать методы как захотим.

1
2
<?php namespace Repositories;
3
4
# app/repositories/EloquentPostRepository.php

5
6
use Repositories\PostRepositoryInterface;
7
use Post;
8
9
class EloquentPostRepository implements PostRepositoryInterface {
10
11
  public function all()
12
  {
13
      return Post::all();
14
  }
15
16
  public function find($id)
17
  {
18
      return Post::find($id);
19
  }
20
21
  public function create($input)
22
  {
23
      return Post::create($input);
24
  }
25
26
}

Некоторые из вас могут возразить, что модель Post следует встроить в эту реализацию для целей тестирования. Если вы согласны, то просто встройте ее через конструктор как обычно.

Это все, что нужно сделать! Обновляем браузер, и все снова работает. Только теперь ваше приложение гораздо лучше структурировано, и контроллер более не привязан к Eloquent.

Представим что через несколько месяцев спустя, ваш босс приходит к вам и говорит, что вам нужно заменить Eloquent на Redis. Отлично, так как мы организовали наше приложение для подобного рода изменений, все что будет нужно - это создать новую реилизацию app/repositories/RedisPostRepository.

1
2
<?php namespace Repositories;
3
4
# app/repositories/RedisPostRepository.php

5
6
use Repositories\PostRepositoryInterface;
7
8
class RedisPostRepository implements PostRepositoryInterface {
9
10
  public function all()
11
  {
12
      // return all with Redis

13
  }
14
15
  public function find($id)
16
  {
17
      // return find one with Redis

18
  }
19
20
  public function create($input)
21
  {
22
      // return create with Redis

23
  }
24
25
}

И обновить привязку:

1
2
# app/routes.php

3
4
App::bind(
5
    'Repositories\PostRepositoryInterface',
6
    'Repositories\RedisPostRepository'
7
);

Теперь мгновенно в контролере будет использоваться Redis. Вы обратили внимание что app/controllers/PostsController.php даже не затрагивался нами. В этом то и вся прелесть!


Структура

Пока что в этом уроке, наша организация была не самой лучшей. Привязки контейнера в файле routes.php? Все репозитории сгруппированы вместе в одной директории? Конечно такой подход может работать вначале, но очень скоро окажется, что это никак не масштабируется.

В заключительной части статьи, мы изменим наш код в сторону PSR, и используем сервис провайдеров для регистрации наших привязок в контейнере.

PSR-0 определяет обязательные требования, которые должны соблюдаться для автозагрузчика.

Автозагрузчик PSR-0 может быть зарегистрирован через Composer, с помощью объекта psr-0.

1
2
// composer.json

3
4
"autoload": {
5
    "psr-0": {
6
        "Way": "app/lib/"
7
    }
8
}

Синтаксис сперва может показаться непонятным. Тоже самое было и для меня. Простым способом расшифровать "Way": "app/lib/" будет подумать про себя "базовая папка для пространства имен Way находится в app/lib". Конечно же замените здесь мою фамилию на название вашего собственного проекта. Структура папок при этом должна быть следующей:

  • app/
    • lib/
    • Way/

Затем вместе группирования всех репозиториев в папке repositories, более элегантным решением будет сгруппировать их по категориям в различные папки, вроде этого:

  • app/
    • lib/
    • Way/
      • Storage/
      • Post/
        • PostRepositoryInterface.php
        • EloquentPostRepository.php

Важно, что мы придерживаемся этого соглашения именования файлов и папок, если мы хотим чтобы автозагрузчик работал так, как ожидалось. Единственная вещь, которая осталась - это обновить пространства имен для PostRepositoryInterface и EloquentPostRepository.

1
2
<?php namespace Way\Storage\Post;
3
4
# app/lib/Way/Storage/Post/PostRepositoryInterface.php

5
6
interface PostRepositoryInterface {
7
8
    public function all();
9
10
    public function find($id);
11
12
    public function create($input);
13
14
}

И реализация:

1
2
<?php namespace Way\Storage\Post;
3
4
# app/lib/Way/Storage/Post/EloquentPostRepository.php

5
6
use Post;
7
8
class EloquentPostRepository implements PostRepositoryInterface {
9
10
    public function all()
11
    {
12
        return Post::all();
13
    }
14
15
    public function find($id)
16
    {
17
        return Post::find($id);
18
    }
19
20
    public function create($input)
21
    {
22
        return Post::create($input);
23
    }
24
25
}

Вот так, теперь выглядит гораздо лучше. Но как насчет тех надоедливых привязок? Файл с маршрутами может быть удобным местом для экспериментов, но лучше их хранить отдельно. Вместо маршрутов воспользуемся сервис провайдерами.

Сервис провайдеры - это не более чем загрузчики классов, которые могут делать все, что вам нужно: зарегистрировать привязку, обработать событие, импортировать файл маршрутов и прочее.

Laravel автоматически вызовет метод register() у сервис провайдера.

1
2
<?php namespace Way\Storage;
3
4
# app/lib/Way/Storage/StorageServiceProvider.php

5
6
use Illuminate\Support\ServiceProvider;
7
8
class StorageServiceProvider extends ServiceProvider {
9
10
    // Triggered automatically by Laravel

11
    public function register()
12
    {
13
        $this->app->bind(
14
            'Way\Storage\Post\PostRepositoryInterface',
15
            'Way\Storage\Post\EloquentPostRepository'
16
        );
17
    }
18
19
}

Чтобы дать знать Laravel о новом сервис провайдере, необходимо добавить его в файл app/config/app.php в массив providers.

1
2
# app/config/app.php

3
4
'providers' => array(
5
    'Illuminate\Foundation\Providers\ArtisanServiceProvider',
6
    'Illuminate\Auth\AuthServiceProvider',
7
    // ...

8
    'Way\Storage\StorageServiceProvider'
9
)

Отлично; теперь у нас есть отдельный файл для регистрации новых привязок.

Обновляем тесты

Имея в виду нашу новую структуру, теперь вместо того чтобы мокать саму модель Eloquent, мы можем замокать PostRepositoryInterface. Вот пример одного из таких тестов:

1
2
# app/tests/controllers/PostsControllerTest.php

3
4
public function testIndex()
5
{
6
    $mock = Mockery::mock('Way\Storage\Post\PostRepositoryInterface');
7
    $mock->shouldReceive('all')->once();
8
9
    $this->app->instance('Way\Storage\Post\PostRepositoryInterface', $mock);
10
11
    $this->call('GET', 'posts');
12
13
    $this->assertViewHas('posts');
14
}

Однако мы можем улучшить и это. Очевидно, что каждый метод PostsControllerTest потребует замоканную версию репозитория. Таким образом гораздо лучше будет выделить эту отдельную подготовительную работу в свой собственный метод следующим образом:

1
2
# app/tests/controllers/PostsControllerTest.php

3
4
public function setUp()
5
{
6
    parent::setUp();
7
8
    $this->mock('Way\Storage\Post\PostRepositoryInterface');
9
}
10
11
public function mock($class)
12
{
13
    $mock = Mockery::mock($class);
14
15
    $this->app->instance($class, $mock);
16
17
    return $mock;
18
}
19
20
public function testIndex()
21
{
22
    $this->mock->shouldReceive('all')->once();
23
24
    $this->call('GET', 'posts');
25
26
    $this->assertViewHas('posts');
27
}

Неплохо, да?

Теперь вы даже можете использовать моки с моделью Eloquent. Это позволит выполнить следующее:

1
2
Post::shouldReceive('all')->once();

За кулисами будет замокан PostRepositoryInterface и обновлена привязка в контейнере. Вы уже не сможете получить код гораздо проще, чем этот!

Чтобы получить возможность использовать подобного рода синтаксис, нужно обновить модель Post, а еще лучше BaseModel, от которой наследуются все Eloquent модели. Вот примере этого:

1
2
<?php
3
4
# app/models/Post.php

5
6
class Post extends Eloquent {
7
8
    public static function shouldReceive()
9
    {
10
        $class = get_called_class();
11
        $repo = "Way\\Storage\\{$class}\\{$class}RepositoryInterface";
12
        $mock = Mockery::mock($repo);
13
14
        App::instance($repo, $mock);
15
16
        return call_user_func_array([$mock, 'shouldReceive'], func_get_args());
17
    }
18
19
}

Если вы сможете справиться с внутренним диалогом "Должен ли я встраивать тестовую логику в боевой код", то обнаружите, что это позволяет сделать тесты гораздо более читабельными.

1
2
<?php
3
4
# app/tests/controllers/PostsControllerTest.php

5
6
class PostsControllerTest extends TestCase {
7
8
    public function tearDown()
9
    {
10
        Mockery::close();
11
    }
12
13
    public function testIndex()
14
    {
15
        Post::shouldReceive('all')->once();
16
17
        $this->call('GET', 'posts');
18
19
        $this->assertViewHas('posts');
20
    }
21
22
    public function testStoreFails()
23
    {
24
        Input::replace($input = ['title' => '']);
25
26
        $this->call('POST', 'posts');
27
28
        $this->assertRedirectedToRoute('posts.create');
29
        $this->assertSessionHasErrors();
30
    }
31
32
    public function testStoreSuccess()
33
    {
34
        Input::replace($input = ['title' => 'Foo Title']);
35
36
        Post::shouldReceive('create')->once();
37
38
        $this->call('POST', 'posts');
39
40
        $this->assertRedirectedToRoute('posts.index', ['flash']);
41
    }
42
43
}

Хорошо, не правда ли? Надеюсь, эта статья не была слишком нудной. Главное что стоит подчерпнуть из этой статьи - это организация ваших репозиториев таким образом, чтобы можно было их легко мокать и встраивать в ваши контроллеры. В результате использования такого подхода, ваши тесты будут выполняться молниеносно!

Эта статья — отрывок из моей предстоящей книги Laravel Testing Decoded. Оставайтесь со мной до ее выпуска в мае 2013 г.!

Advertisement
Did you find this post useful?
Want a weekly email summary?
Subscribe below and we’ll send you a weekly email summary of all new Code tutorials. Never miss out on learning about the next big thing.
Advertisement
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.