Обращали ли вы внимание, что в Kernel и Functional тестах сервисы вызываются через $this->container->get() или через \Drupal::service()? Казалось бы, что какая разница - мы так и так получаем сервис и тест работает, но есть нюансы. Давайте разберемся.
Kernel тесты
В Kernel тестах контейнер сервисов доступен через внутреннюю переменную $this->container и класс \Drupal.
Класс \Drupal (находится в core/lib/Drupal.php) - оболочка статичного контейнера сервисов (Static Service Container wrapper). Он был создан, для того, чтобы была возможность достать сервисы из контейнера сервисов в процедурном коде, например в хуках. Если в классах мы можем (и должны) использовать внедрение зависимостей, то в процедурном коде по-другому не получится.
Соответственно для загрузки сервисов мы можем использовать $this->container->get() и \Drupal::service(). Использование \Drupal в тестах считается антипатерном потому, что он создавался для использования в процедурном коде, а не в ООП.
Соответственно, в Kernel тестах предпочтительнее вызывать сервисы через $this->container->get().
Functional тесты
Пример 1
Давайте рассмотрим примеры функциональных тестов, где используются контейнеры сервисов $this->container и \Drupal, в которых нам нужно включить и использовать модуль book внутри теста:
namespace Drupal\Tests\example\Functional;
use Drupal\Tests\BrowserTestBase;
class ContainerFunctionalTest extends BrowserTestBase {
/**
* Test is failed since 'book.manager' doesn't exist in $this->container service container.
*/
public function testContainerFail() {
$this->container->get('module_installer')->install(['book']);
// Error is shown "Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException: You have requested a non-existent service "book.manager"."
$all_books = $this->container->get('book.manager')->getAllBooks();
$this->assertEmpty($all_books);
}
/**
* Test is passed since 'book.manager' exists in \Drupal::service() service container.
*/
public function testDrupalPass() {
\Drupal::service('module_installer')->install(['book']);
$all_books = \Drupal::service('book.manager')->getAllBooks();
$this->assertEmpty($all_books);
}
Тест testContainerFail(), который использует $this->container, не будет пройден потому, что после включения модуля “book” контейнер $this->container не обновится. Тест testDrupalPass() пройдет успешно - \Drupal::service будет включать в себя сервисы из только что включенного модуля.
Чтобы тест testContainerFail() прошел успешно, нужно контейнер сервисов проинициализировать еще раз используя $this->rebuildContainer(); либо же $this->container = \Drupal::getContainer();
public function testContainerPass() {
$this->container->get('module_installer')->install(['book']);
// Initialise the service container once again to pass the test.
$this->rebuildContainer();
$all_books = $this->container->get('book.manager')->getAllBooks();
$this->assertEmpty($all_books);
}
Пример 2
Давайте рассмотрим другой пример в котором у нас контейнер сервисов используется в хуке:
/**
* Implements hook_ENTITY_TYPE_load().
*/
function example_user_load(array $entities) {
// Service container is re-initialised during cache flush.
drupal_flush_all_caches();
\Drupal::service('state')->set('test', 'bar');
}
Обратите внимание, что в хуке используется drupal_flush_all_caches().
И есть два Functional теста:
namespace Drupal\Tests\example\Functional;
use Drupal\Tests\BrowserTestBase;
/**
* Tests behaviour of service containers in Functional tests.
*/
class StateFunctionalTest extends BrowserTestBase {
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $modules = [
'example',
];
/**
* Test is passed since the state returns correct value.
*/
public function testDrupalStatePass() {
\Drupal::service('state')->set('test', 'foo');
$this->assertEquals('foo', \Drupal::service('state')->get('test'));
\Drupal::entityTypeManager()->getStorage('user')->load(1);
$this->assertEquals('bar', \Drupal::service('state')->get('test'));
}
/**
* Test fails since \Drupal and $this->container point to different instances of State service.
*/
public function testContainerStateFail() {
$this->container->get('state')->set('test', 'foo');
$this->assertEquals('foo', $this->container->get('state')->get('test'));
$this->container->get('entity_type.manager')->getStorage('user')->load(1);
$this->assertEquals('bar', $this->container->get('state')->get('test'));
}
}
Первый тест testDrupalStatePass(), где используется \Drupal, пройдет успешно, а вот второй тест testContainerStateFail() пройти не сможет из-за того, что в example_user_load() мы очистили кеш и инициализировали контейнер сервисов заново. Теперь мы имеем дело с разными экземплярами контейнера сервисов в \Drupal и $this->container. Чтобы пройти тест нужно, как и в первом примере, обновить контейнер сервисов $this->container вручную используя $this->rebuildContainer(); либо же $this->container = \Drupal::getContainer();
/**
* Test passes since $this->container is updated manually.
*/
public function testContainerStatePass() {
$this->container->get('state')->set('test', 'foo');
$this->assertEquals('foo', $this->container->get('state')->get('test'));
$this->container->get('entity_type.manager')->getStorage('user')->load(1);
$this->rebuildContainer();
$this->assertEquals('bar', $this->container->get('state')->get('test'));
}
Обновлять вручную контейнер сервисов $this->container неудобно, не правда ли? Во время написания тестов достаточно сложно понять что контейнеры сервисов в \Drupal и $this->container рассинхронизированы.
Поэтому в Functional тестах для избежания непонятных ситуаций для получения сервисов лучше использовать \Drupal::service().
Это довольно странная ситуация, когда в Kernel тестах лучше использовать $this->container->get(), а в Functional тестах \Drupal::service(). На drupal.org eсть задача, где решается каким образом решить эту проблему. Надеюсь в Drupal 10 мы будем использовать единый подход.
Евгений Никитин