diff --git a/search_api_autocomplete.module b/search_api_autocomplete.module
index cfeaa66..da65d70 100644
--- a/search_api_autocomplete.module
+++ b/search_api_autocomplete.module
@@ -67,32 +67,38 @@ function search_api_autocomplete_form_views_exposed_form_alter(array &$form, For
     return;
   }
 
-  $index_id = substr($view->storage->get('base_table'), 17);
-
-  $index = Index::load($index_id);
-  if (!$fields = $index->getFulltextFields()) {
+  if (!$fields = $search->getIndex()->getFulltextFields()) {
     return;
   }
   // Add the "Search: Fulltext search" filter as another text field.
   $fields[] = 'search_api_fulltext';
 
-  \Drupal::moduleHandler()
-    ->alter('search_api_autocomplete_views_fulltext_fields', $fields, $search, $view);
+  \Drupal::moduleHandler()->alter('search_api_autocomplete_views_fulltext_fields', $fields, $search, $view);
+
   foreach ($view->filter as $filter_name => $filter) {
-    if (in_array($filter->realField, $fields)) {
-      if (!empty($filter->options['expose']['identifier'])) {
-        $key = $filter->options['expose']['identifier'];
-        if (isset($form[$key]) && $form[$key]['#type'] == 'textfield') {
-          if ($filter->realField == 'search_api_fulltext') {
-            $autocomplete_fields = $filter->options['fields'];
-          }
-          else {
-            $autocomplete_fields = [$filter->realField];
-          }
-          AutocompleteFormUtility::alterElement($form[$key], $search, $autocomplete_fields);
-        }
+    $key = $filter->options['expose']['identifier'];
+    if (!in_array($filter->realField, $fields)
+        || empty($filter->options['expose']['identifier'])
+        || !isset($form[$key])) {
+      continue;
+    }
+
+    $element = &$form[$key];
+    // The Views filter for individual fulltext fields uses a nested "value"
+    // field for the real input, due to Views internals.
+    if (!empty($element['value'])) {
+      $element = &$element['value'];
+    }
+    if ($element['#type'] == 'textfield') {
+      if ($filter->realField == 'search_api_fulltext') {
+        $autocomplete_fields = $filter->options['fields'];
+      }
+      else {
+        $autocomplete_fields = [$filter->realField];
       }
+      AutocompleteFormUtility::alterElement($element, $search, $autocomplete_fields);
     }
+    unset($element);
   }
 }
 
diff --git a/search_api_autocomplete.routing.yml b/search_api_autocomplete.routing.yml
index 9ffb3f1..c6f7571 100644
--- a/search_api_autocomplete.routing.yml
+++ b/search_api_autocomplete.routing.yml
@@ -12,4 +12,4 @@ search_api_autocomplete.admin_overview:
     _form: \Drupal\search_api_autocomplete\Form\IndexOverviewForm
     title: 'Autocomplete'
   requirements:
-    _entity_access: search_api_index.update
+    _permission: administer search_api_autocomplete
diff --git a/src/Controller/AutocompleteController.php b/src/Controller/AutocompleteController.php
index 68930cc..4f0c7a8 100644
--- a/src/Controller/AutocompleteController.php
+++ b/src/Controller/AutocompleteController.php
@@ -180,17 +180,16 @@ public function autocomplete(SearchInterface $search_api_autocomplete_search, $f
    * @param \Drupal\Core\Session\AccountInterface $account
    *   The account.
    *
-   * @return \Drupal\Core\Access\AccessResult
+   * @return \Drupal\Core\Access\AccessResultInterface
    *   The access result.
    */
   public function access(SearchInterface $search_api_autocomplete_search, AccountInterface $account) {
-    $permission = 'use search_api_autocomplete for ' . $search_api_autocomplete_search->id();
-    $access = AccessResult::allowedIf($search_api_autocomplete_search->status())
-      ->andIf(AccessResult::allowedIf($search_api_autocomplete_search->hasValidIndex()))
-      ->andIf(AccessResult::allowedIf($search_api_autocomplete_search->getIndex()->status()))
-      ->andIf(AccessResult::allowedIfHasPermission($account, $permission))
-      ->cachePerPermissions()
-      ->addCacheableDependency($search_api_autocomplete_search);
+    $search = $search_api_autocomplete_search;
+    $permission = 'use search_api_autocomplete for ' . $search->id();
+    $access = AccessResult::allowedIf($search->status())
+      ->andIf(AccessResult::allowedIf($search->hasValidIndex() && $search->getIndex()->status()))
+      ->andIf(AccessResult::allowedIfHasPermissions($account, [$permission, 'administer search_api_autocomplete'], 'OR'))
+      ->addCacheableDependency($search);
     if ($access instanceof AccessResultReasonInterface) {
       $access->setReason("The \"$permission\" permission is required and autocomplete for this search must be enabled.");
     }
diff --git a/src/Entity/Search.php b/src/Entity/Search.php
index b5d655b..cb13d8a 100644
--- a/src/Entity/Search.php
+++ b/src/Entity/Search.php
@@ -35,8 +35,8 @@
  *     "status" = "status",
  *   },
  *   links = {
- *     "edit-form" = "/admin/config/search/search-api/index/autocomplete/{search_api_autocomplete_search}/edit",
- *     "delete-form" = "/admin/config/search/search-api/index/autocomplete/{search_api_autocomplete_search}/delete",
+ *     "edit-form" = "/admin/config/search/search-api/index/{search_api_index}/autocomplete/{search_api_autocomplete_search}/edit",
+ *     "delete-form" = "/admin/config/search/search-api/index/{search_api_index}/autocomplete/{search_api_autocomplete_search}/delete",
  *   },
  *   config_export = {
  *     "id",
@@ -158,6 +158,17 @@ class Search extends ConfigEntityBase implements SearchInterface {
   /**
    * {@inheritdoc}
    */
+  protected function urlRouteParameters($rel) {
+    $parameters = parent::urlRouteParameters($rel);
+
+    $parameters['search_api_index'] = $this->getIndexId();
+
+    return $parameters;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
   public function getIndexId() {
     return $this->index_id;
   }
diff --git a/src/Form/IndexOverviewForm.php b/src/Form/IndexOverviewForm.php
index 02e74fb..e8ec874 100644
--- a/src/Form/IndexOverviewForm.php
+++ b/src/Form/IndexOverviewForm.php
@@ -219,6 +219,8 @@ public function buildForm(array $form, FormStateInterface $form_state, IndexInte
             'title' => $this->t('Edit'),
             'url' => $search->toUrl('edit-form'),
           ];
+        }
+        if (!$search->isNew()) {
           $items[] = [
             'title' => $this->t('Delete'),
             'url' => $search->toUrl('delete-form'),
@@ -292,11 +294,14 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
       $search = $form_state->get(['searches', $id]);
       if ($search->status() != $enabled) {
         $change = TRUE;
-        if (!empty($search)) {
+        if ($search->isNew()) {
           $options['query'] = $this->redirectDestination->getAsArray();
           $options['fragment'] = 'module-search_api_autocomplete';
-          $vars[':perm_url'] = Url::fromRoute('user.admin_permissions', [], $options)->toString();
-          $messages = $this->t('The settings have been saved. Please remember to set the <a href=":perm_url">permissions</a> for the newly enabled searches.', $vars);
+          $url = Url::fromRoute('user.admin_permissions', [], $options);
+          if ($url->access()) {
+            $vars[':perm_url'] = $url->toString();
+            $messages = $this->t('The settings have been saved. Please remember to set the <a href=":perm_url">permissions</a> for the newly enabled searches.', $vars);
+          }
         }
         $search->setStatus($enabled);
         $search->save();
diff --git a/src/Form/SearchEditForm.php b/src/Form/SearchEditForm.php
index 15c9b03..9fbc92e 100644
--- a/src/Form/SearchEditForm.php
+++ b/src/Form/SearchEditForm.php
@@ -137,14 +137,6 @@ public function buildForm(array $form, FormStateInterface $form_state) {
       '#collapsible' => TRUE,
       '#collapsed' => TRUE,
     ];
-    $form['advanced']['submit_button_selector'] = [
-      '#type' => 'textfield',
-      '#title' => $this->t('Search button selector'),
-      '#description' => $this->t('<a href="@jquery_url">jQuery selector</a> to identify the search button in the search form. Use the ID attribute of the "Search" submit button to prevent issues when another button is present (e.g., "Reset"). The selector is evaluated relative to the form. The default value is ":submit".', ['@jquery_url' => 'https://api.jquery.com/category/selectors/']),
-      '#default_value' => $search->getOption('submit_button_selector', ':submit'),
-      '#required' => TRUE,
-      '#parents' => ['options', 'submit_button_selector'],
-    ];
     $form['advanced']['autosubmit'] = [
       '#type' => 'checkbox',
       '#title' => $this->t('Enable auto-submit'),
@@ -152,9 +144,22 @@ public function buildForm(array $form, FormStateInterface $form_state) {
       '#default_value' => $search->getOption('autosubmit', TRUE),
       '#parents' => ['options', 'autosubmit'],
     ];
+    $form['advanced']['submit_button_selector'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Search button selector'),
+      '#description' => $this->t('<a href="@jquery_url">jQuery selector</a> identifying the button to use for submitting the search form. Use the ID attribute of the "Search" submit button to prevent issues when another button is present (e.g., "Reset"). The selector is evaluated relative to the form. The default value is "@default".', ['@jquery_url' => 'https://api.jquery.com/category/selectors/', '@default' => ':submit']),
+      '#default_value' => $search->getOption('submit_button_selector', ':submit'),
+      '#required' => TRUE,
+      '#parents' => ['options', 'submit_button_selector'],
+      '#states' => [
+        'visible' => [
+          ':input[name="options[autosubmit]"]' => ['checked' => TRUE],
+        ],
+      ],
+    ];
     $form['advanced']['delay'] = [
       '#type' => 'number',
-      '#title' => $this->t('Delay in ms'),
+      '#title' => $this->t('Delay (in ms)'),
       '#description' => $this->t('The delay in milliseconds between when a keystroke occurs and when a search is performed. Low values will result in a more responsive experience for users, but can also cause a higher load on the server. Defaults to 300 ms.'),
       '#min' => 0,
       '#step' => 1,
diff --git a/src/Suggester/SuggesterInterface.php b/src/Suggester/SuggesterInterface.php
index 42bfdb0..6152dcc 100644
--- a/src/Suggester/SuggesterInterface.php
+++ b/src/Suggester/SuggesterInterface.php
@@ -31,7 +31,8 @@ public static function supportsSearch(SearchInterface $search);
    * Retrieves autocompletion suggestions for some user input.
    *
    * For example, when given the user input "teach us", with "us" being
-   * considered incomplete, the following might be returned:
+   * considered incomplete, \Drupal\search_api_autocomplete\SuggestionInterface
+   * objects representing the following suggestions might be returned:
    *
    * @code
    *   [
@@ -47,7 +48,6 @@ public static function supportsSearch(SearchInterface $search);
    *       'user_input' => 'teach us',
    *       'suggestion_suffix' => ' swimming',
    *     ],
-   *     'teach users swimming',
    *   ];
    * @endcode
    *
diff --git a/src/Tests/TestsHelper.php b/src/Tests/TestsHelper.php
new file mode 100644
index 0000000..7412746
--- /dev/null
+++ b/src/Tests/TestsHelper.php
@@ -0,0 +1,100 @@
+<?php
+
+namespace Drupal\search_api_autocomplete\Tests;
+
+use Drupal\search_api\Query\QueryInterface;
+use Drupal\search_api_autocomplete\SearchInterface;
+use Drupal\search_api_autocomplete\Suggestion;
+
+/**
+ * Contains helper methods for running tests.
+ *
+ * Needed for test callbacks since test classes in \Drupal\Tests\* cannot be
+ * accessed during page requests in Functional tests.
+ */
+class TestsHelper {
+
+  /**
+   * Returns FALSE.
+   *
+   * @return false
+   *   FALSE.
+   */
+  public static function returnFalse() {
+    return FALSE;
+  }
+
+  /**
+   * Returns all features that the test backend should support.
+   *
+   * @return string[]
+   *   The identifiers of all features this backend supports.
+   *
+   * @see \Drupal\search_api_test\Plugin\search_api\backend\TestBackend::getSupportedFeatures()
+   */
+  public static function getSupportedFeatures() {
+    return ['search_api_autocomplete'];
+  }
+
+  /**
+   * Retrieves autocompletion suggestions for some user input.
+   *
+   * @param \Drupal\search_api\Backend\BackendInterface $backend
+   *   The backend on which this method was originally called.
+   * @param \Drupal\search_api\Query\QueryInterface $query
+   *   A query representing the base search, with all completely entered words
+   *   in the user input so far as the search keys.
+   * @param \Drupal\search_api_autocomplete\SearchInterface $search
+   *   An object containing details about the search the user is on, and
+   *   settings for the autocompletion. See the class documentation for details.
+   *   Especially $search->getOptions() should be checked for settings, like
+   *   whether to try and estimate result counts for returned suggestions.
+   * @param string $incomplete_key
+   *   The start of another fulltext keyword for the search, which should be
+   *   completed. Might be empty, in which case all user input up to now was
+   *   considered completed. Then, additional keywords for the search could be
+   *   suggested.
+   * @param string $user_input
+   *   The complete user input for the fulltext search keywords so far.
+   *
+   * @return \Drupal\search_api_autocomplete\SuggestionInterface[]
+   *   An array of autocomplete suggestions.
+   *
+   * @see \Drupal\search_api_autocomplete\AutocompleteBackendInterface::getAutocompleteSuggestions()
+   */
+  public static function getAutocompleteSuggestions($backend, QueryInterface $query, SearchInterface $search, $incomplete_key, $user_input) {
+    $args = array_slice(func_get_args(), 1);
+    static::logMethodCall('backend', __FUNCTION__, $args);
+
+    $suggestions = [];
+    for ($i = 1; $i <= $query->getOption('limit', 10); ++$i) {
+      $suggestions[] = Suggestion::fromSuggestionSuffix("-backend-$i", $i, $user_input);
+    }
+    return $suggestions;
+  }
+
+  /**
+   * Logs a method call to the site state.
+   *
+   * @param string $type
+   *   The type of plugin.
+   * @param string $method
+   *   The name of the called method.
+   * @param array $args
+   *   (optional) The method arguments.
+   */
+  protected function logMethodCall($type, $method, array $args = []) {
+    $state = \Drupal::state();
+
+    // Log call.
+    $key = "search_api_test.$type.methods_called";
+    $methods_called = $state->get($key, []);
+    $methods_called[] = $method;
+    $state->set($key, $methods_called);
+
+    // Log arguments.
+    $key = "search_api_test.$type.method_arguments.$method";
+    $state->set($key, $args);
+  }
+
+}
diff --git a/src/Type/TypeInterface.php b/src/Type/TypeInterface.php
index 1e7b6e9..2bd0b6f 100644
--- a/src/Type/TypeInterface.php
+++ b/src/Type/TypeInterface.php
@@ -22,8 +22,10 @@
    * @param \Drupal\search_api\IndexInterface $index
    *   A search api index.
    *
-   * @return array
-   *   An array of searches.
+   * @return array[]
+   *   An associative array of searches. Keys are the search IDs, values arrays
+   *   with information about the search. The following keys are recognized:
+   *   - label: The human-readable label for the search.
    */
   public function listSearches(IndexInterface $index);
 
diff --git a/tests/search_api_autocomplete_test/config/install/search_api.index.autocomplete_search_index.yml b/tests/search_api_autocomplete_test/config/install/search_api.index.autocomplete_search_index.yml
new file mode 100644
index 0000000..521f513
--- /dev/null
+++ b/tests/search_api_autocomplete_test/config/install/search_api.index.autocomplete_search_index.yml
@@ -0,0 +1,84 @@
+id: autocomplete_search_index
+name: 'Autocomplete test index'
+description: 'An index used for testing the Autocomplete module'
+read_only: false
+field_settings:
+  id:
+    label: ID
+    type: integer
+    datasource_id: 'entity:entity_test_mulrev_changed'
+    property_path: id
+  name:
+    label: Name
+    type: text
+    datasource_id: 'entity:entity_test_mulrev_changed'
+    property_path: name
+    boost: 5.0
+  created:
+    label: Authored on
+    type: date
+    datasource_id: 'entity:entity_test_mulrev_changed'
+    property_path: created
+  body:
+    label: Body
+    type: text
+    datasource_id: 'entity:entity_test_mulrev_changed'
+    property_path: body
+  type:
+    label: Type
+    type: string
+    datasource_id: 'entity:entity_test_mulrev_changed'
+    property_path: type
+  keywords:
+    label: Keywords
+    type: string
+    datasource_id: 'entity:entity_test_mulrev_changed'
+    property_path: keywords
+  category:
+    label: Category
+    type: string
+    datasource_id: 'entity:entity_test_mulrev_changed'
+    property_path: category
+  width:
+    label: Width
+    type: decimal
+    datasource_id: 'entity:entity_test_mulrev_changed'
+    property_path: width
+processor_settings:
+  add_url:
+    weights:
+      preprocess_index: -30
+  aggregated_field:
+    weights:
+      add_properties: 20
+  rendered_item:
+    weights:
+      add_properties: 0
+      pre_index_save: -10
+options:
+  cron_limit: -1
+  index_directly: false
+datasource_settings:
+  'entity:entity_test_mulrev_changed': {  }
+tracker_settings:
+  'default': { }
+server: autocomplete_search_server
+status: true
+langcode: en
+dependencies:
+  config:
+    - field.field.entity_test_mulrev_changed.article.body
+    - field.field.entity_test_mulrev_changed.article.keywords
+    - field.field.entity_test_mulrev_changed.article.category
+    - field.field.entity_test_mulrev_changed.article.width
+    - field.field.entity_test_mulrev_changed.item.body
+    - field.field.entity_test_mulrev_changed.item.keywords
+    - field.field.entity_test_mulrev_changed.item.category
+    - field.field.entity_test_mulrev_changed.item.width
+    - field.storage.entity_test_mulrev_changed.body
+    - field.storage.entity_test_mulrev_changed.keywords
+    - field.storage.entity_test_mulrev_changed.category
+    - field.storage.entity_test_mulrev_changed.width
+    - search_api.server.autocomplete_search_server
+  module:
+    - entity_test
diff --git a/tests/search_api_autocomplete_test/config/install/search_api.server.autocomplete_search_server.yml b/tests/search_api_autocomplete_test/config/install/search_api.server.autocomplete_search_server.yml
new file mode 100644
index 0000000..94a00a6
--- /dev/null
+++ b/tests/search_api_autocomplete_test/config/install/search_api.server.autocomplete_search_server.yml
@@ -0,0 +1,10 @@
+id: autocomplete_search_server
+name: 'Autocomplete search server'
+description: 'A server used for testing the Autocomplete module'
+backend: search_api_test
+backend_config:
+status: true
+langcode: en
+dependencies:
+  module:
+    - search_api_test
diff --git a/tests/search_api_autocomplete_test/config/install/views.view.search_api_autocomplete_test.yml b/tests/search_api_autocomplete_test/config/install/views.view.search_api_autocomplete_test.yml
new file mode 100644
index 0000000..3564f8a
--- /dev/null
+++ b/tests/search_api_autocomplete_test/config/install/views.view.search_api_autocomplete_test.yml
@@ -0,0 +1,161 @@
+base_field: search_api_id
+base_table: search_api_index_autocomplete_search_index
+core: 8.x
+description: ''
+status: true
+display:
+  default:
+    display_plugin: default
+    id: default
+    display_title: Master
+    position: 0
+    display_options:
+      access:
+        type: none
+        options: {  }
+      cache:
+        type: none
+        options: {  }
+      query:
+        type: search_api_query
+        options:
+          skip_access: true
+      exposed_form:
+        type: basic
+        options:
+          submit_button: Search
+          reset_button: false
+          reset_button_label: Reset
+          exposed_sorts_label: 'Sort by'
+          expose_sort_order: true
+          sort_asc_label: Asc
+          sort_desc_label: Desc
+      pager:
+        type: full
+        options:
+          items_per_page: 10
+          offset: 0
+          id: 0
+          total_pages: null
+          expose:
+            items_per_page: false
+            items_per_page_label: 'Items per page'
+            items_per_page_options: '5, 10, 20, 40, 60'
+            items_per_page_options_all: false
+            items_per_page_options_all_label: '- All -'
+            offset: false
+            offset_label: Offset
+          tags:
+            previous: '‹ previous'
+            next: 'next ›'
+            first: '« first'
+            last: 'last »'
+          quantity: 9
+      style:
+        type: default
+      row:
+        type: search_api
+        options:
+          view_modes:
+            'entity:entity_test_mulrev_changed':
+              article: default
+              page: default
+      filters:
+        keys:
+          id: keys
+          table: search_api_index_autocomplete_search_index
+          field: search_api_fulltext
+          relationship: none
+          group_type: group
+          admin_label: ''
+          operator: and
+          value: ''
+          group: 1
+          exposed: true
+          expose:
+            operator_id: keys_op
+            label: 'Fulltext search'
+            description: ''
+            use_operator: true
+            operator: keys_op
+            identifier: keys
+            required: false
+            remember: false
+            multiple: false
+            remember_roles:
+              authenticated: authenticated
+              anonymous: '0'
+              administrator: '0'
+          is_grouped: false
+          group_info:
+            label: ''
+            description: ''
+            identifier: ''
+            optional: true
+            widget: select
+            multiple: false
+            remember: false
+            default_group: All
+            default_group_multiple: {  }
+            group_items: {  }
+          min_length: 3
+          fields: {  }
+          plugin_id: search_api_fulltext
+        name:
+          plugin_id: search_api_text
+          id: name
+          table: search_api_index_autocomplete_search_index
+          field: name
+          relationship: none
+          admin_label: ''
+          operator: '='
+          group: 1
+          exposed: true
+          expose:
+            operator_id: name_op
+            label: ''
+            description: ''
+            use_operator: true
+            operator: name_op
+            identifier: name
+            required: false
+            remember: false
+            multiple: false
+            remember_roles:
+              authenticated: authenticated
+              anonymous: '0'
+              administrator: '0'
+          is_grouped: false
+      sorts: {  }
+      title: 'Search API Autocomplete Test view'
+      header:
+        result:
+          id: result
+          table: views
+          field: result
+          relationship: none
+          group_type: group
+          admin_label: ''
+          empty: true
+          content: 'Displaying @total search results'
+          plugin_id: result
+      footer: {  }
+      empty: {  }
+      relationships: {  }
+      arguments: {  }
+  page:
+    display_plugin: page
+    id: page
+    display_title: Page
+    position: 1
+    display_options:
+      path: search-api-autocomplete-test
+label: 'Search API Autocomplete Test view'
+module: views
+id: search_api_autocomplete_test
+tag: ''
+langcode: en
+dependencies:
+  module:
+    - search_api
+    - search_api_autocomplete_test
diff --git a/tests/search_api_autocomplete_test/config/schema/search_api_autocomplete_test.schema.yml b/tests/search_api_autocomplete_test/config/schema/search_api_autocomplete_test.schema.yml
new file mode 100644
index 0000000..64fb669
--- /dev/null
+++ b/tests/search_api_autocomplete_test/config/schema/search_api_autocomplete_test.schema.yml
@@ -0,0 +1,5 @@
+plugin.plugin_configuration.search_api_autocomplete_suggester.search_api_autocomplete_test:
+  type: ignore
+
+plugin.plugin_configuration.search_api_autocomplete_type.search_api_autocomplete_test:
+  type: ignore
diff --git a/tests/search_api_autocomplete_test/search_api_autocomplete_test.info.yml b/tests/search_api_autocomplete_test/search_api_autocomplete_test.info.yml
new file mode 100644
index 0000000..3f6f2f9
--- /dev/null
+++ b/tests/search_api_autocomplete_test/search_api_autocomplete_test.info.yml
@@ -0,0 +1,12 @@
+name: Search API Autocomplete Test
+description: "Support module for Search API Autocomplete tests."
+dependencies:
+  - drupal:entity_test
+  - drupal:views
+  - search_api:search_api_test
+  - search_api:search_api_test_example_content
+  - search_api_autocomplete:search_api_autocomplete
+core: 8.x
+type: module
+package: Search
+hidden: true
diff --git a/tests/search_api_autocomplete_test/src/Plugin/search_api_autocomplete/suggester/TestSuggester.php b/tests/search_api_autocomplete_test/src/Plugin/search_api_autocomplete/suggester/TestSuggester.php
new file mode 100644
index 0000000..126d9a4
--- /dev/null
+++ b/tests/search_api_autocomplete_test/src/Plugin/search_api_autocomplete/suggester/TestSuggester.php
@@ -0,0 +1,87 @@
+<?php
+
+namespace Drupal\search_api_autocomplete_test\Plugin\search_api_autocomplete\suggester;
+
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Plugin\PluginFormInterface;
+use Drupal\search_api\Query\QueryInterface;
+use Drupal\search_api_autocomplete\SearchInterface;
+use Drupal\search_api_autocomplete\Suggester\SuggesterPluginBase;
+use Drupal\search_api_autocomplete\Suggestion;
+use Drupal\search_api_test\TestPluginTrait;
+
+/**
+ * Defines a test suggester class.
+ *
+ * @SearchApiAutocompleteSuggester(
+ *   id = "search_api_autocomplete_test",
+ *   label = @Translation("Test suggester"),
+ *   description = @Translation("Suggester used for tests."),
+ * )
+ */
+class TestSuggester extends SuggesterPluginBase implements PluginFormInterface {
+
+  use TestPluginTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function supportsSearch(SearchInterface $search) {
+    $key = 'search_api_test.suggester.method.' . __FUNCTION__;
+    $override = \Drupal::state()->get($key);
+    if ($override) {
+      return call_user_func($override, $search);
+    }
+    return TRUE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
+    $this->logMethodCall(__FUNCTION__, func_get_args());
+    if ($override = $this->getMethodOverride(__FUNCTION__)) {
+      return call_user_func($override, $this, $form, $form_state);
+    }
+    return [];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
+    $this->logMethodCall(__FUNCTION__, func_get_args());
+    if ($override = $this->getMethodOverride(__FUNCTION__)) {
+      call_user_func($override, $this, $form, $form_state);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
+    $this->logMethodCall(__FUNCTION__, func_get_args());
+    if ($override = $this->getMethodOverride(__FUNCTION__)) {
+      call_user_func($override, $this, $form, $form_state);
+      return;
+    }
+    $this->setConfiguration($form_state->getValues());
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getAutocompleteSuggestions(QueryInterface $query, $incomplete_key, $user_input) {
+    $this->logMethodCall(__FUNCTION__, func_get_args());
+    if ($override = $this->getMethodOverride(__FUNCTION__)) {
+      return call_user_func($override, $this, $query, $incomplete_key, $user_input);
+    }
+
+    $suggestions = [];
+    for ($i = 1; $i <= $query->getOption('limit', 10); ++$i) {
+      $suggestions[] = Suggestion::fromSuggestionSuffix("-suggester-$i", $i, $user_input);
+    }
+    return $suggestions;
+  }
+
+}
diff --git a/tests/search_api_autocomplete_test/src/Plugin/search_api_autocomplete/type/TestType.php b/tests/search_api_autocomplete_test/src/Plugin/search_api_autocomplete/type/TestType.php
new file mode 100644
index 0000000..7d0b6a1
--- /dev/null
+++ b/tests/search_api_autocomplete_test/src/Plugin/search_api_autocomplete/type/TestType.php
@@ -0,0 +1,83 @@
+<?php
+
+namespace Drupal\search_api_autocomplete_test\Plugin\search_api_autocomplete\type;
+
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Plugin\PluginFormInterface;
+use Drupal\search_api\IndexInterface;
+use Drupal\search_api_autocomplete\SearchInterface;
+use Drupal\search_api_autocomplete\Type\TypePluginBase;
+use Drupal\search_api_test\TestPluginTrait;
+
+/**
+ * Defines a test type class.
+ *
+ * @SearchApiAutocompleteType(
+ *   id = "search_api_autocomplete_test",
+ *   label = @Translation("Test type"),
+ *   description = @Translation("Type used for tests."),
+ * )
+ */
+class TestType extends TypePluginBase implements PluginFormInterface {
+
+  use TestPluginTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
+    $this->logMethodCall(__FUNCTION__, func_get_args());
+    if ($override = $this->getMethodOverride(__FUNCTION__)) {
+      return call_user_func($override, $this, $form, $form_state);
+    }
+    return [];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
+    $this->logMethodCall(__FUNCTION__, func_get_args());
+    if ($override = $this->getMethodOverride(__FUNCTION__)) {
+      call_user_func($override, $this, $form, $form_state);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
+    $this->logMethodCall(__FUNCTION__, func_get_args());
+    if ($override = $this->getMethodOverride(__FUNCTION__)) {
+      call_user_func($override, $this, $form, $form_state);
+      return;
+    }
+    $this->setConfiguration($form_state->getValues());
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function listSearches(IndexInterface $index) {
+    $this->logMethodCall(__FUNCTION__, func_get_args());
+    if ($override = $this->getMethodOverride(__FUNCTION__)) {
+      return call_user_func($override, $this, $index);
+    }
+    return [
+      'search_api_autocomplete_test' => [
+        'label' => 'Autocomplete test module search',
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function createQuery(SearchInterface $search, $keys) {
+    $this->logMethodCall(__FUNCTION__, func_get_args());
+    if ($override = $this->getMethodOverride(__FUNCTION__)) {
+      return call_user_func($override, $this, $search, $keys);
+    }
+    return $search->getIndex()->query()->keys($keys);
+  }
+}
diff --git a/tests/src/FunctionalJavascript/IntegrationTest.php b/tests/src/FunctionalJavascript/IntegrationTest.php
new file mode 100644
index 0000000..48b5cc9
--- /dev/null
+++ b/tests/src/FunctionalJavascript/IntegrationTest.php
@@ -0,0 +1,435 @@
+<?php
+
+namespace Drupal\Tests\search_api_autocomplete\FunctionalJavascript;
+
+use Behat\Mink\Driver\GoutteDriver;
+use Drupal\FunctionalJavascriptTests\JavascriptTestBase;
+use Drupal\search_api_autocomplete\Tests\TestsHelper;
+use Drupal\search_api_test\PluginTestTrait;
+use Drupal\user\Entity\Role;
+
+/**
+ * Tests the functionality of the whole module from a user's perspective.
+ *
+ * @group search_api_autocomplete
+ */
+class IntegrationTest extends JavascriptTestBase {
+
+  use PluginTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'search_api_test',
+    'search_api_autocomplete_test',
+  ];
+
+  /**
+   * The ID of the search index used in this test.
+   *
+   * @var string
+   */
+  protected $indexId = 'autocomplete_search_index';
+
+  /**
+   * The ID of the search entity created for this test.
+   *
+   * @var string
+   */
+  protected $searchId = 'search_api_views_search_api_autocomplete_test';
+
+  /**
+   * An admin user used for the tests.
+   *
+   * @var \Drupal\user\UserInterface
+   */
+  protected $adminUser;
+
+  /**
+   * A normal (non-admin) user used for the tests.
+   *
+   * @var \Drupal\user\UserInterface
+   */
+  protected $normalUser;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $permissions = [
+      'administer search_api',
+      'administer search_api_autocomplete',
+      'administer permissions',
+    ];
+    $this->adminUser = $this->drupalCreateUser($permissions);
+
+    $this->normalUser = $this->drupalCreateUser();
+  }
+
+  /**
+   * Tests the complete functionality of the module via the UI.
+   */
+  public function testModule() {
+    $this->drupalLogin($this->adminUser);
+
+    $this->enableSearch();
+    $this->configureSearch();
+    $this->checkSearchAutocomplete();
+    $this->checkAutocompleteAccess();
+    $this->checkAdminAccess();
+  }
+
+  /**
+   * Goes to the index's "Autocomplete" tab and creates/enables the test search.
+   */
+  protected function enableSearch() {
+    $assert_session = $this->assertSession();
+
+    // Make test suggester incompatible with all searches to test the "No
+    // suggesters available" message.
+    $callback = [TestsHelper::class, 'returnFalse'];
+    $this->setMethodOverride('suggester', 'supportsSearch', $callback);
+
+    $this->drupalGet($this->getAdminPath());
+    $assert_session->statusCodeEquals(200);
+    $assert_session->pageTextContains('There are currently no suggester plugins installed that support this index.');
+
+    // Make it compatible with all searches again and refresh page.
+    $this->setMethodOverride('suggester', 'supportsSearch', NULL);
+
+    $this->getSession()->reload();
+    $this->logPageChange();
+    $assert_session->statusCodeEquals(200);
+
+    // Check whether all expected types and searches are present.
+    $assert_session->pageTextContains('Search views');
+    $assert_session->pageTextContains('Searches provided by Views');
+    $assert_session->pageTextContains('Search API Autocomplete Test view');
+    $assert_session->pageTextContains('Test type');
+    $assert_session->pageTextContains('Autocomplete test module search');
+
+    // Enable all Views searches (just one).
+    $assert_session->checkboxNotChecked("searches[{$this->searchId}]");
+    $this->click('table[data-drupal-selector="edit-views-searches"] > thead > tr > th.select-all input.form-checkbox');
+    $assert_session->checkboxChecked("searches[{$this->searchId}]");
+
+    $this->click('[data-drupal-selector="edit-submit"]');
+    $this->logPageChange(NULL, 'POST');
+    $assert_session->statusCodeEquals(200);
+    $assert_session->pageTextContains('The settings have been saved. Please remember to set the permissions for the newly enabled searches.');
+  }
+
+  /**
+   * Configures the test search via the UI.
+   */
+  protected function configureSearch() {
+    $assert_session = $this->assertSession();
+
+    $this->click('.dropbutton-action a[href$="/edit"]');
+    $this->logPageChange();
+    $assert_session->statusCodeEquals(200);
+    $assert_session->addressEquals($this->getAdminPath('edit'));
+
+    // The "Server" suggester shouldn't be available at that point.
+    $assert_session->elementExists('css', 'input[name="suggesters[enabled][search_api_autocomplete_test]"]');
+    $assert_session->elementNotExists('css', 'input[name="suggesters[enabled][server]"]');
+
+    // Make the test backend support autocomplete so that the "Server" suggester
+    // becomes available.
+    $callback = [TestsHelper::class, 'getSupportedFeatures'];
+    $this->setMethodOverride('backend', 'getSupportedFeatures', $callback);
+    $callback = [TestsHelper::class, 'getAutocompleteSuggestions'];
+    $this->setMethodOverride('backend', 'getAutocompleteSuggestions', $callback);
+
+    // After refreshing, the "Server" suggester should now be available. But by
+    // default, only our test suggester should be enabled (since it was the only
+    // option before).
+    $this->getSession()->reload();
+    $this->logPageChange();
+    $assert_session->checkboxChecked('suggesters[enabled][search_api_autocomplete_test]');
+    $assert_session->checkboxNotChecked('suggesters[enabled][server]');
+
+    // The "Server" suggester's config form is hidden by default, but displayed
+    // once we check its "Enabled" checkbox.
+    $this->assertNotVisible('css', 'details[data-drupal-selector="edit-suggesters-settings-server"]');
+    $this->click('input[name="suggesters[enabled][server]"]');
+    $this->assertVisible('css', 'details[data-drupal-selector="edit-suggesters-settings-server"]');
+
+    // Submit the form with some values for all fields.
+    $edit = [
+      'suggesters[weights][search_api_autocomplete_test][limit]' => '3',
+      'suggesters[weights][server][limit]' => '3',
+      'suggesters[weights][search_api_autocomplete_test][weight]' => '0',
+      'suggesters[weights][server][weight]' => '10',
+      'suggesters[settings][server][fields][name]' => FALSE,
+      'suggesters[settings][server][fields][body]' => TRUE,
+      'type_settings[display]' => 'page',
+      'options[limit]' => '5',
+      'options[min_length]' => '2',
+      'options[show_count]' => TRUE,
+      'options[delay]' => '1000',
+    ];
+    $this->submitForm($edit, 'Save');
+  }
+
+  /**
+   * Tests autocompletion in the search form.
+   */
+  protected function checkSearchAutocomplete() {
+    $assert_session = $this->assertSession();
+
+    $this->drupalGet('search-api-autocomplete-test');
+    $assert_session->statusCodeEquals(200);
+
+    $assert_session->elementAttributeContains('css', 'input[data-drupal-selector="edit-keys"]', 'data-search-api-autocomplete-search', $this->searchId);
+
+    $elements = $this->getAutocompleteSuggestions();
+    $suggestions = [];
+    $suggestion_elements = [];
+    foreach ($elements as $element) {
+      $user_input = $element->find('css', '.autocomplete-suggestion-user-input')
+        ->getText();
+      $suffix = $element->find('css', '.autocomplete-suggestion-suggestion-suffix')
+        ->getText();
+      $count = $element->find('css', '.autocomplete-suggestion-results');
+      $keys = $user_input . $suffix;
+      $suggestions[] = [
+        'keys' => $keys,
+        'count' => $count ? $count->getText() : NULL,
+      ];
+      $suggestion_elements[$keys] = $element;
+    }
+    $expected = [
+      [
+        'keys' => 'test-suggester-1',
+        'count' => 1,
+      ],
+      [
+        'keys' => 'test-suggester-2',
+        'count' => 2,
+      ],
+      [
+        'keys' => 'test-suggester-3',
+        'count' => 3,
+      ],
+      [
+        'keys' => 'test-backend-1',
+        'count' => 1,
+      ],
+      [
+        'keys' => 'test-backend-2',
+        'count' => 2,
+      ],
+    ];
+    $this->assertEquals($expected, $suggestions);
+
+    /** @var \Drupal\search_api\Query\QueryInterface $query */
+    list($query) = $this->getMethodArguments('backend', 'getAutocompleteSuggestions');
+    $this->assertEquals(['body'], $query->getFulltextFields());
+
+    // Click one of the suggestions. The form should now auto-submit.
+    $suggestion_elements['test-suggester-1']->click();
+    $this->logPageChange();
+    $assert_session->addressEquals('/search-api-autocomplete-test');
+    $this->assertRegExp('#[?&]keys=test-suggester-1#', $this->getUrl());
+
+    // Check that autocomplete in the "Name" filter works, too, and that it sets
+    // the correct fields on the query.
+    $this->getAutocompleteSuggestions('edit-name-value');
+    list($query) = $this->getMethodArguments('suggester', 'getAutocompleteSuggestions');
+    $this->assertEquals(['name'], $query->getFulltextFields());
+  }
+
+  /**
+   * Retrieves autocomplete suggestions from a field on the current page.
+   *
+   * @param string $field_html_id
+   *   (optional) The HTML ID of the field.
+   * @param string $input
+   *   (optional) The input to write into the field.
+   *
+   * @return \Behat\Mink\Element\NodeElement[]
+   *   The suggestion elements from the page.
+   */
+  protected function getAutocompleteSuggestions($field_html_id = 'edit-keys', $input = 'test') {
+    $page = $this->getSession()->getPage();
+    $assert_session = $this->assertSession();
+    $field = $assert_session->elementExists('css', "input[data-drupal-selector=\"$field_html_id\"]");
+    $field->setValue(substr($input, 0, -1));
+    $this->getSession()->getDriver()->keyDown($field->getXpath(), substr($input, -1));
+
+    $element = $assert_session->waitOnAutocomplete();
+    $this->assertTrue($element && $element->isVisible());
+    $this->logPageChange();
+
+    $locator = '.ui-autocomplete .search-api-autocomplete-suggestion';
+    // Contrary to documentation, this can also return NULL. Therefore, we need
+    // to make sure to return an array even in this case.
+    return $page->findAll('css', $locator) ?: [];
+  }
+
+  /**
+   * Verifies that autocomplete is only applied after access checks.
+   */
+  protected function checkAutocompleteAccess() {
+    $assert_session = $this->assertSession();
+
+    // Make sure autocomplete functionality is only available for users with the
+    // right permission.
+    $users = [
+      'non-admin' => $this->normalUser,
+      'anonymous' => NULL,
+    ];
+    $permission = "use search_api_autocomplete for {$this->searchId}";
+    $autocomplete_path = "search_api_autocomplete/{$this->searchId}/-";
+    foreach ($users as $user_type => $account) {
+      $this->drupalLogout();
+      if ($account) {
+        $this->drupalLogin($account);
+      }
+
+      $this->drupalGet('search-api-autocomplete-test');
+      $assert_session->statusCodeEquals(200);
+      $element = $assert_session->elementExists('css', 'input[data-drupal-selector="edit-keys"]');
+      $this->assertFalse($element->hasAttribute('data-search-api-autocomplete-search'), "Autocomplete should not be enabled for $user_type user without the necessary permission.");
+      $this->assertFalse($element->hasClass('form-autocomplete'), "Autocomplete should not be enabled for $user_type user without the necessary permission.");
+
+      $this->drupalGet($autocomplete_path, ['query' => ['q' => 'test']]);
+      $assert_session->statusCodeEquals(403);
+
+      $rid = $account ? 'authenticated' : 'anonymous';
+      Role::load($rid)->grantPermission($permission)->save();
+
+      $this->drupalGet('search-api-autocomplete-test');
+      $assert_session->statusCodeEquals(200);
+      $element = $assert_session->elementExists('css', 'input[data-drupal-selector="edit-keys"]');
+      $this->assertTrue($element->hasAttribute('data-search-api-autocomplete-search'), "Autocomplete should not be enabled for $user_type user without the necessary permission.");
+      $this->assertContains($this->searchId, $element->getAttribute('data-search-api-autocomplete-search'), "Autocomplete should not be enabled for $user_type user without the necessary permission.");
+      $this->assertTrue($element->hasClass('form-autocomplete'), "Autocomplete should not be enabled for $user_type user without the necessary permission.");
+
+      $this->drupalGet($autocomplete_path, ['query' => ['q' => 'test']]);
+      $assert_session->statusCodeEquals(200);
+    }
+    $this->drupalLogin($this->adminUser);
+  }
+
+  /**
+   * Verifies that admin pages are properly protected.
+   */
+  protected function checkAdminAccess() {
+    // Make sure anonymous and non-admin users cannot access admin pages.
+    $users = [
+      'non-admin' => $this->normalUser,
+      'anonymous' => NULL,
+    ];
+    $paths = [
+      'index overview' => $this->getAdminPath(),
+      'search edit form' => $this->getAdminPath('edit'),
+      'search delete form' => $this->getAdminPath('delete'),
+    ];
+    foreach ($users as $user_type => $account) {
+      $this->drupalLogout();
+      if ($account) {
+        $this->drupalLogin($account);
+      }
+      foreach ($paths as $label => $path) {
+        $this->drupalGet($path);
+        $status_code = $this->getSession()->getStatusCode();
+        $this->assertEquals(403, $status_code, "The $label is accessible for $user_type users.");
+      }
+    }
+    $this->drupalLogin($this->adminUser);
+  }
+
+  /**
+   * Returns the path of an admin page.
+   *
+   * @param string|null $page
+   *   (optional) "edit" or "delete" to get the path of the respective search
+   *   form, or NULL for the index's "Autocomplete" tab.
+   * @param string|null $search_id
+   *   (optional) The ID of the search to link to, if a page is specified. NULL
+   *   to use the default search used by this test.
+   *
+   * @return string
+   */
+  protected function getAdminPath($page = NULL, $search_id = NULL) {
+    $path = 'admin/config/search/search-api/index/autocomplete_search_index/autocomplete';
+    if ($page !== NULL) {
+      if ($search_id === NULL) {
+        $search_id = $this->searchId;
+      }
+      $path .= "/$search_id/$page";
+    }
+    return $path;
+  }
+
+  /**
+   * Logs a page change, if HTML output logging is enabled.
+   *
+   * The base class only logs requests when the drupalGet() or drupalPost()
+   * methods are used, so we need to implement this ourselves for other page
+   * changes.
+   *
+   * To enable HTML output logging, create some file where links to the logged
+   * pages should be placed and set the "BROWSERTEST_OUTPUT_FILE" environment
+   * variable to that file's path.
+   *
+   * @param string|null $url
+   *   (optional) The URL requested, if not the current URL.
+   * @param string $method
+   *   (optional) The HTTP method used for the request.
+   *
+   * @see \Drupal\Tests\BrowserTestBase::drupalGet()
+   * @see \Drupal\Tests\BrowserTestBase::setUp()
+   */
+  protected function logPageChange($url = NULL, $method = 'GET') {
+    $session = $this->getSession();
+    $driver = $session->getDriver();
+    if (!$this->htmlOutputEnabled || $driver instanceof GoutteDriver) {
+      return;
+    }
+    $current_url = $session->getCurrentUrl();
+    $url = $url ?: $current_url;
+    $html_output = "$method request to: $url<hr />Ending URL: $current_url";
+    $html_output .= '<hr />' . $session->getPage()->getContent();;
+    $html_output .= $this->getHtmlOutputHeaders();
+    $this->htmlOutput($html_output);
+  }
+
+  /**
+   * Asserts that the specified element exists and is visible.
+   *
+   * @param string $selector_type
+   *   The element selector type (CSS, XPath).
+   * @param string|array $selector
+   *   The element selector. Note: the first found element is used.
+   *
+   * @throws \Behat\Mink\Exception\ElementHtmlException
+   *   Thrown if the element doesn't exist.
+   */
+  protected function assertVisible($selector_type, $selector) {
+    $element = $this->assertSession()->elementExists($selector_type, $selector);
+    $this->assertTrue($element->isVisible(), "Element should be visible but isn't.");
+  }
+
+  /**
+   * Asserts that the specified element exists but is not visible.
+   *
+   * @param string $selector_type
+   *   The element selector type (CSS, XPath).
+   * @param string|array $selector
+   *   The element selector. Note: the first found element is used.
+   *
+   * @throws \Behat\Mink\Exception\ElementHtmlException
+   *   Thrown if the element doesn't exist.
+   */
+  protected function assertNotVisible($selector_type, $selector) {
+    $element = $this->assertSession()->elementExists($selector_type, $selector);
+    $this->assertFalse($element->isVisible(), "Element shouldn't be visible but is.");
+  }
+
+}
diff --git a/tests/src/Kernel/SearchCrudTest.php b/tests/src/Kernel/SearchCrudTest.php
index 83b39a7..414c7e7 100644
--- a/tests/src/Kernel/SearchCrudTest.php
+++ b/tests/src/Kernel/SearchCrudTest.php
@@ -6,6 +6,7 @@
 use Drupal\search_api\Entity\Index;
 use Drupal\search_api\Entity\Server;
 use Drupal\search_api_autocomplete\Entity\Search;
+use Drupal\search_api_autocomplete\SearchInterface;
 
 /**
  * Tests saving a Search API autocomplete config entity.
@@ -75,20 +76,111 @@ public function setUp() {
   }
 
   /**
-   * Creates and saves an autocomplete entity.
+   * Tests whether saving a new search entity works correctly.
    */
   public function testCreate() {
-    $autocomplete_search = Search::create([
+    $values = $this->getSearchTestValues();
+    $search = Search::create($values);
+    $search->save();
+
+    $this->assertInstanceOf(SearchInterface::class, $search);
+
+    $this->assertEquals($values['id'], $search->id());
+    $this->assertEquals($values['label'], $search->label());
+    $this->assertEquals($values['index_id'], $search->getIndexId());
+    $actual = $search->getSuggesterIds();
+    $this->assertEquals(array_keys($values['suggester_settings']), $actual);
+    $actual = $search->getSuggester('server')->getConfiguration();
+    $this->assertEquals($values['suggester_settings']['server'], $actual);
+    $actual = $search->getSuggesterWeights();
+    $this->assertEquals($values['suggester_weights'], $actual);
+    $actual = $search->getSuggesterLimits();
+    $this->assertEquals($values['suggester_limits'], $actual);
+    $this->assertEquals('views', $search->getTypeId());
+    $actual = $search->getTypeInstance()->getConfiguration();
+    $this->assertEquals($values['type_settings']['views'], $actual);
+    $this->assertEquals($values['options'], $search->getOptions());
+  }
+
+  /**
+   * Tests whether loading a search entity works correctly.
+   */
+  public function testLoad() {
+    $values = $this->getSearchTestValues();
+    $search = Search::create($values);
+    $search->save();
+
+    $loaded_search = Search::load($search->id());
+    $this->assertInstanceOf(SearchInterface::class, $loaded_search);
+    $this->assertEquals($search->toArray(), $loaded_search->toArray());
+  }
+
+  /**
+   * Tests whether updating a search entity works correctly.
+   */
+  public function testUpdate() {
+    $values = $this->getSearchTestValues();
+    $search = Search::create($values);
+    $search->save();
+
+    $search->set('label', 'foobar');
+    $search->save();
+
+    $this->assertEquals('foobar', $search->label());
+    $loaded_search = Search::load($search->id());
+    $this->assertInstanceOf(SearchInterface::class, $loaded_search);
+    $this->assertEquals($search->toArray(), $loaded_search->toArray());
+  }
+
+  /**
+   * Tests whether deleting a search entity works correctly.
+   */
+  public function testDelete() {
+    $values = $this->getSearchTestValues();
+    $search = Search::create($values);
+    $search->save();
+
+    $loaded_search = Search::load($search->id());
+    $this->assertInstanceOf(SearchInterface::class, $loaded_search);
+
+    $search->delete();
+
+    $loaded_search = Search::load($search->id());
+    $this->assertNull($loaded_search);
+  }
+
+  /**
+   * Retrieves properties for creating a test search entity.
+   *
+   * @return array
+   *   Properties for an Autocomplete Search entity.
+   */
+  protected function getSearchTestValues() {
+    return [
       'id' => 'muh',
       'label' => 'Meh',
       'index_id' => 'index',
-      'suggester_settings' => ['server' => []],
-      'type_settings' => ['test_type' => []],
+      'suggester_settings' => [
+        'server' => [
+          'fields' => ['foo', 'bar'],
+        ]
+      ],
+      'suggester_weights' => ['server' => -10],
+      'suggester_limits' => ['server' => 5],
+      'type_settings' => [
+        'views' => [
+          'display' => 'page',
+        ]
+      ],
       'options' => [
+        'limit' => 8,
+        'min_length' => 2,
+        'show_count' => TRUE,
         'delay' => 1338,
+        'submit_button_selector' => '#edit-submit',
+        'autosubmit' => TRUE,
       ],
-    ]);
-    $autocomplete_search->save();
+    ];
   }
 
 }
diff --git a/tests/src/Unit/AccessTest.php b/tests/src/Unit/AccessTest.php
new file mode 100644
index 0000000..085f3ff
--- /dev/null
+++ b/tests/src/Unit/AccessTest.php
@@ -0,0 +1,170 @@
+<?php
+
+namespace Drupal\Tests\search_api_autocomplete\Unit;
+
+use Drupal\Core\Access\AccessResultReasonInterface;
+use Drupal\Core\Cache\CacheableDependencyInterface;
+use Drupal\Core\Cache\Context\CacheContextsManager;
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\Core\Render\RendererInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\search_api\IndexInterface;
+use Drupal\search_api_autocomplete\Controller\AutocompleteController;
+use Drupal\search_api_autocomplete\SearchInterface;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * Tests access to the autocomplete path.
+ *
+ * @group search_api_autocomplete
+ *
+ * @coversDefaultClass \Drupal\search_api_autocomplete\Controller\AutocompleteController
+ */
+class AccessTest extends UnitTestCase {
+
+  /**
+   * The controller object used for the test.
+   *
+   * @var \Drupal\search_api_autocomplete\Controller\AutocompleteController
+   */
+  protected $controller;
+
+  /**
+   * The renderer used for the test.
+   *
+   * @var \Drupal\Core\Render\RendererInterface|\PHPUnit_Framework_MockObject_MockObject
+   */
+  protected $renderer;
+
+  /**
+   * The search entity used in this test.
+   *
+   * @var \Drupal\search_api_autocomplete\SearchInterface|\PHPUnit_Framework_MockObject_MockObject
+   */
+  protected $search;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $this->renderer = $this->getMock(RendererInterface::class);
+    $this->controller = new AutocompleteController($this->renderer);
+    $this->search = $this->getMock(SearchInterface::class);
+    $this->search->method('id')->willReturn('test');
+    $this->search->method('getCacheContexts')->willReturn(['test']);
+    $this->search->method('getCacheTags')->willReturn(['test']);
+    $this->search->method('getCacheMaxAge')->willReturn(1337);
+
+    // \Drupal\Core\Access\AccessResult::addCacheContexts() will need the cache
+    // contexts manager service for validation.
+    $container = new ContainerBuilder();
+    $contexts = ['test', 'user.permissions'];
+    $cacheContextsManager = new CacheContextsManager($container, $contexts);
+    $container->set('cache_contexts_manager', $cacheContextsManager);
+    \Drupal::setContainer($container);
+  }
+
+  /**
+   * Tests access to the autocomplete path under a given set of conditions.
+   *
+   * @param array $options
+   *   Associative array of options, containing one or more of the following:
+   *   - status: Whether the search should be enabled.
+   *   - index: Whether the search's index should exist.
+   *   - index_status: Whether the search's index should be enabled.
+   *   - permission: Whether the user should have the necessary permission to
+   *     access the search.
+   *   - admin: Whether the user should have the "administer
+   *     search_api_autocomplete" permission.
+   *   All options default to TRUE.
+   * @param bool $should_be_allowed
+   *   Whether access should be allowed.
+   *
+   * @covers ::access
+   *
+   * @dataProvider accessTestDataProvider
+   */
+  public function testAccess(array $options, $should_be_allowed) {
+    $options += [
+      'status' => TRUE,
+      'index' => TRUE,
+      'index_status' => TRUE,
+      'permission' => TRUE,
+      'admin' => TRUE,
+    ];
+
+    $this->search->method('status')->willReturn($options['status']);
+    $this->search->method('hasValidIndex')->willReturn($options['index']);
+    if ($options['index']) {
+      $index = $this->getMock(IndexInterface::class);
+      $index->method('status')->willReturn($options['index_status']);
+      $this->search->method('getIndex')->willReturn($index);
+    }
+
+    /** @var \Drupal\Core\Session\AccountInterface|\PHPUnit_Framework_MockObject_MockObject $account */
+    $account = $this->getMock(AccountInterface::class);
+    $permission = 'use search_api_autocomplete for ' . $this->search->id();
+    $account->method('hasPermission')->willReturnMap([
+      [$permission, $options['permission']],
+      ['administer search_api_autocomplete', $options['admin']],
+    ]);
+
+    // Needn't really be AccessResultNeutral, of course, but this is the easiest
+    // way to get all the possible interfaces.
+    /** @var \Drupal\Core\Access\AccessResultNeutral $result */
+    $result = $this->controller->access($this->search, $account);
+    $this->assertEquals($should_be_allowed, $result->isAllowed());
+    $this->assertEquals(FALSE, $result->isForbidden());
+    $this->assertEquals(!$should_be_allowed, $result->isNeutral());
+
+    $this->assertInstanceOf(CacheableDependencyInterface::class, $result);
+    $this->assertContains('test', $result->getCacheContexts());
+    $this->assertContains('test', $result->getCacheTags());
+    $this->assertEquals(1337, $result->getCacheMaxAge());
+
+    if (!$should_be_allowed) {
+      $this->assertInstanceOf(AccessResultReasonInterface::class, $result);
+      $this->assertEquals("The \"$permission\" permission is required and autocomplete for this search must be enabled.", $result->getReason());
+    }
+  }
+
+  /**
+   * Provides test data for the testAccess() method.
+   *
+   * @return array
+   *   An array containing arrays of method arguments for testAccess().
+   *
+   * @see \Drupal\Tests\search_api_autocomplete\Unit\AccessTest::testAccess
+   */
+  public function accessTestDataProvider() {
+    return [
+      'search disabled' => [
+        ['status' => FALSE],
+        FALSE,
+      ],
+      'index does not exist' => [
+        ['index' => FALSE],
+        FALSE,
+      ],
+      'index disabled' => [
+        ['index_status' => FALSE],
+        FALSE,
+      ],
+      'search-specific permission missing' => [
+        ['permission' => FALSE, 'admin' => FALSE],
+        FALSE,
+      ],
+      'search-specific permission present' => [
+        ['admin' => FALSE],
+        TRUE,
+      ],
+      'is admin' => [
+        ['permission' => FALSE],
+        TRUE,
+      ],
+    ];
+  }
+
+}
diff --git a/tests/src/Unit/AutocompleteFormUtilityTest.php b/tests/src/Unit/AutocompleteFormUtilityTest.php
index 7a96654..489503e 100644
--- a/tests/src/Unit/AutocompleteFormUtilityTest.php
+++ b/tests/src/Unit/AutocompleteFormUtilityTest.php
@@ -3,6 +3,7 @@
 namespace Drupal\Tests\search_api_autocomplete\Unit;
 
 use Drupal\search_api_autocomplete\AutocompleteFormUtility;
+use Drupal\Tests\UnitTestCase;
 
 /**
  * Tests various utility methods of the Search API Autocomplete module.
@@ -11,11 +12,16 @@
  *
  * @coversDefaultClass \Drupal\search_api_autocomplete\AutocompleteFormUtility
  */
-class AutocompleteFormUtilityTest extends \PHPUnit_Framework_TestCase {
+class AutocompleteFormUtilityTest extends UnitTestCase {
 
   /**
    * Tests splitting of user input into complete and incomplete words.
    *
+   * @param string $keys
+   *   The processed keywords.
+   * @param string[] $expected
+   *   The expected result of splitting the given user input.
+   *
    * @covers ::splitKeys
    *
    * @dataProvider providerTestSplitKeys
@@ -29,11 +35,12 @@ public function testSplitKeys($keys, array $expected) {
    */
   public function providerTestSplitKeys() {
     $data = [];
-    $data['simple-word'] = ['word', ['', 'word']];
-    $data['simple-word-dash'] = ['word-dash', ['', 'word-dash']];
-    $data['whitespace-right-side'] = ['word-dash ', ['word-dash', '']];
-    $data['quote-word-start'] = ['"word" other', ['"word"', 'other']];
-    $data['quote-word-end'] = ['word "other"', ['word "other"', '']];
+    $data['simple word'] = ['word', ['', 'word']];
+    $data['simple word with dash'] = ['word-dash', ['', 'word-dash']];
+    $data['trailing whitespace'] = ['word-dash ', ['word-dash', '']];
+    $data['quoted first word'] = ['"word" other', ['"word"', 'other']];
+    $data['quoted word in middle'] = ['word "other" word', ['word "other"', 'word']];
+    $data['quoted last word'] = ['word "other"', ['word "other"', '']];
 
     return $data;
   }
