Skip to content

Testing and debugging

Test a site-aware module against real sites, hostnames and per-site roles, and see what Sites resolved for a page.

Base classes and traits

Sites ships them under tests/src, namespace Drupal\Tests\sites. A module that has Sites in its codebase can extend them.

Class or trait Gives you
Kernel\SitesKernelTestBase Sites and Sites content with their schemas installed, and the three traits below. Declare only the modules you add.
Functional\SitesFunctionalTestBase BrowserTestBase with SiteCreationTrait, the stark theme, and helpers for several hostnames, the page cache and members.
Traits\SiteCreationTrait createSiteEntity(), createSiteOfferingRoles(), assignContentToSite(), loadSiteEntity(), refreshSitePlugins().
Traits\SiteContextTrait setCurrentSite() and withSite().
Traits\SiteSchemaInstallTrait installSiteSchemas(): the site, configuration override and role assignment schemas, and the membership schema when Sites content is on.

createSiteEntity($id, $settings, $values) creates a site with the hostname <id>.example.com; $settings is keyed by setting plugin, then by setting key (Site settings). It refreshes the site plugin caches; call refreshSitePlugins() yourself after changing a site entity in another way.

Kernel tests

setCurrentSite() puts the test where a request on the site's hostname is: the site is negotiated and active. withSite() switches to another site for a callback, as site_switcher does, and switches back even when an assertion fails. Until a site is set, the current site is not ready: it answers the hub, and configuration overrides and query scoping stay off. Call setCurrentSite('default') when the test needs the hub.

#[Group('my_module')]
#[RunTestsInSeparateProcesses]
final class MyModuleScopingTest extends SitesKernelTestBase {

  protected static $modules = ['node', 'field', 'path', 'my_module'];

  protected function setUp(): void {
    parent::setUp();
    $this->installEntitySchema('node');
    $this->installSchema('node', ['node_access']);
    $this->installConfig(['system']);
    // Bundles first: a site reads them when its definition is built.
    NodeType::create(['type' => 'page', 'name' => 'Page'])->save();
  }

  public function testQueryFollowsTheSite(): void {
    $alpha = $this->createSiteEntity('alpha', ['site_filtering' => ['filter_views' => TRUE]]);
    $node = Node::create(['type' => 'page', 'title' => 'Alpha news']);
    $node->save();
    $this->assignContentToSite($node, $alpha, TRUE);

    $this->setCurrentSite('alpha');
    $ids = \Drupal::entityQuery('node')->accessCheck(TRUE)->execute();
    $this->assertSame([(string) $node->id()], array_values($ids));
  }

}

EntityQueryScopingTest in Sites is the complete version.

In a kernel test, the first user created is user 1, who passes every access check: create it before the users you test with. The entity access handlers remember their answers for the whole test: reset them with resetCache() after changing a role assignment.

Per-site role access needs functional tests

The access policy adds the permissions of a per-site role to the account of the current request. A kernel test that checks access for another account, or switches accounts, does not take that path and can pass or fail wrongly. Test what a per-site role allows with real requests, logged in as a member that holds only the per-site role. A user with Administer sites passes every site gate and proves nothing. Kernel tests are fine for permissions of global roles and for membership rules.

#[Group('my_module')]
#[RunTestsInSeparateProcesses]
final class SiteEditorAccessTest extends SitesFunctionalTestBase {

  protected static $modules = ['node', 'sites_content'];

  protected function memberPermissions(): array {
    return ['access content'];
  }

  public function testEditorEditsTheirSiteOnly(): void {
    $this->drupalCreateContentType(['type' => 'page']);
    Role::create(['id' => 'site_editor', 'label' => 'Site editor'])
      ->grantPermission('edit any page content')
      ->save();
    $alpha = $this->createSiteOfferingRoles('alpha', ['site_editor']);
    $beta = $this->createSiteOfferingRoles('beta', ['site_editor']);
    $alpha_page = $this->drupalCreateNode(['type' => 'page']);
    $this->assignContentToSite($alpha_page, $alpha, TRUE);
    $beta_page = $this->drupalCreateNode(['type' => 'page']);
    $this->assignContentToSite($beta_page, $beta, TRUE);

    $this->drupalLogin($this->memberWithRole('alpha', 'site_editor'));
    $this->drupalGet('node/' . $alpha_page->id() . '/edit');
    $this->assertSession()->statusCodeEquals(200);
    $this->drupalGet('node/' . $beta_page->id() . '/edit');
    $this->assertSession()->statusCodeEquals(403);
  }

}

memberWithRole() creates a user with memberPermissions(), makes it a member of the site and grants the role there. SiteRoleJourneyTest in Sites walks a site manager and a site editor through the hub and a site's hostname.

Hostnames in functional tests

Every request of a functional test goes to the test site's own URL. drupalGetOnHost($host, $path) sends it with a Host header, so Sites negotiates the site of that hostname and no DNS entry is needed. Pass the host on every call; NULL is the hub. A redirect to a site's hostname cannot be followed, since the hostname does not resolve: send such a request through getHttpClient() with allow_redirects off, as SiteRoleJourneyTest::rawStatusOnHost() does.

public function testPageCachePerHost(): void {
  $this->enableAnonymousPageCache();
  $path = 'node/' . $this->alphaPage->id();

  $this->drupalGetOnHost('alpha.example.com', $path);
  $this->assertPageCache('MISS');
  $this->drupalGetOnHost('alpha.example.com', $path);
  $this->assertPageCache('HIT');
  $this->drupalGetOnHost('beta.example.com', $path);
  $this->assertSession()->responseHeaderNotEquals('X-Drupal-Cache', 'HIT');
}

HostResolutionIsolationTest in Sites has this test with its setup.

Sites devel

Sites devel adds a Sites debug information block to the content region of every page. It needs Devel and Block plugin view builder, and you do not place it. For the request it dumps:

  • the sites that claim the route, each with its definition, its resolved settings and its config_overrides setting; sensitive settings are redacted;
  • the active site;
  • the route name and parameters;
  • the languages;
  • the path aliases and redirects of the route's entity, with Sites path alias and Redirect.

The block shows to users with the View the Sites debug block permission (use sites_devel). In development mode it shows to every user who is logged in, and Devel's dump() and kint() output does too, without Access developer information. Development mode is the sites.development container parameter, false by default. Turn it on in the services.yml of a development environment and rebuild the container:

parameters:
  sites.development: true

Anonymous visitors never see the block without the permission. Do not enable Sites devel in production.

drush sites:info acme prints a site's resolved settings and configuration overrides on the command line; see Drush commands.