diff --git a/migrations/d7_pathauto_patterns.yml b/migrations/d7_pathauto_patterns.yml
index 27b1fa6..123e150 100644
--- a/migrations/d7_pathauto_patterns.yml
+++ b/migrations/d7_pathauto_patterns.yml
@@ -3,21 +3,69 @@ label: Pathauto patterns
 migration_tags:
   - Drupal 7
   - Configuration
+deriver: Drupal\pathauto\Plugin\migrate\PathautoPatternDeriver
 source:
   plugin: pathauto_pattern
   constants:
     status: true
     selection_logic: 'and'
+    type_prefix: 'canonical_entities'
 process:
   status: constants/status
   id: id
-  label: label
-  type: type
-  pattern: pattern
-  selection_criteria: selection_criteria
+  entity_type:
+    -
+      plugin: skip_on_empty
+      source: entity_type
+      method: row
+    -
+      plugin: static_map
+      bypass: true
+      map: {  }
+  bundle:
+    -
+      plugin: skip_on_empty
+      source: bundle
+      method: process
+    -
+      # This plugin checks if the pattern being migrated belongs to the taxonomy
+      # vocabulary used by Forum. If so, we use the machine name that Forum
+      # expects. Otherwise, we leave it unchanged.
+      plugin: pathauto_forum_vocabulary
+      machine_name: forums
+  label:
+    plugin: pathauto_pattern_label
+    source:
+      - '@entity_type'
+      - '@bundle'
+      - langcode
+  type:
+    plugin: concat
+    source:
+      - constants/type_prefix
+      - '@entity_type'
+    delimiter: ':'
+  pattern:
+    plugin: callback
+    source: value
+    callable: unserialize
+  selection_criteria:
+    -
+      plugin: skip_on_empty
+      method: process
+      source: '@bundle'
+    -
+      plugin: pathauto_pattern_selection_criteria
+      source:
+        - '@entity_type'
+        - '@bundle'
+        - langcode
   selection_logic: constants/selection_logic
+  relationships:
+    plugin: pathauto_pattern_relationships
+    source:
+      - '@entity_type'
+      - langcode
+  weight: weight
 destination:
   plugin: 'entity:pathauto_pattern'
-migration_dependencies:
-  optional:
-    - d7_node_type
diff --git a/pathauto.module b/pathauto.module
index d1faa47..c3b243d 100644
--- a/pathauto.module
+++ b/pathauto.module
@@ -27,6 +27,7 @@ use Drupal\Core\Routing\RouteMatchInterface;
 use Drupal\Core\Url;
 use Drupal\pathauto\PathautoFieldItemList;
 use Drupal\pathauto\PathautoItem;
+use Drupal\taxonomy\Plugin\migrate\process\ForumVocabulary;
 
 /**
  * Implements hook_hook_info().
@@ -172,3 +173,15 @@ function pathauto_pattern_validate($element, FormStateInterface $form_state) {
   return $element;
 
 }
+
+/**
+ * Implements hook_migrate_process_info_alter().
+ */
+function pathauto_migrate_process_info_alter(&$definitions) {
+  if (
+    \Drupal::moduleHandler()->moduleExists('taxonomy') &&
+    !empty($definitions['pathauto_forum_vocabulary'])
+  ) {
+    $definitions['pathauto_forum_vocabulary']['class'] = ForumVocabulary::class;
+  }
+}
diff --git a/src/EventSubscriber/ContentEntityMigration.php b/src/EventSubscriber/ContentEntityMigration.php
new file mode 100644
index 0000000..f0c0a5d
--- /dev/null
+++ b/src/EventSubscriber/ContentEntityMigration.php
@@ -0,0 +1,220 @@
+<?php
+
+namespace Drupal\pathauto\EventSubscriber;
+
+use Drupal\Core\Database\DatabaseExceptionWrapper;
+use Drupal\Core\Entity\ContentEntityStorageInterface;
+use Drupal\Core\Entity\EntityFieldManagerInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Field\FieldDefinitionInterface;
+use Drupal\Core\KeyValueStore\KeyValueFactoryInterface;
+use Drupal\migrate\Event\MigrateEvents;
+use Drupal\migrate\Event\MigratePostRowSaveEvent;
+use Drupal\migrate\Event\MigratePreRowSaveEvent;
+use Drupal\migrate\Plugin\migrate\destination\EntityContentBase;
+use Drupal\migrate_drupal\Plugin\migrate\source\DrupalSqlBase;
+use Drupal\pathauto\PathautoState;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * A subscriber to content entity migrations.
+ *
+ * This event subscriber prevents generating path aliases for content entities
+ * being migrated.
+ *
+ * It also saves path alias states (pathauto states) for nodes and taxonomy term
+ * entities (only these entities might have path alias state in Drupal 7
+ * Pathauto). For an up-to-date Pathauto module, the states are fetched from
+ * Pathauto's "pathauto_state" table. In case of an older release, the event
+ * subscriber tries to fetch the state from the "pathauto_persist" table of
+ * Pathauto Persistent State (pathauto_persist) module.
+ */
+class ContentEntityMigration implements EventSubscriberInterface {
+
+  /**
+   * Constant to flag a new content entity.
+   *
+   * @const string
+   */
+  const ENTITY_BEING_MIGRATED = '_pathauto_content_entity_migration';
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The entity field manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityFieldManager;
+
+  /**
+   * The key value factory.
+   *
+   * @var \Drupal\Core\KeyValueStore\KeyValueFactoryInterface
+   */
+  protected $keyValue;
+
+  /**
+   * Constructs a ContentEntityMigration instance.
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $entity_field_manager, KeyValueFactoryInterface $key_value) {
+    $this->entityTypeManager = $entity_type_manager;
+    $this->entityFieldManager = $entity_field_manager;
+    $this->keyValue = $key_value;
+  }
+
+  /**
+   * Checks whether a migration is a content entity migration with "path" field.
+   *
+   * @param \Drupal\migrate\Event\MigratePreRowSaveEvent $event
+   *   The migrate post row save event.
+   *
+   * @return bool
+   *   TRUE if the migration's source is Drupal, the migration's destination is
+   *   a content entity, and the destination entity has a "path" field; FALSE
+   *   otherwise.
+   */
+  protected function isApplicable(MigratePreRowSaveEvent $event): bool {
+    $migration = $event->getMigration();
+    if (!($migration->getSourcePlugin() instanceof DrupalSqlBase)) {
+      return FALSE;
+    }
+    if (!(($destination_plugin = $migration->getDestinationPlugin()) instanceof EntityContentBase)) {
+      return FALSE;
+    }
+    if (!($destination_entity_type = $destination_plugin->getDerivativeId())) {
+      return FALSE;
+    }
+    $path_field_definition = $this->entityFieldManager->getBaseFieldDefinitions($destination_entity_type)['path'] ?? NULL;
+    return $path_field_definition instanceof FieldDefinitionInterface;
+  }
+
+  /**
+   * Flags new content entities as 'migrated'.
+   *
+   * @param \Drupal\migrate\Event\MigratePreRowSaveEvent $event
+   *   The migrate pre row save event.
+   */
+  public function onPreRowSave(MigratePreRowSaveEvent $event): void {
+    if (!$this->isApplicable($event)) {
+      return;
+    }
+
+    // "Flag" the entity to make PathautoGenerator skip creating an alias during
+    // the migration process.
+    // @see \Drupal\pathauto\PathautoGenerator::createEntityAlias()
+    $row = $event->getRow();
+    $row->setDestinationProperty(static::ENTITY_BEING_MIGRATED, NULL);
+  }
+
+  /**
+   * Saves pathauto states.
+   *
+   * @param \Drupal\migrate\Event\MigratePostRowSaveEvent $event
+   *   The migrate post row save event.
+   */
+  public function onPostRowSave(MigratePostRowSaveEvent $event): void {
+    if (!$this->isApplicable($event)) {
+      return;
+    }
+    $row = $event->getRow();
+
+    // Only nodes and terms may have pathauto alias state.
+    $migration = $event->getMigration();
+    $entity_type = $migration->getDestinationPlugin()->getDerivativeId();
+    if (!in_array($entity_type, ['node', 'taxonomy_term'], TRUE)) {
+      return;
+    }
+
+    // Determine the right state table.
+    $source = $migration->getSourcePlugin();
+    $pathauto_schema = $source->getSystemData()['module']['pathauto']['schema_version'] ?? 0;
+    $path_alias_state_table = $pathauto_schema >= 7006
+      ? 'pathauto_state'
+      : 'pathauto_persist';
+    // Get the source ID of the current content entity the row represents.
+    $source_id_values = $row->getSourceIdValues();
+    $source_entity_id = reset($source_id_values);
+
+    // Check the source database for a matching path alias state record.
+    try {
+      $path_alias_state_from_source_result = $source->getDatabase()->select($path_alias_state_table, 'pas')
+        ->fields('pas', ['pathauto'])
+        ->condition('pas.entity_type', $entity_type)
+        ->condition('pas.entity_id', $source_entity_id)
+        ->execute()->fetchField();
+      $path_alias_state_from_source = $path_alias_state_from_source_result !== FALSE ? (int) $path_alias_state_from_source_result : NULL;
+    }
+    catch (DatabaseExceptionWrapper $e) {
+      // No table found or the table does not have the expected schema.
+      $path_alias_state_from_source = NULL;
+    }
+
+    // Try to load the destination entity and check its alias current state.
+    // This is required for e.g. node translation: multilingual node sources
+    // have different IDs on the source site, but they will have the same node
+    // ID on the destination site. If any of the translations (even the default
+    // one) had a custom path alias, then we will set the state to
+    // PathautoState::SKIP to prevent accidental data loss.
+    // @see https://www.w3.org/Provider/Style/URI
+    $storage = $this->entityTypeManager->getStorage($entity_type);
+    assert($storage instanceof ContentEntityStorageInterface);
+    $destination_id_values = $event->getDestinationIdValues();
+    if (!$destination_id_values) {
+      return;
+    }
+    $destination_entity_id = current($destination_id_values);
+
+    $path_alias_state_at_dest = $this->keyValue->get("pathauto_state.$entity_type")
+      ->get(PathautoState::getPathautoStateKey($destination_entity_id));
+
+    // Determine the right path alias state.
+    $path_alias_state = NULL;
+    if ($path_alias_state_from_source === NULL && $path_alias_state_at_dest === NULL) {
+      // No path alias status was found.
+      return;
+    }
+    elseif ($path_alias_state_from_source === NULL && $path_alias_state_at_dest !== NULL) {
+      // Source does not have path alias state for this entity row, but
+      // destination does. This might happen with entity translations.
+      $path_alias_state = $path_alias_state_at_dest;
+    }
+    elseif ($path_alias_state_from_source !== NULL && $path_alias_state_at_dest === NULL) {
+      // Source does have state for this entity row, but destination does not.
+      // This might be the row that contains the default translation.
+      $path_alias_state = $path_alias_state_from_source;
+    }
+    else {
+      // Both source and destination have state for this entity row. If these
+      // are equal, then use that state value; if they aren't, set it to
+      // PathautoState::SKIP to prevent changing the custom path alias.
+      $path_alias_state = $path_alias_state_at_dest === $path_alias_state_from_source
+        ? $path_alias_state_from_source
+        : PathautoState::SKIP;
+    }
+
+    // Actually, I (huzooka) was not able to push the state value to
+    // PathautoFieldItemList::computeValue() when a content entity's language
+    // is not equal with the destination site's default language: that's why the
+    // right keyvalue record is set here.
+    // @see Drupal\pathauto\PathautoFieldItemList::computeValue()
+    $this->keyValue->get("pathauto_state.$entity_type")
+      ->set(PathautoState::getPathautoStateKey($destination_entity_id), $path_alias_state);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      MigrateEvents::PRE_ROW_SAVE => ['onPreRowSave'],
+      MigrateEvents::POST_ROW_SAVE => ['onPostRowSave'],
+    ];
+  }
+
+}
diff --git a/src/PathautoFieldItemList.php b/src/PathautoFieldItemList.php
index 300675c..b700351 100644
--- a/src/PathautoFieldItemList.php
+++ b/src/PathautoFieldItemList.php
@@ -2,12 +2,16 @@
 
 namespace Drupal\pathauto;
 
+use Drupal\Core\KeyValueStore\KeyValueStoreInterface;
 use Drupal\path\Plugin\Field\FieldType\PathFieldItemList;
 
+/**
+ * Overridden FieldItemList class for also tracking the field state.
+ */
 class PathautoFieldItemList extends PathFieldItemList {
 
   /**
-   * @{inheritdoc}
+   * {@inheritdoc}
    */
   protected function delegateMethod($method) {
     // @todo Workaround until this is fixed, see
@@ -27,14 +31,22 @@ class PathautoFieldItemList extends PathFieldItemList {
   }
 
   /**
-   * @{inheritdoc}
+   * {@inheritdoc}
    */
   protected function computeValue() {
     parent::computeValue();
 
-    // For a new entity, default to creating a new alias.
-    if ($this->getEntity()->isNew()) {
-      $this->list[0]->set('pathauto', PathautoState::CREATE);
+    // For a new entity, default to creating a new alias (unless it is a content
+    // entity being migrated).
+    if ($this->getEntity()->id() !== NULL && $this->getEntity()->isNew()) {
+      // Try to get the proper path alias state: This new entity could be a
+      // migrated entity which already has a pathauto state.
+      // @see \Drupal\pathauto\EventSubscriber\ContentEntityMigration::onPreRowSave()
+      $entity_type_id = $this->getEntity()->getEntityTypeId();
+      $keyvalue = \Drupal::keyValue("pathauto_state.$entity_type_id");
+      assert($keyvalue instanceof KeyValueStoreInterface);
+      $pathauto_state_key = PathautoState::getPathautoStateKey($this->getEntity()->id());
+      $this->list[0]->set('pathauto', $keyvalue->get($pathauto_state_key, PathautoState::CREATE));
     }
   }
 
diff --git a/src/PathautoGenerator.php b/src/PathautoGenerator.php
index 5e50359..20e9137 100644
--- a/src/PathautoGenerator.php
+++ b/src/PathautoGenerator.php
@@ -16,6 +16,7 @@ use Drupal\Core\Render\BubbleableMetadata;
 use Drupal\Core\StringTranslation\StringTranslationTrait;
 use Drupal\Core\StringTranslation\TranslationInterface;
 use Drupal\Core\Utility\Token;
+use Drupal\pathauto\EventSubscriber\ContentEntityMigration;
 use Drupal\token\TokenEntityMapperInterface;
 
 /**
@@ -154,6 +155,24 @@ class PathautoGenerator implements PathautoGeneratorInterface {
    * {@inheritdoc}
    */
   public function createEntityAlias(EntityInterface $entity, $op) {
+    // Do not create path alias for an entity which will be migrated right now.
+    // @see \Drupal\pathauto\EventSubscriber\ContentEntityMigration::onPreRowSave()
+    try {
+      // We need to use reflection because only "$entity->values" contains the
+      // array key ContentEntityMigration::ENTITY_BEING_MIGRATED set in
+      // ContentEntityMigration::onPreRowSave() and "$entity->values" is a
+      // protected property.
+      $reflection = new \ReflectionClass($entity);
+      $value_ref_properties = $reflection->getProperty('values');
+      $value_ref_properties->setAccessible(TRUE);
+      // We cannot check the value of the key since it is set to NULL for
+      // translations.
+      if (array_key_exists(ContentEntityMigration::ENTITY_BEING_MIGRATED, $value_ref_properties->getValue($entity))) {
+        return NULL;
+      }
+    }
+    catch (\ReflectionException $e) {
+    }
     // Retrieve and apply the pattern for this content type.
     $pattern = $this->getPatternByEntity($entity);
     if (empty($pattern)) {
diff --git a/src/PathautoServiceProvider.php b/src/PathautoServiceProvider.php
index ea05c45..e4089c1 100644
--- a/src/PathautoServiceProvider.php
+++ b/src/PathautoServiceProvider.php
@@ -4,6 +4,8 @@ namespace Drupal\pathauto;
 
 use Drupal\Core\DependencyInjection\ContainerBuilder;
 use Drupal\Core\DependencyInjection\ServiceProviderBase;
+use Drupal\pathauto\EventSubscriber\ContentEntityMigration;
+use Symfony\Component\DependencyInjection\Reference;
 
 /**
  * Remove the drush commands until path_alias module is enabled.
@@ -18,6 +20,15 @@ class PathautoServiceProvider extends ServiceProviderBase {
     if (!in_array('path_alias.repository', $definitions)) {
       $container->removeDefinition('pathauto.commands');
     }
+
+    $modules = $container->getParameter('container.modules');
+    if (isset($modules['migrate'])) {
+      $container->register('pathauto.content_entity_migration', ContentEntityMigration::class)
+        ->addTag('event_subscriber')
+        ->addArgument(new Reference('entity_type.manager'))
+        ->addArgument(new Reference('entity_field.manager'))
+        ->addArgument(new Reference('keyvalue'));
+    }
   }
 
 }
diff --git a/src/Plugin/migrate/PathautoPatternDeriver.php b/src/Plugin/migrate/PathautoPatternDeriver.php
new file mode 100644
index 0000000..921a1fe
--- /dev/null
+++ b/src/Plugin/migrate/PathautoPatternDeriver.php
@@ -0,0 +1,163 @@
+<?php
+
+namespace Drupal\pathauto\Plugin\migrate;
+
+use Drupal\Component\Plugin\Derivative\DeriverBase;
+use Drupal\Component\Plugin\PluginBase;
+use Drupal\Core\Database\DatabaseExceptionWrapper;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\migrate\Exception\RequirementsException;
+use Drupal\migrate\Plugin\MigrationDeriverTrait;
+use Drupal\migrate\Row;
+use Drupal\migrate_drupal\Plugin\migrate\source\DrupalSqlBase;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Deriver class for Pathauto pattern migrations.
+ */
+class PathautoPatternDeriver extends DeriverBase implements ContainerDeriverInterface {
+
+  use StringTranslationTrait;
+  use MigrationDeriverTrait;
+
+  /**
+   * The module handler.
+   *
+   * @var \Drupal\Core\Extension\ModuleHandlerInterface
+   */
+  protected $moduleHandler;
+
+  /**
+   * Constructs PathautoPatternDeriver.
+   *
+   * @param string $base_plugin_id
+   *   The base plugin ID this derivative is for.
+   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
+   *   The module handler.
+   */
+  public function __construct($base_plugin_id, ModuleHandlerInterface $module_handler) {
+    $this->basePluginId = $base_plugin_id;
+    $this->moduleHandler = $module_handler;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, $base_plugin_id) {
+    return new static(
+      $base_plugin_id,
+      $container->get('module_handler')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getDerivativeDefinitions($base_plugin_definition) {
+    $pathauto_source = static::getSourcePlugin('pathauto_pattern');
+    assert($pathauto_source instanceof DrupalSqlBase);
+
+    try {
+      $pathauto_source->checkRequirements();
+    }
+    catch (RequirementsException $e) {
+      // If the source plugin requirements failed, that means we do not have a
+      // Drupal source database configured - there is nothing to generate.
+      return $this->derivatives;
+    }
+
+    $source_system_data = $pathauto_source->getSystemData();
+    $pathauto_entity_installed_on_source = !empty($source_system_data['module']['pathauto_entity']['status']);
+    $file_entity_installed_on_source = !empty($source_system_data['module']['file_entity']['status']);
+    $media_migration_available = $this->moduleHandler->moduleExists('media_migration');
+    if ($file_entity_installed_on_source && $media_migration_available) {
+      // If media_migration is installed, then file entities will be migrated
+      // to media entities.
+      $base_plugin_definition['process']['entity_type'][1]['map']['file'] = 'media';
+    }
+
+    try {
+      foreach ($pathauto_source as $pathauto_row) {
+        assert($pathauto_row instanceof Row);
+        $source = $pathauto_row->getSource();
+        $entity_type = $source['entity_type'];
+        $bundle = $source['bundle'] ?? NULL;
+        $derivative_id = $bundle
+          ? implode(PluginBase::DERIVATIVE_SEPARATOR, [
+            $entity_type,
+            $bundle,
+          ])
+          : "{$entity_type}_default";
+
+        if (
+          // Pathauto Entity adds pattern support for every (content) entity.
+          (
+            !$pathauto_entity_installed_on_source &&
+            !in_array($entity_type, ['node', 'taxonomy_term', 'user'], TRUE)
+          ) ||
+          // "file_entity" provides basic support for file pathauto patterns –
+          // this means that a default (bundle agnostic) pattern can be
+          // configured for files.
+          (
+            $entity_type === 'file' &&
+            !$file_entity_installed_on_source
+          )
+        ) {
+          continue;
+        }
+
+        $derivative_definition = $base_plugin_definition;
+        $derivative_definition['source']['entity_type'] = $entity_type;
+        $derivative_definition['source']['bundle'] = $bundle ?? FALSE;
+        $derivative_definition['label'] = $this->t('@label (@type - @bundle)', [
+          '@label' => $derivative_definition['label'],
+          '@type' => $entity_type,
+          '@bundle' => $bundle ?? 'default',
+        ]);
+
+        $migration_requirements = [];
+        if ($bundle) {
+          switch ($entity_type) {
+            case 'node':
+              $migration_requirements = ['d7_node_type'];
+              break;
+
+            case 'taxonomy_term':
+              $migration_requirements = ['d7_taxonomy_vocabulary'];
+              break;
+
+            case 'file':
+              // If media_migration is installed, then media bundles will be
+              // migrated by "d7_file_entity_type". But per-bundle patterns of
+              // "file" entities are functional on the source only if
+              // "pathauto_entity" was installed.
+              if ($pathauto_entity_installed_on_source && $media_migration_available) {
+                $migration_requirements = ['d7_file_entity_type'];
+              }
+              else {
+                continue 2;
+              }
+              break;
+
+            case 'comment':
+              $migration_requirements = ['d7_comment_type'];
+              break;
+          }
+        }
+        $derivative_definition['migration_dependencies']['optional'] = array_merge(
+          $derivative_definition['migration_dependencies']['optional'] ?? [],
+          $migration_requirements
+        );
+
+        $this->derivatives[$derivative_id] = $derivative_definition;
+      }
+    }
+    catch (DatabaseExceptionWrapper $e) {
+    }
+
+    return $this->derivatives;
+  }
+
+}
diff --git a/src/Plugin/migrate/process/PathautoForumVocabularyFallback.php b/src/Plugin/migrate/process/PathautoForumVocabularyFallback.php
new file mode 100644
index 0000000..bed7e2d
--- /dev/null
+++ b/src/Plugin/migrate/process/PathautoForumVocabularyFallback.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace Drupal\pathauto\Plugin\migrate\process;
+
+use Drupal\migrate\MigrateExecutableInterface;
+use Drupal\migrate\ProcessPluginBase;
+use Drupal\migrate\Row;
+
+/**
+ * Fallback plugin implementation for "pathauto_forum_vocabulary".
+ *
+ * If taxonomy module is enabled, this implementation is replaced with
+ * \Drupal\taxonomy\Plugin\migrate\process\ForumVocabulary.
+ *
+ * @MigrateProcessPlugin(
+ *   id = "pathauto_forum_vocabulary"
+ * )
+ */
+class PathautoForumVocabularyFallback extends ProcessPluginBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
+    return $value;
+  }
+
+}
diff --git a/src/Plugin/migrate/process/PathautoPatternLabel.php b/src/Plugin/migrate/process/PathautoPatternLabel.php
new file mode 100644
index 0000000..b842e1e
--- /dev/null
+++ b/src/Plugin/migrate/process/PathautoPatternLabel.php
@@ -0,0 +1,117 @@
+<?php
+
+namespace Drupal\pathauto\Plugin\migrate\process;
+
+use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\migrate\MigrateExecutableInterface;
+use Drupal\migrate\Plugin\MigrationInterface;
+use Drupal\migrate\ProcessPluginBase;
+use Drupal\migrate\Row;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Process plugin for pathauto pattern's label.
+ *
+ * @code
+ * process:
+ *   label:
+ *     plugin: pathauto_pattern_label
+ *     source:
+ *       - entity_type_id_value
+ *       - entity_bundle_value
+ * @endcode
+ *
+ * @MigrateProcessPlugin(
+ *   id = "pathauto_pattern_label"
+ * )
+ */
+class PathautoPatternLabel extends ProcessPluginBase implements ContainerFactoryPluginInterface {
+
+  /**
+   * The migration to be executed.
+   *
+   * @var \Drupal\migrate\Plugin\MigrationInterface
+   */
+  protected $migration;
+
+  /**
+   * The available entity type definitions.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeInterface[]
+   */
+  protected $entityTypeDefinitions;
+
+  /**
+   * The bundle info of all available entity types.
+   *
+   * @var array[]
+   */
+  protected $entityTypeBundleInfo;
+
+  /**
+   * Constructs a PathautoPatternLabel instance.
+   *
+   * @param array $configuration
+   *   A configuration array containing information about the plugin instance.
+   * @param string $plugin_id
+   *   The plugin_id for the plugin instance.
+   * @param mixed $plugin_definition
+   *   The plugin implementation definition.
+   * @param \Drupal\migrate\Plugin\MigrationInterface $migration
+   *   The Migration the plugin is being used in.
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The migrate lookup service.
+   * @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $entity_bundle_info
+   *   The migrate stub service.
+   */
+  public function __construct(array $configuration, $plugin_id, $plugin_definition, MigrationInterface $migration, EntityTypeManagerInterface $entity_type_manager, EntityTypeBundleInfoInterface $entity_bundle_info) {
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+    $this->migration = $migration;
+    $this->entityTypeDefinitions = $entity_type_manager->getDefinitions();
+    $this->entityTypeBundleInfo = $entity_bundle_info->getAllBundleInfo();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition, MigrationInterface $migration = NULL) {
+    return new static(
+      $configuration,
+      $plugin_id,
+      $plugin_definition,
+      $migration,
+      $container->get('entity_type.manager'),
+      $container->get('entity_type.bundle.info')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
+    $entity_type = ((array) $value)[0];
+    $bundle = ((array) $value)[1] ?? NULL;
+    $langcode = ((array) $value)[2] ?? NULL;
+
+    $entity_type_label = isset($this->entityTypeDefinitions[$entity_type])
+      ? (string) $this->entityTypeDefinitions[$entity_type]->getLabel()
+      : $entity_type;
+    $bundle_suffix = $bundle
+      ? $this->entityTypeBundleInfo[$entity_type][$bundle]['label'] ?? $bundle
+      : 'default';
+
+    $label = implode(' - ', [
+      $entity_type_label,
+      $bundle_suffix,
+    ]);
+
+    if (is_string($langcode)) {
+      $label .= " ($langcode)";
+    }
+
+    return $label;
+  }
+
+}
diff --git a/src/Plugin/migrate/process/PathautoPatternRelationships.php b/src/Plugin/migrate/process/PathautoPatternRelationships.php
new file mode 100644
index 0000000..b7ec74b
--- /dev/null
+++ b/src/Plugin/migrate/process/PathautoPatternRelationships.php
@@ -0,0 +1,115 @@
+<?php
+
+namespace Drupal\pathauto\Plugin\migrate\process;
+
+use Drupal\Component\Uuid\UuidInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\Core\Plugin\Context\ContextHandlerInterface;
+use Drupal\Core\Plugin\Context\ContextRepositoryInterface;
+use Drupal\migrate\MigrateExecutableInterface;
+use Drupal\migrate\MigrateSkipProcessException;
+use Drupal\migrate\Plugin\MigrationInterface;
+use Drupal\migrate\ProcessPluginBase;
+use Drupal\migrate\Row;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Process plugin for a pathauto pattern relationships.
+ *
+ * @code
+ * process:
+ *   relationships:
+ *     plugin: pathauto_pattern_relationships
+ *     source:
+ *       - entity_type_id_value
+ *       - langcode
+ * @endcode
+ *
+ * @MigrateProcessPlugin(
+ *   id = "pathauto_pattern_relationships"
+ * )
+ */
+class PathautoPatternRelationships extends ProcessPluginBase implements ContainerFactoryPluginInterface {
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * Constructs a PathautoPatternRelationships process plugin instance.
+   *
+   * @param array $configuration
+   *   A configuration array containing information about the plugin instance.
+   * @param string $plugin_id
+   *   The plugin_id for the plugin instance.
+   * @param mixed $plugin_definition
+   *   The plugin implementation definition.
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   */
+  public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager) {
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+    $this->entityTypeManager = $entity_type_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition, MigrationInterface $migration = NULL) {
+    return new static(
+      $configuration,
+      $plugin_id,
+      $plugin_definition,
+      $container->get('entity_type.manager')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * @see \Drupal\pathauto\Entity\PathautoPattern::addRelationship()
+   */
+  public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
+    $value = (array) $value;
+    if (count($value) < 2) {
+      return [];
+    }
+
+    [
+      $entity_type,
+      $langcode,
+    ] = $value;
+
+    if (empty($langcode)) {
+      return [];
+    }
+
+    if (!is_string($entity_type)) {
+      throw new MigrateSkipProcessException('The entity_type must be a string.');
+    }
+    if (!($this->entityTypeManager->hasDefinition($entity_type))) {
+      throw new MigrateSkipProcessException(sprintf("The '%s' entity type does not exist.", $entity_type));
+    }
+    if (!is_string($langcode)) {
+      throw new MigrateSkipProcessException('The language code must be a string.');
+    }
+
+    // Variable copied from \Drupal\pathauto\Form\PatternEditForm...
+    $language_mapping = implode(':', [
+      $entity_type,
+      $this->entityTypeManager->getDefinition($entity_type)->getKey('langcode'),
+      'language',
+    ]);
+
+    return [
+      $language_mapping => [
+        'label' => 'Language',
+      ],
+    ];
+  }
+
+}
diff --git a/src/Plugin/migrate/process/PathautoPatternSelectionCriteria.php b/src/Plugin/migrate/process/PathautoPatternSelectionCriteria.php
new file mode 100644
index 0000000..805c433
--- /dev/null
+++ b/src/Plugin/migrate/process/PathautoPatternSelectionCriteria.php
@@ -0,0 +1,141 @@
+<?php
+
+namespace Drupal\pathauto\Plugin\migrate\process;
+
+use Drupal\Component\Uuid\UuidInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\migrate\MigrateExecutableInterface;
+use Drupal\migrate\MigrateSkipProcessException;
+use Drupal\migrate\Plugin\MigrationInterface;
+use Drupal\migrate\ProcessPluginBase;
+use Drupal\migrate\Row;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Process plugin for a pathauto pattern's selection criteria.
+ *
+ * @code
+ * process:
+ *   selection_criteria:
+ *     plugin: pathauto_pattern_selection_criteria
+ *     source:
+ *       - entity_type_id_value
+ *       - entity_bundle_value
+ *       - langcode
+ * @endcode
+ *
+ * @MigrateProcessPlugin(
+ *   id = "pathauto_pattern_selection_criteria"
+ * )
+ */
+class PathautoPatternSelectionCriteria extends ProcessPluginBase implements ContainerFactoryPluginInterface {
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The UUID service.
+   *
+   * @var \Drupal\Component\Uuid\UuidInterface
+   */
+  protected $uuidService;
+
+  /**
+   * Constructs a PathautoPatternSelectionCriteria instance.
+   *
+   * @param array $configuration
+   *   A configuration array containing information about the plugin instance.
+   * @param string $plugin_id
+   *   The plugin_id for the plugin instance.
+   * @param mixed $plugin_definition
+   *   The plugin implementation definition.
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   * @param \Drupal\Component\Uuid\UuidInterface $uuid_service
+   *   The UUID service.
+   */
+  public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, UuidInterface $uuid_service) {
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+    $this->entityTypeManager = $entity_type_manager;
+    $this->uuidService = $uuid_service;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition, MigrationInterface $migration = NULL) {
+    return new static(
+      $configuration,
+      $plugin_id,
+      $plugin_definition,
+      $container->get('entity_type.manager'),
+      $container->get('uuid')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
+    $value = (array) $value;
+    if (count($value) < 3) {
+      throw new MigrateSkipProcessException('The entity_type, the bundle, the langcode or more of these sources are missing.');
+    }
+
+    [
+      $entity_type,
+      $bundle,
+      $langcode,
+    ] = $value;
+
+    if (!is_string($entity_type)) {
+      throw new MigrateSkipProcessException('The entity_type must be a string.');
+    }
+
+    if (!($this->entityTypeManager->hasDefinition($entity_type))) {
+      throw new MigrateSkipProcessException(sprintf("The '%s' entity type does not exist.", $entity_type));
+    }
+
+    $selection_criteria = [];
+
+    if (is_string($bundle)) {
+      $uuid = $this->uuidService->generate();
+      $selection_criteria[$uuid] = [
+        'uuid' => $uuid,
+        'id' => ($entity_type == 'node') ? 'entity_bundle:node' : 'entity_bundle:' . $entity_type,
+        'bundles' => [$bundle => $bundle],
+        'negate' => FALSE,
+        'context_mapping' => [$entity_type => $entity_type],
+      ];
+    }
+
+    if (is_string($langcode)) {
+      $uuid = $this->uuidService->generate();
+      // Variable copied from \Drupal\pathauto\Form\PatternEditForm...
+      $language_mapping = implode(':', [
+        $entity_type,
+        $this->entityTypeManager->getDefinition($entity_type)->getKey('langcode'),
+        'language',
+      ]);
+      $selection_criteria[$uuid] = [
+        'uuid' => $uuid,
+        'id' => 'language',
+        'langcodes' => [
+          $langcode => $langcode,
+        ],
+        'negate' => FALSE,
+        'context_mapping' => [
+          'language' => $language_mapping,
+        ],
+      ];
+    }
+
+    return $selection_criteria;
+  }
+
+}
diff --git a/src/Plugin/migrate/source/PathautoPattern.php b/src/Plugin/migrate/source/PathautoPattern.php
index c30bdde..017eb25 100644
--- a/src/Plugin/migrate/source/PathautoPattern.php
+++ b/src/Plugin/migrate/source/PathautoPattern.php
@@ -2,9 +2,12 @@
 
 namespace Drupal\pathauto\Plugin\migrate\source;
 
-use Drupal\Core\Entity\EntityTypeBundleInfo;
+use Drupal\Core\Entity\ContentEntityTypeInterface;
 use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
 use Drupal\Core\State\StateInterface;
+use Drupal\migrate\Plugin\MigrationDeriverTrait;
 use Drupal\migrate\Plugin\MigrationInterface;
 use Drupal\migrate\Row;
 use Drupal\migrate_drupal\Plugin\migrate\source\DrupalSqlBase;
@@ -18,21 +21,28 @@ use Symfony\Component\DependencyInjection\ContainerInterface;
  *   source_module = "pathauto",
  * )
  */
-class PathautoPattern extends DrupalSqlBase {
+class PathautoPattern extends DrupalSqlBase implements ContainerFactoryPluginInterface {
+
+  use MigrationDeriverTrait;
 
   /**
-   * The entity type bundle info.
+   * The module handler.
    *
-   * @var \Drupal\Core\Entity\EntityTypeBundleInfo
+   * @var \Drupal\Core\Extension\ModuleHandlerInterface
    */
-  protected $entityTypeBundleInfo;
+  protected $moduleHandler;
 
   /**
    * {@inheritdoc}
    */
-  public function __construct(array $configuration, $plugin_id, $plugin_definition, MigrationInterface $migration, StateInterface $state, EntityTypeManagerInterface $entity_type_manager, EntityTypeBundleInfo $entity_bundle_info) {
+  public function __construct(array $configuration, $plugin_id, $plugin_definition, MigrationInterface $migration, StateInterface $state, EntityTypeManagerInterface $entity_type_manager, ModuleHandlerInterface $module_handler) {
+    $configuration += [
+      'entity_type' => NULL,
+      'bundle' => NULL,
+    ];
     parent::__construct($configuration, $plugin_id, $plugin_definition, $migration, $state, $entity_type_manager);
-    $this->entityTypeBundleInfo = $entity_bundle_info;
+
+    $this->moduleHandler = $module_handler;
   }
 
   /**
@@ -46,7 +56,7 @@ class PathautoPattern extends DrupalSqlBase {
       $migration,
       $container->get('state'),
       $container->get('entity_type.manager'),
-      $container->get('entity_type.bundle.info')
+      $container->get('module_handler')
     );
   }
 
@@ -54,11 +64,187 @@ class PathautoPattern extends DrupalSqlBase {
    * {@inheritdoc}
    */
   public function query() {
+    [
+      'entity_type' => $entity_type,
+      'bundle' => $bundle,
+    ] = $this->configuration;
     // Fetch all pattern variables whose value is not a serialized empty string.
-    return $this->select('variable', 'v')
+    $query = $this->select('variable', 'v')
       ->fields('v', ['name', 'value'])
-      ->condition('name', 'pathauto_%_pattern', 'LIKE')
-      ->condition('value', 's:0:"";', '<>');
+      ->condition('value', serialize(''), '<>');
+
+    // Exclude forum pattern if forum wasn't enabled on the source site.
+    if (!$this->moduleExists('forum')) {
+      $query->condition('name', 'pathauto_forum_pattern', '<>');
+    }
+
+    if (!$entity_type) {
+      if ($bundle) {
+        throw new \LogicException(sprintf('If "bundle" configuration is set for %s migration source plugin, "entity_type" configuration must also be defined.', get_class($this)));
+      }
+      // Fetch every pattern variable.
+      $query->condition('name', 'pathauto_%_pattern', 'LIKE');
+    }
+    else {
+      $forum_vocabulary = $this->getForumTaxonomyVocabularyMachineName();
+      $pattern_id = $entity_type === 'taxonomy_term' && !empty($bundle) && $forum_vocabulary === $bundle
+        ? 'forum'
+        : implode('_', array_filter([
+          $entity_type,
+          $bundle,
+        ]));
+
+      // Entity types might have multilingual patterns.
+      if ($bundle) {
+        // For "node" entity type, the following conditions should match
+        // "pathauto_node_foo_pattern", "pathauto_node_foo_en_pattern" and
+        // "pathauto_node_foo_fr_pattern" where "foo" is the node type (bundle).
+        $query->condition('name', "pathauto_{$pattern_id}%_pattern", 'LIKE');
+        $query->condition('name', "pathauto_{$pattern_id}_%pattern", 'LIKE');
+      }
+      else {
+        $or_group = $query->orConditionGroup()
+          ->condition('name', "pathauto_{$entity_type}_%_pattern", 'LIKE')
+          ->condition('name', "pathauto_{$entity_type}_pattern");
+        $query->condition($or_group);
+      }
+    }
+
+    return $query;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function initializeIterator() {
+    $results = $this->prepareQuery()->execute()->fetchAll();
+    $forum_vocabulary = $this->getForumTaxonomyVocabularyMachineName();
+    // See Drupal 7 locale_language_list() and language_list().
+    $languages = [];
+    if ($this->moduleExists('locale')) {
+      try {
+        $language_source = static::getSourcePlugin('language');
+        $language_source->checkRequirements();
+        foreach ($language_source as $language_source_row) {
+          assert($language_source_row instanceof Row);
+          $langcode = $language_source_row->getSourceProperty('language');
+          $languages[] = $langcode;
+        }
+        $languages = array_unique(
+          array_merge(
+            $languages,
+            ['und']
+          )
+        );
+      }
+      catch (\Exception $e) {
+      }
+    }
+
+    $rows = [];
+    foreach ($results as $result) {
+      preg_match('/^pathauto_(.+)_pattern$/', $result['name'], $matches);
+      $row = $result + [
+        'id' => $matches[1],
+        'forum_vocabulary' => $matches[1] === 'forum' && $forum_vocabulary,
+      ];
+
+      if ($forum_vocabulary && $row['id'] === 'forum') {
+        $row += [
+          'entity_type' => 'taxonomy_term',
+          'bundle' => $forum_vocabulary,
+        ];
+      }
+      elseif ($forum_vocabulary && $row['id'] === "taxonomy_term_$forum_vocabulary") {
+        // This pattern wasn't used by Drupal 7 pathauto.
+        // @see https://git.drupalcode.org/project/pathauto/-/blob/7.x-1.x/pathauto.module#L825
+        continue;
+      }
+      else {
+        // Try to determine the destination entity_type.
+        $variable_id_parts = explode('_', $row['id']);
+        $provisioned_entity_type_id_parts = [];
+        // This loop tries to find the destination entity type. If the ID is
+        // "taxonomy_term_tags", then for first, it tries to find a content
+        // entity definition with ID "taxonomy", then "taxonomy_term", and
+        // finally "taxonomy_term_tags".
+        foreach ($variable_id_parts as $variable_id_part) {
+          $provisioned_entity_type_id_parts[] = $variable_id_part;
+          $provisioned_entity_type_id = implode('_', $provisioned_entity_type_id_parts);
+
+          // File entity may have pattern if "pathauto_entity" was enabled on
+          // the source. If "media" is installed, the pathauto patterm migration
+          // will map these patterns to media entity types.
+          if ($provisioned_entity_type_id === 'file' && $this->moduleHandler->moduleExists('media_migration')) {
+            $row['entity_type'] = 'file';
+            break;
+          }
+          elseif ($this->entityTypeManager->getDefinition($provisioned_entity_type_id, FALSE) instanceof ContentEntityTypeInterface) {
+            $row['entity_type'] = $provisioned_entity_type_id;
+            break;
+          }
+        }
+
+        // If entity type was indeterminable, skip this row. This will happen
+        // with the "blog" pattern: in Drupal 8 or Drupal 9, no equivalent
+        // "thing" exists for this where the "pathauto_blog_pattern" could be
+        // used.
+        if (empty($row['entity_type'])) {
+          continue;
+        }
+
+        // If entity type was determined, try to get the bundle and the language
+        // code as well.
+        if (
+          $row['entity_type'] !== $row['id'] &&
+          strpos($row['id'], $row['entity_type'] . '_') === 0
+        ) {
+          $bundle_and_language = substr($row['id'], strlen($row['entity_type']) + 1);
+
+          $langcode = NULL;
+          foreach ($languages as $available_langcode) {
+            if (preg_match("/^(.+)_{$available_langcode}$/", $bundle_and_language, $langcode_matches)) {
+              $langcode = $available_langcode;
+              break 1;
+            }
+          }
+
+          if (is_string($langcode)) {
+            $substr_length = (strlen($langcode) + 1) * -1;
+            $row['bundle'] = substr($bundle_and_language, 0, $substr_length);
+            $row['langcode'] = $langcode;
+          }
+          else {
+            $row['bundle'] = $bundle_and_language;
+          }
+        }
+      }
+
+      // If the current 'bundle' config is FALSE, then we want to skip results
+      // which have bundle value, because this derivative should only migrate
+      // the entity type's default (fallback) pattern.
+      if ($this->configuration['bundle'] === FALSE && !empty($row['bundle'])) {
+        continue;
+      }
+
+      // "Default" patterns (which do not have entity bundle restriction) should
+      // get higher weight to act as a fallback. Patterns with language (and
+      // bundle) restriction should prioritized over every other pattern.
+      $weight = 1;
+      if (!empty($row['bundle'])) {
+        $weight--;
+        // With Drupal 7 Pathauto, language specific patterns only work for
+        // bundle-restricted patterns.
+        if (!empty($row['langcode'])) {
+          $weight--;
+        }
+      }
+      $rows[] = $row + [
+        'weight' => $weight,
+      ];
+    }
+
+    return new \ArrayIterator($rows);
   }
 
   /**
@@ -76,58 +262,48 @@ class PathautoPattern extends DrupalSqlBase {
     return [
       'name' => $this->t("The name of the pattern's variable."),
       'value' => $this->t("The value of the pattern's variable."),
+      'id' => $this->t('The ID of the destination pathauto pattern. This is the name without the "pathauto_" prefix and "_pattern" suffix'),
+      'entity_type' => $this->t('The provisioned destination entity type ID of the pattern.'),
+      'bundle' => $this->t('The provisioned destination entity bundle of the pattern, if any.'),
+      'forum_vocabulary' => $this->t('Whether the current pattern belongs to the forum taxonomy vocabulary.'),
+      'weight' => $this->t('The weight of the pattern'),
+      'langcode' => $this->t('The language code (language ID) of the pattern'),
     ];
   }
 
   /**
    * {@inheritdoc}
    */
-  public function prepareRow(Row $row) {
-    $entity_definitions = $this->entityTypeManager->getDefinitions();
-    $name = $row->getSourceProperty('name');
-    // Pattern variables are made of pathauto_[entity type]_[bundle]_pattern.
-    // First let's find a matching entity type from the variable name.
-    foreach ($entity_definitions as $entity_type => $definition) {
-      // Check if this is the default pattern for this entity type.
-      // Otherwise, check if this is a pattern for a specific bundle.
-      if ($name == 'pathauto_' . $entity_type . '_pattern') {
-        // Set process values.
-        $row->setSourceProperty('id', $entity_type);
-        $row->setSourceProperty('label', (string) $definition->getLabel() . ' - default');
-        $row->setSourceProperty('type', 'canonical_entities:' . $entity_type);
-        $row->setSourceProperty('pattern', unserialize($row->getSourceProperty('value')));
-        return parent::prepareRow($row);
-      }
-      elseif (strpos($name, 'pathauto_' . $entity_type . '_') === 0) {
-        // Extract the bundle out of the variable name.
-        preg_match('/^pathauto_' . $entity_type . '_([a-zA-z0-9_]+)_pattern$/', $name, $matches);
-        $bundle = $matches[1];
-
-        // Check that the bundle exists.
-        $bundles = $this->entityTypeBundleInfo->getBundleInfo($entity_type);
-        if (!in_array($bundle, array_keys($bundles))) {
-          // No matching bundle found in destination.
-          return FALSE;
-        }
+  public function count($refresh = FALSE) {
+    // The number of variables fetched with query() does not match the number of
+    // rows to migrate.
+    return (int) $this->initializeIterator()->count();
+  }
 
-        // Set process values.
-        $row->setSourceProperty('id', $entity_type . '_' . $bundle);
-        $row->setSourceProperty('label', (string) $definition->getLabel() . ' - ' . $bundles[$bundle]['label']);
-        $row->setSourceProperty('type', 'canonical_entities:' . $entity_type);
-        $row->setSourceProperty('pattern', unserialize($row->getSourceProperty('value')));
-
-        $selection_criteria = [
-          'id' => 'entity_bundle:' . $entity_type,
-          'bundles' => [$bundle => $bundle],
-          'negate' => FALSE,
-          'context_mapping' => [$entity_type => $entity_type],
-        ];
-        $row->setSourceProperty('selection_criteria', [$selection_criteria]);
-        return parent::prepareRow($row);
+  /**
+   * Returns the machine name of the forum navigation taxonomy on the source.
+   *
+   * @return string|null
+   *   The machine name of the forum navigation taxonomy on the source.
+   */
+  protected function getForumTaxonomyVocabularyMachineName() {
+    $forum_vocabulary = FALSE;
+    if ($this->moduleExists('taxonomy') && $this->moduleExists('forum')) {
+      $forum_vocabulary_id = $this->variableGet('forum_nav_vocabulary', NULL);
+      try {
+        if ($forum_vocabulary_id !== NULL) {
+          $forum_vocabulary = $this->select('taxonomy_vocabulary', 'tv')
+            ->fields('tv', ['machine_name'])
+            ->condition('tv.vid', $forum_vocabulary_id)
+            ->execute()
+            ->fetchField();
+        }
+      }
+      catch (\Exception $e) {
       }
     }
 
-    return FALSE;
+    return $forum_vocabulary ? $forum_vocabulary : NULL;
   }
 
 }
diff --git a/tests/fixtures/drupal7.php b/tests/fixtures/drupal7.php
new file mode 100644
index 0000000..7eb4342
--- /dev/null
+++ b/tests/fixtures/drupal7.php
@@ -0,0 +1,458 @@
+<?php
+
+/**
+ * @file
+ * A database agnostic dump for testing purposes.
+ */
+
+use Drupal\Core\Database\Database;
+
+$connection = Database::getConnection();
+
+$connection->schema()->createTable('pathauto_state', [
+  'fields' => [
+    'entity_type' => [
+      'type' => 'varchar',
+      'not null' => TRUE,
+      'length' => '32',
+    ],
+    'entity_id' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'normal',
+      'unsigned' => TRUE,
+    ],
+    'pathauto' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'tiny',
+      'default' => '0',
+    ],
+  ],
+  'primary key' => [
+    'entity_type',
+    'entity_id',
+  ],
+  'mysql_character_set' => 'utf8',
+]);
+
+$connection->insert('pathauto_state')
+  ->fields([
+    'entity_type',
+    'entity_id',
+    'pathauto',
+  ])
+  ->values([
+    'entity_type' => 'taxonomy_term',
+    'entity_id' => '2',
+    'pathauto' => '0',
+  ])
+  ->values([
+    'entity_type' => 'taxonomy_term',
+    'entity_id' => '3',
+    'pathauto' => '0',
+  ])
+  ->values([
+    'entity_type' => 'taxonomy_term',
+    'entity_id' => '4',
+    'pathauto' => '0',
+  ])
+  ->values([
+    'entity_type' => 'taxonomy_term',
+    'entity_id' => '11',
+    'pathauto' => '1',
+  ])
+  ->values([
+    'entity_type' => 'node',
+    'entity_id' => '2',
+    'pathauto' => '0',
+  ])
+  ->values([
+    'entity_type' => 'node',
+    'entity_id' => '3',
+    'pathauto' => '0',
+  ])
+  ->values([
+    'entity_type' => 'node',
+    'entity_id' => '4',
+    'pathauto' => '0',
+  ])
+  ->values([
+    'entity_type' => 'node',
+    'entity_id' => '5',
+    'pathauto' => '0',
+  ])
+  ->values([
+    'entity_type' => 'node',
+    'entity_id' => '9',
+    'pathauto' => '0',
+  ])
+  ->values([
+    'entity_type' => 'node',
+    'entity_id' => '11',
+    'pathauto' => '1',
+  ])
+  ->execute();
+
+$connection->insert('variable')
+  ->fields([
+    'name',
+    'value',
+  ])
+  ->values([
+    'name' => 'pathauto_blog_pattern',
+    'value' => 's:17:"blogs/[user:name]";',
+  ])
+  ->values([
+    'name' => 'pathauto_case',
+    'value' => 's:1:"1";',
+  ])
+  ->values([
+    'name' => 'pathauto_forum_pattern',
+    'value' => 's:29:"[term:vocabulary]/[term:name]";',
+  ])
+  ->values([
+    'name' => 'pathauto_ignore_words',
+    'value' => 's:134:"a, an, as, at, before, but, by, for, from, is, in, into, like, of, off, on, onto, per, since, than, the, this, that, to, up, via, with";',
+  ])
+  ->values([
+    'name' => 'pathauto_max_component_length',
+    'value' => 's:3:"100";',
+  ])
+  ->values([
+    'name' => 'pathauto_max_length',
+    'value' => 's:3:"100";',
+  ])
+  ->values([
+    'name' => 'pathauto_node_article_en_pattern',
+    'value' => 's:35:"[node:content-type]/en/[node:title]";',
+  ])
+  ->values([
+    'name' => 'pathauto_node_article_fr_pattern',
+    'value' => 's:35:"[node:content-type]/fr/[node:title]";',
+  ])
+  ->values([
+    'name' => 'pathauto_node_article_is_pattern',
+    'value' => 's:35:"[node:content-type]/is/[node:title]";',
+  ])
+  ->values([
+    'name' => 'pathauto_node_article_pattern',
+    'value' => 's:32:"[node:content-type]/[node:title]";',
+  ])
+  ->values([
+    'name' => 'pathauto_node_article_und_pattern',
+    'value' => 's:0:"";',
+  ])
+  ->values([
+    'name' => 'pathauto_node_blog_en_pattern',
+    'value' => 's:0:"";',
+  ])
+  ->values([
+    'name' => 'pathauto_node_blog_fr_pattern',
+    'value' => 's:0:"";',
+  ])
+  ->values([
+    'name' => 'pathauto_node_blog_is_pattern',
+    'value' => 's:0:"";',
+  ])
+  ->values([
+    'name' => 'pathauto_node_blog_pattern',
+    'value' => 's:37:"blogs/[node:author:name]/[node:title]";',
+  ])
+  ->values([
+    'name' => 'pathauto_node_blog_und_pattern',
+    'value' => 's:0:"";',
+  ])
+  ->values([
+    'name' => 'pathauto_node_book_pattern',
+    'value' => 's:0:"";',
+  ])
+  ->values([
+    'name' => 'pathauto_node_et_en_pattern',
+    'value' => 's:0:"";',
+  ])
+  ->values([
+    'name' => 'pathauto_node_et_fr_pattern',
+    'value' => 's:0:"";',
+  ])
+  ->values([
+    'name' => 'pathauto_node_et_is_pattern',
+    'value' => 's:0:"";',
+  ])
+  ->values([
+    'name' => 'pathauto_node_et_pattern',
+    'value' => 's:43:"[node:content-type]/[node:nid]/[node:title]";',
+  ])
+  ->values([
+    'name' => 'pathauto_node_et_und_pattern',
+    'value' => 's:0:"";',
+  ])
+  ->values([
+    'name' => 'pathauto_node_forum_pattern',
+    'value' => 's:0:"";',
+  ])
+  ->values([
+    'name' => 'pathauto_node_page_en_pattern',
+    'value' => 's:0:"";',
+  ])
+  ->values([
+    'name' => 'pathauto_node_page_fr_pattern',
+    'value' => 's:0:"";',
+  ])
+  ->values([
+    'name' => 'pathauto_node_page_is_pattern',
+    'value' => 's:0:"";',
+  ])
+  ->values([
+    'name' => 'pathauto_node_page_pattern',
+    'value' => 's:0:"";',
+  ])
+  ->values([
+    'name' => 'pathauto_node_page_und_pattern',
+    'value' => 's:0:"";',
+  ])
+  ->values([
+    'name' => 'pathauto_node_pattern',
+    'value' => 's:12:"[node:title]";',
+  ])
+  ->values([
+    'name' => 'pathauto_node_test_content_type_en_pattern',
+    'value' => 's:0:"";',
+  ])
+  ->values([
+    'name' => 'pathauto_node_test_content_type_fr_pattern',
+    'value' => 's:0:"";',
+  ])
+  ->values([
+    'name' => 'pathauto_node_test_content_type_is_pattern',
+    'value' => 's:0:"";',
+  ])
+  ->values([
+    'name' => 'pathauto_node_test_content_type_pattern',
+    'value' => 's:0:"";',
+  ])
+  ->values([
+    'name' => 'pathauto_node_test_content_type_und_pattern',
+    'value' => 's:0:"";',
+  ])
+  ->values([
+    'name' => 'pathauto_punctuation_ampersand',
+    'value' => 's:1:"0";',
+  ])
+  ->values([
+    'name' => 'pathauto_punctuation_asterisk',
+    'value' => 's:1:"0";',
+  ])
+  ->values([
+    'name' => 'pathauto_punctuation_at',
+    'value' => 's:1:"0";',
+  ])
+  ->values([
+    'name' => 'pathauto_punctuation_backtick',
+    'value' => 's:1:"0";',
+  ])
+  ->values([
+    'name' => 'pathauto_punctuation_back_slash',
+    'value' => 's:1:"0";',
+  ])
+  ->values([
+    'name' => 'pathauto_punctuation_caret',
+    'value' => 's:1:"0";',
+  ])
+  ->values([
+    'name' => 'pathauto_punctuation_colon',
+    'value' => 's:1:"0";',
+  ])
+  ->values([
+    'name' => 'pathauto_punctuation_comma',
+    'value' => 's:1:"0";',
+  ])
+  ->values([
+    'name' => 'pathauto_punctuation_dollar',
+    'value' => 's:1:"0";',
+  ])
+  ->values([
+    'name' => 'pathauto_punctuation_double_quotes',
+    'value' => 's:1:"0";',
+  ])
+  ->values([
+    'name' => 'pathauto_punctuation_equal',
+    'value' => 's:1:"0";',
+  ])
+  ->values([
+    'name' => 'pathauto_punctuation_exclamation',
+    'value' => 's:1:"0";',
+  ])
+  ->values([
+    'name' => 'pathauto_punctuation_greater_than',
+    'value' => 's:1:"0";',
+  ])
+  ->values([
+    'name' => 'pathauto_punctuation_hash',
+    'value' => 's:1:"0";',
+  ])
+  ->values([
+    'name' => 'pathauto_punctuation_hyphen',
+    'value' => 's:1:"1";',
+  ])
+  ->values([
+    'name' => 'pathauto_punctuation_left_curly',
+    'value' => 's:1:"0";',
+  ])
+  ->values([
+    'name' => 'pathauto_punctuation_left_parenthesis',
+    'value' => 's:1:"0";',
+  ])
+  ->values([
+    'name' => 'pathauto_punctuation_left_square',
+    'value' => 's:1:"0";',
+  ])
+  ->values([
+    'name' => 'pathauto_punctuation_less_than',
+    'value' => 's:1:"0";',
+  ])
+  ->values([
+    'name' => 'pathauto_punctuation_percent',
+    'value' => 's:1:"0";',
+  ])
+  ->values([
+    'name' => 'pathauto_punctuation_period',
+    'value' => 's:1:"0";',
+  ])
+  ->values([
+    'name' => 'pathauto_punctuation_pipe',
+    'value' => 's:1:"0";',
+  ])
+  ->values([
+    'name' => 'pathauto_punctuation_plus',
+    'value' => 's:1:"0";',
+  ])
+  ->values([
+    'name' => 'pathauto_punctuation_question_mark',
+    'value' => 's:1:"0";',
+  ])
+  ->values([
+    'name' => 'pathauto_punctuation_quotes',
+    'value' => 's:1:"0";',
+  ])
+  ->values([
+    'name' => 'pathauto_punctuation_right_curly',
+    'value' => 's:1:"0";',
+  ])
+  ->values([
+    'name' => 'pathauto_punctuation_right_parenthesis',
+    'value' => 's:1:"0";',
+  ])
+  ->values([
+    'name' => 'pathauto_punctuation_right_square',
+    'value' => 's:1:"0";',
+  ])
+  ->values([
+    'name' => 'pathauto_punctuation_semicolon',
+    'value' => 's:1:"0";',
+  ])
+  ->values([
+    'name' => 'pathauto_punctuation_slash',
+    'value' => 's:1:"0";',
+  ])
+  ->values([
+    'name' => 'pathauto_punctuation_tilde',
+    'value' => 's:1:"0";',
+  ])
+  ->values([
+    'name' => 'pathauto_punctuation_underscore',
+    'value' => 's:1:"0";',
+  ])
+  ->values([
+    'name' => 'pathauto_reduce_ascii',
+    'value' => 'i:0;',
+  ])
+  ->values([
+    'name' => 'pathauto_separator',
+    'value' => 's:1:"-";',
+  ])
+  ->values([
+    'name' => 'pathauto_taxonomy_term_sujet_de_discussion_pattern',
+    'value' => 's:0:"";',
+  ])
+  ->values([
+    'name' => 'pathauto_taxonomy_term_tags_pattern',
+    'value' => 's:15:"tag/[term:name]";',
+  ])
+  ->values([
+    'name' => 'pathauto_taxonomy_term_test_vocabulary_pattern',
+    'value' => 's:0:"";',
+  ])
+  ->values([
+    'name' => 'pathauto_taxonomy_term_vocabfixed_pattern',
+    'value' => 's:0:"";',
+  ])
+  ->values([
+    'name' => 'pathauto_taxonomy_term_vocablocalized_pattern',
+    'value' => 's:0:"";',
+  ])
+  ->values([
+    'name' => 'pathauto_taxonomy_term_vocabtranslate_pattern',
+    'value' => 's:0:"";',
+  ])
+  ->values([
+    'name' => 'pathauto_taxonomy_term_vocabulary_name_much_longer_than_thirty_two_characters_pattern',
+    'value' => 's:0:"";',
+  ])
+  ->values([
+    'name' => 'pathauto_taxonomy_term_pattern',
+    'value' => 's:29:"[term:vocabulary]/[term:name]";',
+  ])
+  ->values([
+    'name' => 'pathauto_transliterate',
+    'value' => 'i:1;',
+  ])
+  ->values([
+    'name' => 'pathauto_update_action',
+    'value' => 's:1:"2";',
+  ])
+  ->values([
+    'name' => 'pathauto_user_pattern',
+    'value' => 's:17:"users/[user:name]";',
+  ])
+  ->values([
+    'name' => 'pathauto_verbose',
+    'value' => 'i:0;',
+  ])
+  ->execute();
+
+$connection->insert('system')
+  ->fields([
+    'filename',
+    'name',
+    'type',
+    'owner',
+    'status',
+    'bootstrap',
+    'schema_version',
+    'weight',
+    'info',
+  ])
+  ->values([
+    'filename' => 'sites/all/modules/pathauto/pathauto.module',
+    'name' => 'pathauto',
+    'type' => 'module',
+    'owner' => '',
+    'status' => '1',
+    'bootstrap' => '0',
+    'schema_version' => '7006',
+    'weight' => '1',
+    'info' => 'a:14:{s:4:"name";s:8:"Pathauto";s:11:"description";s:95:"Provides a mechanism for modules to automatically generate aliases for the content they manage.";s:12:"dependencies";a:2:{i:0;s:4:"path";i:1;s:5:"token";}s:4:"core";s:3:"7.x";s:5:"files";a:2:{i:0;s:20:"pathauto.migrate.inc";i:1;s:13:"pathauto.test";}s:9:"configure";s:33:"admin/config/search/path/patterns";s:10:"recommends";a:1:{i:0;s:8:"redirect";}s:7:"version";s:7:"7.x-1.3";s:7:"project";s:8:"pathauto";s:9:"datestamp";s:10:"1444232655";s:5:"mtime";i:1561521861;s:7:"package";s:5:"Other";s:3:"php";s:5:"5.2.4";s:9:"bootstrap";i:0;}',
+  ])
+  ->values([
+    'filename' => 'sites/all/modules/token/token.module',
+    'name' => 'token',
+    'type' => 'module',
+    'owner' => '',
+    'status' => '1',
+    'bootstrap' => '0',
+    'schema_version' => '7000',
+    'weight' => '0',
+    'info' => 'a:12:{s:4:"name";s:7:"Tracker";s:11:"description";s:45:"Enables tracking of recent content for users.";s:12:"dependencies";a:1:{i:0;s:7:"comment";}s:7:"package";s:4:"Core";s:7:"version";s:4:"7.61";s:4:"core";s:3:"7.x";s:5:"files";a:1:{i:0;s:12:"tracker.test";}s:7:"project";s:6:"drupal";s:9:"datestamp";s:10:"1541684322";s:5:"mtime";i:1541684322;s:3:"php";s:5:"5.2.4";s:9:"bootstrap";i:0;}',
+  ])
+  ->execute();
diff --git a/tests/modules/pathauto_test_uuid_generator/pathauto_test_uuid_generator.info.yml b/tests/modules/pathauto_test_uuid_generator/pathauto_test_uuid_generator.info.yml
new file mode 100644
index 0000000..5951ae7
--- /dev/null
+++ b/tests/modules/pathauto_test_uuid_generator/pathauto_test_uuid_generator.info.yml
@@ -0,0 +1,3 @@
+name: 'UUID generator for pathauto migration tests'
+type: module
+package: Testing
diff --git a/tests/modules/pathauto_test_uuid_generator/src/PathautoTestUuidGeneratorServiceProvider.php b/tests/modules/pathauto_test_uuid_generator/src/PathautoTestUuidGeneratorServiceProvider.php
new file mode 100644
index 0000000..e0d9b65
--- /dev/null
+++ b/tests/modules/pathauto_test_uuid_generator/src/PathautoTestUuidGeneratorServiceProvider.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace Drupal\pathauto_test_uuid_generator;
+
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\Core\DependencyInjection\ServiceModifierInterface;
+use Symfony\Component\DependencyInjection\Reference;
+
+/**
+ * Changes the UUID service to a generator with predictable results.
+ */
+class PathautoTestUuidGeneratorServiceProvider implements ServiceModifierInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function alter(ContainerBuilder $container) {
+    if ($container->has('uuid')) {
+      $container->getDefinition('uuid')
+        ->setClass(UuidTestGenerator::class)
+        ->addArgument(new Reference('state'));
+    }
+  }
+
+}
diff --git a/tests/modules/pathauto_test_uuid_generator/src/UuidTestGenerator.php b/tests/modules/pathauto_test_uuid_generator/src/UuidTestGenerator.php
new file mode 100644
index 0000000..7a340a8
--- /dev/null
+++ b/tests/modules/pathauto_test_uuid_generator/src/UuidTestGenerator.php
@@ -0,0 +1,83 @@
+<?php
+
+namespace Drupal\pathauto_test_uuid_generator;
+
+use Drupal\Component\Uuid\Php as DefaultGenerator;
+use Drupal\Core\State\StateInterface;
+
+/**
+ * A predictable UUID generator.
+ */
+class UuidTestGenerator extends DefaultGenerator {
+
+  /**
+   * Key of the state storing how many times a predictable UUID was generated.
+   *
+   * @const string
+   */
+  const LAST_SUFFIX_STATE_KEY = 'pathauto_test_uuid_generator.last';
+
+  /**
+   * Key of the state where the watches classes are stored.
+   *
+   * @const string
+   */
+  const WATCHED_CLASSES_STATE_KEY = 'pathauto_test_uuid_generator.watch';
+
+  /**
+   * The state service.
+   *
+   * @var \Drupal\Core\State\StateInterface
+   */
+  protected $state;
+
+  /**
+   * Constructs a UuidTestGenerator instance.
+   *
+   * @param \Drupal\Core\State\StateInterface $state
+   *   The state service.
+   */
+  public function __construct(StateInterface $state) {
+    $this->state = $state;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function generate() {
+    if (empty($watch = $this->state->get(self::WATCHED_CLASSES_STATE_KEY, []))) {
+      return parent::generate();
+    }
+
+    $watched_classes = array_reduce((array) $watch, function (array $carry, string $fqcn) {
+      $name_parts = explode('\\', $fqcn);
+      $carry[end($name_parts)] = $fqcn;
+      return $carry;
+    }, []);
+
+    $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 1);
+    $trace_name_parts = explode(DIRECTORY_SEPARATOR, $trace[0]['file'] ?? '');
+    $trace_name = basename(end($trace_name_parts), '.php');
+
+    if (!in_array($trace_name, array_keys($watched_classes))) {
+      return parent::generate();
+    }
+
+    try {
+      $suspicious_class_location = (new \ReflectionClass($watched_classes[$trace_name]))->getFileName();
+    }
+    catch (\ReflectionException $e) {
+      return parent::generate();
+    }
+
+    if ($suspicious_class_location === $trace[0]['file']) {
+      $current = $this->state->get(self::LAST_SUFFIX_STATE_KEY, 0);
+      $current++;
+      $this->state->set(self::LAST_SUFFIX_STATE_KEY, $current);
+      return 'uuid' . $current;
+    }
+
+    return parent::generate();
+  }
+
+}
diff --git a/tests/src/Functional/Migrate/PathautoMigrateUiTest.php b/tests/src/Functional/Migrate/PathautoMigrateUiTest.php
new file mode 100644
index 0000000..14f7325
--- /dev/null
+++ b/tests/src/Functional/Migrate/PathautoMigrateUiTest.php
@@ -0,0 +1,215 @@
+<?php
+
+namespace Drupal\Tests\pathauto\Functional\Migrate;
+
+use Drupal\node\Entity\Node;
+use Drupal\path_alias\AliasRepositoryInterface;
+use Drupal\pathauto\Plugin\migrate\process\PathautoPatternSelectionCriteria;
+use Drupal\taxonomy\Entity\Term;
+use Drupal\Tests\migrate_drupal_ui\Functional\MigrateUpgradeTestBase;
+use Drupal\Tests\pathauto\Traits\PathautoMigrationAssertionsTrait;
+
+/**
+ * Tests migration of pathauto with Migrate Drupal UI.
+ *
+ * @group pathauto
+ */
+class PathautoMigrateUiTest extends MigrateUpgradeTestBase {
+
+  use PathautoMigrationAssertionsTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'content_translation',
+    'migrate_drupal_ui',
+    'pathauto',
+    'pathauto_test_uuid_generator',
+    'telephone',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getSourceBasePath() {
+    return \Drupal::service('extension.list.module')->getPath('migrate_drupal_ui') . '/tests/src/Functional/d7/files';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    // Pathauto's migration database fixture extends Drupal core's fixture.
+    $this->loadFixture(implode(DIRECTORY_SEPARATOR, [
+      DRUPAL_ROOT,
+      \Drupal::service('extension.list.module')->getPath('migrate_drupal'),
+      'tests',
+      'fixtures',
+      'drupal7.php',
+    ]));
+    $this->loadFixture(implode(DIRECTORY_SEPARATOR, [
+      DRUPAL_ROOT,
+      \Drupal::service('extension.list.module')->getPath('pathauto'),
+      'tests',
+      'fixtures',
+      'drupal7.php',
+    ]));
+
+    // UUIDs used in selection criteria must be predictable.
+    $this->container->get('state')->set('pathauto_test_uuid_generator.watch', PathautoPatternSelectionCriteria::class);
+  }
+
+  /**
+   * Tests the result of pathauto migrations including path alias states.
+   */
+  public function testPathautoMigrate() {
+    $this->executeMigrateUpgradeViaUi();
+
+    $this->assertPathautoSettings();
+
+    $this->assertTermForumsPattern(1);
+    $this->assertNodeArticleEnPattern(2);
+    $this->assertNodeArticleFrPattern(4);
+    $this->assertNodeArticleIsPattern(6);
+    $this->assertNodeArticlePattern(8);
+    $this->assertNodeBlogPattern(9);
+    $this->assertNodeEtPattern(10);
+    $this->assertNodePattern();
+    $this->assertTermTagsPattern(11);
+    $this->assertTermPattern();
+    $this->assertUserPattern();
+
+    $path_alias_repository = $this->container->get('path_alias.repository');
+    assert($path_alias_repository instanceof AliasRepositoryInterface);
+
+    // Check that the migrated URL aliases are present.
+    $this->assertEquals('/term33', $path_alias_repository->lookupBySystemPath('/taxonomy/term/4', 'en')['alias']);
+    $this->assertEquals('/term33', $path_alias_repository->lookupBySystemPath('/taxonomy/term/4', 'fr')['alias']);
+    $this->assertEquals('/term33', $path_alias_repository->lookupBySystemPath('/taxonomy/term/4', 'is')['alias']);
+    $this->assertEquals('/deep-space-9', $path_alias_repository->lookupBySystemPath('/node/2', 'en')['alias']);
+    $this->assertEquals('/deep-space-9-is', $path_alias_repository->lookupBySystemPath('/node/2', 'is')['alias']);
+    $this->assertEquals('/firefly-is', $path_alias_repository->lookupBySystemPath('/node/4', 'is')['alias']);
+    $this->assertEquals('/firefly', $path_alias_repository->lookupBySystemPath('/node/4', 'en')['alias']);
+
+    // Node 11 and taxonomy term 11 will have a generated path alias (after a
+    // resave), since they have pathalias = 1 on the source site.
+    $this->assertEquals(NULL, $path_alias_repository->lookupBySystemPath('/node/11', 'en'));
+    $this->assertEquals(NULL, $path_alias_repository->lookupBySystemPath('/taxonomy/term/11', 'en'));
+    Node::load(11)->save();
+    Term::load(11)->save();
+    $this->assertEquals('/entity-translation-test/11/page-one', $path_alias_repository->lookupBySystemPath('/node/11', 'en')['alias']);
+    $this->assertEquals('/tag/dax', $path_alias_repository->lookupBySystemPath('/taxonomy/term/11', 'en')['alias']);
+
+    // Taxonomy terms 2 and 3 do not have path alias, and their path alias state
+    // is "0": They shouldn't get (new) path alias, neither after a resave.
+    $this->assertEquals(NULL, $path_alias_repository->lookupBySystemPath('/taxonomy/term/2', 'en'));
+    $this->assertEquals(NULL, $path_alias_repository->lookupBySystemPath('/taxonomy/term/3', 'en'));
+    Term::load(2)->save();
+    Term::load(3)->save();
+    $this->assertEquals(NULL, $path_alias_repository->lookupBySystemPath('/taxonomy/term/2', 'en'));
+    $this->assertEquals(NULL, $path_alias_repository->lookupBySystemPath('/taxonomy/term/3', 'en'));
+
+    // The French translation of node 8 (its node ID on source is "9") has
+    // path auto state "0", but the other translations do not have states.
+    // So node 8 should't get generated path aliases.
+    $this->assertEquals(NULL, $path_alias_repository->lookupBySystemPath('/node/8', 'en'));
+    $this->assertEquals(NULL, $path_alias_repository->lookupBySystemPath('/node/8', 'fr'));
+    $this->assertEquals(NULL, $path_alias_repository->lookupBySystemPath('/node/8', 'is'));
+    Node::load(8)->save();
+    $this->assertEquals(NULL, $path_alias_repository->lookupBySystemPath('/node/8', 'en'));
+    $this->assertEquals(NULL, $path_alias_repository->lookupBySystemPath('/node/8', 'fr'));
+    $this->assertEquals(NULL, $path_alias_repository->lookupBySystemPath('/node/8', 'is'));
+  }
+
+  /**
+   * Submits the Migrate Upgrade source connection and files form.
+   */
+  protected function submitMigrateUpgradeSourceConnectionForm() {
+    $connection_options = $this->sourceDatabase->getConnectionOptions();
+    $this->drupalGet('/upgrade');
+    $session = $this->assertSession();
+    $session->responseContains("Upgrade a site by importing its files and the data from its database into a clean and empty new install of Drupal");
+
+    $this->drupalPostForm(NULL, [], 'Continue');
+    $session->pageTextContains('Provide credentials for the database of the Drupal site you want to upgrade.');
+
+    $driver = $connection_options['driver'];
+
+    // Use the driver connection form to get the correct options out of the
+    // database settings. This supports all of the databases we test against.
+    $drivers = drupal_get_database_types();
+    $form = $drivers[$driver]->getFormOptions($connection_options);
+    $connection_options = array_intersect_key($connection_options, $form + $form['advanced_options']);
+    $version = $this->getLegacyDrupalVersion($this->sourceDatabase);
+    $edit = [
+      $driver => $connection_options,
+      'source_private_file_path' => $this->getSourceBasePath(),
+      'version' => $version,
+      'source_base_path' => $this->getSourceBasePath(),
+    ];
+
+    if (count($drivers) !== 1) {
+      $edit['driver'] = $driver;
+    }
+    $edits = $this->translatePostValues($edit);
+
+    $this->drupalPostForm(NULL, $edits, 'Review upgrade');
+  }
+
+  /**
+   * Executes the upgrade process with Migrate Drupal UI.
+   */
+  protected function executeMigrateUpgradeViaUi() {
+    $this->submitMigrateUpgradeSourceConnectionForm();
+    $assert_session = $this->assertSession();
+    $assert_session->pageTextNotContains('Resolve all issues below to continue the upgrade.');
+
+    // When complete node migration is executed, Drupal 8.9 and above (even 9.x)
+    // will complain about content id conflicts. Drupal 8.8 and below won't.
+    // @see https://www.drupal.org/node/2928118
+    // @see https://www.drupal.org/node/3105503
+    if ($this->getSession()->getPage()->findButton('I acknowledge I may lose data. Continue anyway.')) {
+      $this->drupalPostForm(NULL, [], 'I acknowledge I may lose data. Continue anyway.');
+      $assert_session->statusCodeEquals(200);
+    }
+
+    // Perform the upgrade.
+    $this->drupalPostForm(NULL, [], 'Perform upgrade');
+    $this->assertText('Congratulations, you upgraded Drupal!');
+
+    // Have to reset all the statics after migration to ensure entities are
+    // loadable.
+    $this->resetAll();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getEntityCounts() {
+    return [];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getAvailablePaths() {
+    return [];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getMissingPaths() {
+    return [];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getEntityCountsIncremental() {
+    return [];
+  }
+
+}
diff --git a/tests/src/Functional/PathautoBulkUpdateTest.php b/tests/src/Functional/PathautoBulkUpdateTest.php
index a719372..99d7580 100644
--- a/tests/src/Functional/PathautoBulkUpdateTest.php
+++ b/tests/src/Functional/PathautoBulkUpdateTest.php
@@ -6,6 +6,7 @@ use Drupal\Component\Render\FormattableMarkup;
 use Drupal\pathauto\PathautoGeneratorInterface;
 use Drupal\pathauto\PathautoState;
 use Drupal\Tests\BrowserTestBase;
+use Drupal\user\Entity\User;
 
 /**
  * Bulk update functionality tests.
@@ -98,9 +99,12 @@ class PathautoBulkUpdateTest extends BrowserTestBase {
     foreach ($this->nodes as $node) {
       $this->assertEntityAliasExists($node);
     }
-    $this->assertEntityAliasExists($this->adminUser);
     // This is the default "General discussion" forum.
     $this->assertAliasExists(['path' => '/taxonomy/term/1']);
+    // User 1 is automatic in Drupal.
+    $this->assertEntityAliasExists(User::load(1));
+    // The custom admin user created in setUp() above.
+    $this->assertEntityAliasExists($this->adminUser);
 
     // Add a new node.
     $new_node = $this->drupalCreateNode(['path' => ['alias' => '', 'pathauto' => PathautoState::SKIP]]);
diff --git a/tests/src/FunctionalJavascript/PathautoUiTest.php b/tests/src/FunctionalJavascript/PathautoUiTest.php
index 6644977..dcdc1a8 100644
--- a/tests/src/FunctionalJavascript/PathautoUiTest.php
+++ b/tests/src/FunctionalJavascript/PathautoUiTest.php
@@ -27,7 +27,7 @@ class PathautoUiTest extends WebDriverTestBase {
    *
    * @var array
    */
-  protected static $modules = ['pathauto', 'node', 'block'];
+  protected static $modules = ['pathauto', 'node', 'block', 'taxonomy', 'user'];
 
   /**
    * Admin user.
diff --git a/tests/src/Kernel/Migrate/d7/MigratePathautoTest.php b/tests/src/Kernel/Migrate/d7/MigratePathautoTest.php
new file mode 100644
index 0000000..084e5b8
--- /dev/null
+++ b/tests/src/Kernel/Migrate/d7/MigratePathautoTest.php
@@ -0,0 +1,312 @@
+<?php
+
+namespace Drupal\Tests\pathauto\Kernel\Migrate\d7;
+
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\Core\Extension\ModuleInstallerInterface;
+use Drupal\node\Entity\Node;
+use Drupal\path_alias\AliasRepository;
+use Drupal\pathauto\Entity\PathautoPattern;
+use Drupal\pathauto\Plugin\migrate\process\PathautoPatternSelectionCriteria;
+use Drupal\taxonomy\Entity\Term;
+use Drupal\Tests\migrate_drupal\Kernel\d7\MigrateDrupal7TestBase;
+use Drupal\Tests\pathauto\Traits\PathautoMigrationAssertionsTrait;
+
+/**
+ * Tests pathauto settings and pathauto pattern migrations.
+ *
+ * @group pathauto
+ */
+class MigratePathautoTest extends MigrateDrupal7TestBase {
+
+  use PathautoMigrationAssertionsTrait;
+
+  /**
+   * Test is executed with forum enabled.
+   *
+   * @var bool
+   */
+  protected $withForum;
+
+  /**
+   * Multilingual test.
+   *
+   * @var bool
+   */
+  protected $multilingual = FALSE;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'comment',
+    'ctools',
+    'datetime',
+    'datetime_range',
+    'image',
+    'link',
+    'filter',
+    'file',
+    'node',
+    'path',
+    'path_alias',
+    'pathauto',
+    'system',
+    'pathauto_test_uuid_generator',
+    'taxonomy',
+    'telephone',
+    'text',
+    'token',
+    'taxonomy',
+    'menu_ui',
+  ];
+
+  /**
+   * Returns the path to the file system fixture.
+   */
+  protected function getFilesystemFixturePath() {
+    return implode(DIRECTORY_SEPARATOR, [
+      DRUPAL_ROOT,
+      \Drupal::service('extension.list.module')->getPath('migrate_drupal_ui'),
+      'tests',
+      'src',
+      'Functional',
+      'd7',
+      'files',
+    ]);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function register(ContainerBuilder $container) {
+    parent::register($container);
+    $container->register('stream_wrapper.private', 'Drupal\Core\StreamWrapper\PrivateStream')
+      ->addTag('stream_wrapper', ['scheme' => 'private']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $this->loadFixture(implode(DIRECTORY_SEPARATOR, [
+      DRUPAL_ROOT,
+      \Drupal::service('extension.list.module')->getPath('pathauto'),
+      'tests',
+      'fixtures',
+      'drupal7.php',
+    ]));
+
+    $this->installConfig(static::$modules);
+    $this->installSchema('node', ['node_access']);
+    $this->installSchema('file', ['file_usage']);
+    $this->installEntitySchema('file');
+    $this->installEntitySchema('path_alias');
+
+    // UUIDs used in selection criteria must be predictable.
+    $this->container->get('state')->set('pathauto_test_uuid_generator.watch', PathautoPatternSelectionCriteria::class);
+  }
+
+  /**
+   * Test pathauto migration from Drupal 7.
+   *
+   * @param bool $forum_enabled
+   *   Whether forum module is enabled on the source site or not.
+   *
+   * @dataProvider providerPathautoMigrations
+   */
+  public function testPathautoMigrations(bool $forum_enabled) {
+    $this->withForum = $forum_enabled;
+
+    if ($this->multilingual) {
+      $this->enableModules([
+        'language',
+        'content_translation',
+      ]);
+    }
+
+    $this->sourceDatabase->update('system')
+      ->fields(['status' => (int) $forum_enabled])
+      ->condition('name', 'forum')
+      ->condition('type', 'module')
+      ->execute();
+
+    $this->executeMigrations([
+      'd7_pathauto_settings',
+    ]);
+    $this->assertPathautoSettings();
+
+    $this->executeMigrations([
+      'd7_node_type',
+      'd7_pathauto_patterns:node:article',
+      'd7_pathauto_patterns:node:blog',
+      'd7_pathauto_patterns:node:et',
+      'd7_pathauto_patterns:node_default',
+    ]);
+
+    $this->assertNodeArticlePattern();
+    $this->assertNodeBlogPattern();
+    $this->assertNodeEtPattern();
+    $this->assertNodePattern();
+
+    $this->executeMigrations([
+      'd7_taxonomy_vocabulary',
+      'd7_pathauto_patterns:taxonomy_term',
+      'd7_pathauto_patterns:taxonomy_term_default',
+    ]);
+    $this->assertTermTagsPattern();
+    $this->assertTermPattern();
+    if ($forum_enabled) {
+      $this->assertTermForumsPattern();
+    }
+    else {
+      $this->assertNull(PathautoPattern::load('forum'));
+    }
+
+    // Execute the rest of the migrations. For now, this is equal to executing
+    // "d7_pathauto_patterns:user_default".
+    $this->executeMigrations([
+      'd7_pathauto_patterns',
+    ]);
+    $this->assertUserPattern();
+
+    // Pathauto states (the state whether a path alias for a given entity was
+    // generated by pathauto or not) are migrated with the content entity
+    // migrations. Let's execute "user", "taxonomy_term" and "node" migrations.
+    if ($this->multilingual) {
+      $this->executeMigrations([
+        'language',
+        'd7_language_content_settings',
+        'd7_language_content_taxonomy_vocabulary_settings',
+      ]);
+    }
+    $this->executeMigration('d7_comment_type');
+    if ($this->multilingual) {
+      $this->executeMigrations([
+        'd7_entity_translation_settings',
+      ]);
+    }
+    $this->executeMigrations([
+      'd7_filter_format',
+      'd7_field',
+      'd7_field_instance',
+      'd7_view_modes',
+      'd7_field_formatter_settings',
+      'd7_field_instance_widget_settings',
+      'd7_user_role',
+      'user_picture_field',
+      'user_picture_field_instance',
+      'user_picture_entity_display',
+      'user_picture_entity_form_display',
+      'd7_node_title_label',
+    ]);
+
+    // Migrate files.
+    $fs_fixture_path = $this->getFilesystemFixturePath();
+    foreach (['d7_file', 'd7_file_private'] as $file_migration_plugin_id) {
+      $file_migration = $this->getMigration($file_migration_plugin_id);
+      $source = $file_migration->getSourceConfiguration();
+      $source['constants']['source_base_path'] = $fs_fixture_path;
+      $file_migration->set('source', $source);
+      $this->executeMigration($file_migration);
+    }
+
+    // Ignore migration messages.
+    $this->startCollectingMessages();
+    $this->executeMigrations([
+      'd7_user',
+      'd7_node_complete',
+      'd7_taxonomy_term',
+    ]);
+    if ($this->multilingual) {
+      $this->executeMigrations([
+        'd7_taxonomy_term_entity_translation',
+        'd7_taxonomy_term_localized_translation',
+        'd7_taxonomy_term_translation',
+      ]);
+    }
+    // Migrate path aliases.
+    $this->executeMigrations([
+      'd7_url_alias',
+    ]);
+    $this->stopCollectingMessages();
+
+    $path_alias_repository = $this->container->get('path_alias.repository');
+    assert($path_alias_repository instanceof AliasRepository);
+
+    // Check that the migrated URL aliases are present.
+    $this->assertEquals('/term33', $path_alias_repository->lookupBySystemPath('/taxonomy/term/4', 'en')['alias']);
+    $this->assertEquals('/term33', $path_alias_repository->lookupBySystemPath('/taxonomy/term/4', 'fr')['alias']);
+    $this->assertEquals('/term33', $path_alias_repository->lookupBySystemPath('/taxonomy/term/4', 'is')['alias']);
+    $this->assertEquals('/deep-space-9', $path_alias_repository->lookupBySystemPath('/node/2', 'en')['alias']);
+    if ($this->multilingual) {
+      $this->assertEquals('/deep-space-9-is', $path_alias_repository->lookupBySystemPath('/node/2', 'is')['alias']);
+    }
+    $this->assertEquals('/firefly-is', $path_alias_repository->lookupBySystemPath('/node/4', 'is')['alias']);
+    $this->assertEquals('/firefly', $path_alias_repository->lookupBySystemPath('/node/4', 'en')['alias']);
+
+    // Node 11 and taxonomy term 11 will have a generated path alias (after a
+    // resave), since they have pathalias = 1 on the source site.
+    $this->assertEquals(NULL, $path_alias_repository->lookupBySystemPath('/node/11', 'en'));
+    $this->assertEquals(NULL, $path_alias_repository->lookupBySystemPath('/taxonomy/term/11', 'en'));
+    Node::load(11)->save();
+    Term::load(11)->save();
+    $this->assertEquals('/entity-translation-test/11/page-one', $path_alias_repository->lookupBySystemPath('/node/11', 'en')['alias']);
+    $this->assertEquals('/tag/dax', $path_alias_repository->lookupBySystemPath('/taxonomy/term/11', 'en')['alias']);
+
+    // Taxonomy terms 2 and 3 do not have path alias, and their path alias state
+    // is "0": They shoudn't get (new) path alias.
+    $this->assertEquals(NULL, $path_alias_repository->lookupBySystemPath('/taxonomy/term/2', 'en'));
+    $this->assertEquals(NULL, $path_alias_repository->lookupBySystemPath('/taxonomy/term/3', 'en'));
+    Term::load(2)->save();
+    Term::load(3)->save();
+    $this->assertEquals(NULL, $path_alias_repository->lookupBySystemPath('/taxonomy/term/2', 'en'));
+    $this->assertEquals(NULL, $path_alias_repository->lookupBySystemPath('/taxonomy/term/3', 'en'));
+
+    // The French translation of node 8 (its node ID on source is "9") has
+    // path auto state "0", but the other translations do not have states.
+    // So node 8 should't get generated path aliases.
+    $this->assertEquals(NULL, $path_alias_repository->lookupBySystemPath('/node/8', 'en'));
+    $this->assertEquals(NULL, $path_alias_repository->lookupBySystemPath('/node/8', 'fr'));
+    $this->assertEquals(NULL, $path_alias_repository->lookupBySystemPath('/node/8', 'is'));
+    Node::load(8)->save();
+    dump($path_alias_repository->lookupBySystemPath('/node/8', 'en'));
+    dump($path_alias_repository->lookupBySystemPath('/node/8', 'fr'));
+    dump($path_alias_repository->lookupBySystemPath('/node/8', 'is'));
+    $this->assertEquals(NULL, $path_alias_repository->lookupBySystemPath('/node/8', 'en'));
+    $this->assertEquals(NULL, $path_alias_repository->lookupBySystemPath('/node/8', 'fr'));
+    $this->assertEquals(NULL, $path_alias_repository->lookupBySystemPath('/node/8', 'is'));
+  }
+
+  /**
+   * Test multilingual pathauto pattern migrations.
+   */
+  public function testMultilingualPathautoMigrations() {
+    $this->multilingual = TRUE;
+    $module_installer = $this->container->get('module_installer');
+    assert($module_installer instanceof ModuleInstallerInterface);
+    $module_installer->install(['language']);
+    $this->executeMigration('language');
+    $this->testPathautoMigrations(TRUE);
+    $this->assertAllNodeArticlePatterns();
+  }
+
+  /**
+   * Data provider for ::testPathautoMigrations().
+   *
+   * @return bool[][]
+   *   The test cases.
+   */
+  public function providerPathautoMigrations() {
+    return [
+      'Disabled forum on source site' => [
+        'Forum enabled' => FALSE,
+      ],
+      'Enabled forum on source site' => [
+        'Forum enabled' => TRUE,
+      ],
+    ];
+  }
+
+}
diff --git a/tests/src/Kernel/Plugin/migrate/process/PathautoPatternLabelTest.php b/tests/src/Kernel/Plugin/migrate/process/PathautoPatternLabelTest.php
new file mode 100644
index 0000000..cad6362
--- /dev/null
+++ b/tests/src/Kernel/Plugin/migrate/process/PathautoPatternLabelTest.php
@@ -0,0 +1,114 @@
+<?php
+
+namespace Drupal\Tests\pathauto\Kernel\Plugin\migrate\process;
+
+use Drupal\migrate\MigrateExecutableInterface;
+use Drupal\migrate\Plugin\MigrationInterface;
+use Drupal\migrate\Row;
+use Drupal\pathauto\Plugin\migrate\process\PathautoPatternLabel;
+use Drupal\Tests\node\Traits\ContentTypeCreationTrait;
+use Drupal\Tests\token\Kernel\KernelTestBase;
+
+/**
+ * Tests the "pathauto_pattern_label" migrate process plugin.
+ *
+ * @coversDefaultClass \Drupal\pathauto\Plugin\migrate\process\PathautoPatternLabel
+ * @group pathauto
+ */
+class PathautoPatternLabelTest extends KernelTestBase {
+
+  use ContentTypeCreationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'field',
+    'node',
+    'text',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp(): void {
+    parent::setUp();
+
+    $this->installSchema('node', 'node_access');
+    $this->installEntitySchema('node');
+    $this->installConfig(['field', 'node']);
+
+    $this->createContentType([
+      'type' => 'article',
+      'name' => 'Article',
+    ]);
+    $this->createContentType([
+      'type' => 'blog',
+      'name' => 'Blog entry',
+    ]);
+  }
+
+  /**
+   * Tests pathauto pattern label transform.
+   *
+   * @param array $source
+   *   The source values for the plugin.
+   * @param string $expected
+   *   The expected result.
+   *
+   * @dataProvider providerTestTransform
+   *
+   * @covers ::transform
+   */
+  public function testTransform(array $source, $expected) {
+    $entity_type_manager = $this->container->get('entity_type.manager');
+    $bundle_info = $this->container->get('entity_type.bundle.info');
+    $row = new Row();
+    $migration = $this->prophesize(MigrationInterface::class)->reveal();
+    $executable = $this->prophesize(MigrateExecutableInterface::class)->reveal();
+
+    $plugin = new PathautoPatternLabel([], 'pathauto_pattern_label', [], $migration, $entity_type_manager, $bundle_info);
+
+    $actual = $plugin->transform($source, $executable, $row, 'destination_prop');
+    $this->assertSame($expected, $actual);
+  }
+
+  /**
+   * Data provider for ::testTransform.
+   */
+  public function providerTestTransform() {
+    return [
+      'Node with bundle, no language set' => [
+        'source' => [
+          'node',
+          'article',
+        ],
+        'expected' => 'Content - Article',
+      ],
+      'Node with bundle and with a language' => [
+        'source' => [
+          'node',
+          'blog',
+          'hu',
+        ],
+        'expected' => 'Content - Blog entry (hu)',
+      ],
+      'Node with missing bundle' => [
+        'source' => [
+          'node',
+          'missing_bundle',
+        ],
+        'expected' => 'Content - missing_bundle',
+      ],
+      'Missing entity type' => [
+        'source' => [
+          'missing_entity_type',
+          'missing_bundle',
+          'custom_langcode',
+        ],
+        'expected' => 'missing_entity_type - missing_bundle (custom_langcode)',
+      ],
+    ];
+  }
+
+}
diff --git a/tests/src/Kernel/Plugin/migrate/source/PathautoPatternTest.php b/tests/src/Kernel/Plugin/migrate/source/PathautoPatternTest.php
new file mode 100644
index 0000000..e3e15f4
--- /dev/null
+++ b/tests/src/Kernel/Plugin/migrate/source/PathautoPatternTest.php
@@ -0,0 +1,703 @@
+<?php
+
+namespace Drupal\Tests\pathauto\Kernel\Plugin\migrate\source;
+
+/**
+ * Tests the "pathauto_pattern" migrate source plugin.
+ *
+ * @covers \Drupal\pathauto\Plugin\migrate\source\PathautoPattern
+ * @group pathauto
+ */
+class PathautoPatternTest extends PathautoSourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   *
+   * @see https://www.drupal.org/node/2909426
+   * @todo This should be changed to "protected" after Drupal core 8.x security
+   *   support ends.
+   */
+  public static $modules = [
+    'language',
+    'node',
+    'taxonomy',
+    'user',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  public function providerSource() {
+    return [
+      'No bundle and entity type restrictions' => [
+        'Source' => self::TEST_DB,
+        'Expected' => [
+          0 => [
+            'name' => 'pathauto_forum_pattern',
+            'value' => 's:29:"[term:vocabulary]/[term:name]";',
+            'id' => 'forum',
+            'forum_vocabulary' => TRUE,
+            'entity_type' => 'taxonomy_term',
+            'bundle' => 'sujet_de_discussion',
+            'weight' => 0,
+          ],
+          1 => [
+            'name' => 'pathauto_node_article_en_pattern',
+            'value' => 's:35:"[node:content-type]/en/[node:title]";',
+            'id' => 'node_article_en',
+            'forum_vocabulary' => FALSE,
+            'entity_type' => 'node',
+            'bundle' => 'article',
+            'langcode' => 'en',
+            'weight' => -1,
+          ],
+          2 => [
+            'name' => 'pathauto_node_article_fr_pattern',
+            'value' => 's:35:"[node:content-type]/fr/[node:title]";',
+            'id' => 'node_article_fr',
+            'forum_vocabulary' => FALSE,
+            'entity_type' => 'node',
+            'bundle' => 'article',
+            'langcode' => 'fr',
+            'weight' => -1,
+          ],
+          3 => [
+            'name' => 'pathauto_node_article_is_pattern',
+            'value' => 's:35:"[node:content-type]/is/[node:title]";',
+            'id' => 'node_article_is',
+            'forum_vocabulary' => FALSE,
+            'entity_type' => 'node',
+            'bundle' => 'article',
+            'langcode' => 'is',
+            'weight' => -1,
+          ],
+          4 => [
+            'name' => 'pathauto_node_article_pattern',
+            'value' => 's:32:"[node:content-type]/[node:title]";',
+            'id' => 'node_article',
+            'forum_vocabulary' => FALSE,
+            'entity_type' => 'node',
+            'bundle' => 'article',
+            'weight' => 0,
+          ],
+          5 => [
+            'name' => 'pathauto_node_blog_pattern',
+            'value' => 's:37:"blogs/[node:author:name]/[node:title]";',
+            'id' => 'node_blog',
+            'forum_vocabulary' => FALSE,
+            'entity_type' => 'node',
+            'bundle' => 'blog',
+            'weight' => 0,
+          ],
+          6 => [
+            'name' => 'pathauto_node_et_pattern',
+            'value' => 's:43:"[node:content-type]/[node:nid]/[node:title]";',
+            'id' => 'node_et',
+            'forum_vocabulary' => FALSE,
+            'entity_type' => 'node',
+            'bundle' => 'et',
+            'weight' => 0,
+          ],
+          7 => [
+            'name' => 'pathauto_node_pattern',
+            'value' => 's:12:"[node:title]";',
+            'id' => 'node',
+            'forum_vocabulary' => FALSE,
+            'entity_type' => 'node',
+            'weight' => 1,
+            'bundle' => NULL,
+          ],
+          8 => [
+            'name' => 'pathauto_taxonomy_term_tags_pattern',
+            'value' => 's:15:"tag/[term:name]";',
+            'id' => 'taxonomy_term_tags',
+            'forum_vocabulary' => FALSE,
+            'entity_type' => 'taxonomy_term',
+            'bundle' => 'tags',
+            'weight' => 0,
+          ],
+          9 => [
+            'name' => 'pathauto_taxonomy_term_pattern',
+            'value' => 's:29:"[term:vocabulary]/[term:name]";',
+            'id' => 'taxonomy_term',
+            'forum_vocabulary' => FALSE,
+            'entity_type' => 'taxonomy_term',
+            'weight' => 1,
+            'bundle' => NULL,
+          ],
+          10 => [
+            'name' => 'pathauto_user_pattern',
+            'value' => 's:17:"users/[user:name]";',
+            'id' => 'user',
+            'forum_vocabulary' => FALSE,
+            'entity_type' => 'user',
+            'weight' => 1,
+            'bundle' => NULL,
+          ],
+        ],
+        'Count' => NULL,
+        'config' => [],
+      ],
+      'User patterns' => [
+        'Source' => self::TEST_DB,
+        'Expected' => [
+          [
+            'name' => 'pathauto_user_pattern',
+            'value' => 's:17:"users/[user:name]";',
+            'id' => 'user',
+            'forum_vocabulary' => FALSE,
+            'entity_type' => 'user',
+            'bundle' => NULL,
+            'weight' => 1,
+          ],
+        ],
+        'Count' => NULL,
+        'config' => [
+          'entity_type' => 'user',
+        ],
+      ],
+      'Node patterns' => [
+        'Source' => self::TEST_DB,
+        'Expected' => [
+          [
+            'name' => 'pathauto_node_article_en_pattern',
+            'value' => 's:35:"[node:content-type]/en/[node:title]";',
+            'id' => 'node_article_en',
+            'forum_vocabulary' => FALSE,
+            'entity_type' => 'node',
+            'bundle' => 'article',
+            'langcode' => 'en',
+            'weight' => -1,
+          ],
+          [
+            'name' => 'pathauto_node_article_fr_pattern',
+            'value' => 's:35:"[node:content-type]/fr/[node:title]";',
+            'id' => 'node_article_fr',
+            'forum_vocabulary' => FALSE,
+            'entity_type' => 'node',
+            'bundle' => 'article',
+            'langcode' => 'fr',
+            'weight' => -1,
+          ],
+          [
+            'name' => 'pathauto_node_article_is_pattern',
+            'value' => 's:35:"[node:content-type]/is/[node:title]";',
+            'id' => 'node_article_is',
+            'forum_vocabulary' => FALSE,
+            'entity_type' => 'node',
+            'bundle' => 'article',
+            'langcode' => 'is',
+            'weight' => -1,
+          ],
+          [
+            'name' => 'pathauto_node_article_pattern',
+            'value' => 's:32:"[node:content-type]/[node:title]";',
+            'id' => 'node_article',
+            'forum_vocabulary' => FALSE,
+            'entity_type' => 'node',
+            'bundle' => 'article',
+            'weight' => 0,
+          ],
+          [
+            'name' => 'pathauto_node_blog_pattern',
+            'value' => 's:37:"blogs/[node:author:name]/[node:title]";',
+            'id' => 'node_blog',
+            'forum_vocabulary' => FALSE,
+            'entity_type' => 'node',
+            'bundle' => 'blog',
+            'weight' => 0,
+          ],
+          [
+            'name' => 'pathauto_node_et_pattern',
+            'value' => 's:43:"[node:content-type]/[node:nid]/[node:title]";',
+            'id' => 'node_et',
+            'forum_vocabulary' => FALSE,
+            'entity_type' => 'node',
+            'bundle' => 'et',
+            'weight' => 0,
+          ],
+          [
+            'name' => 'pathauto_node_pattern',
+            'value' => 's:12:"[node:title]";',
+            'id' => 'node',
+            'forum_vocabulary' => FALSE,
+            'entity_type' => 'node',
+            'weight' => 1,
+            'bundle' => NULL,
+          ],
+        ],
+        'Count' => NULL,
+        'config' => [
+          'entity_type' => 'node',
+        ],
+      ],
+      'Article node patterns' => [
+        'Source' => self::TEST_DB,
+        'Expected' => [
+          [
+            'name' => 'pathauto_node_article_en_pattern',
+            'value' => 's:35:"[node:content-type]/en/[node:title]";',
+            'id' => 'node_article_en',
+            'forum_vocabulary' => FALSE,
+            'entity_type' => 'node',
+            'bundle' => 'article',
+            'langcode' => 'en',
+            'weight' => -1,
+          ],
+          [
+            'name' => 'pathauto_node_article_fr_pattern',
+            'value' => 's:35:"[node:content-type]/fr/[node:title]";',
+            'id' => 'node_article_fr',
+            'forum_vocabulary' => FALSE,
+            'entity_type' => 'node',
+            'bundle' => 'article',
+            'langcode' => 'fr',
+            'weight' => -1,
+          ],
+          [
+            'name' => 'pathauto_node_article_is_pattern',
+            'value' => 's:35:"[node:content-type]/is/[node:title]";',
+            'id' => 'node_article_is',
+            'forum_vocabulary' => FALSE,
+            'entity_type' => 'node',
+            'bundle' => 'article',
+            'langcode' => 'is',
+            'weight' => -1,
+          ],
+          [
+            'name' => 'pathauto_node_article_pattern',
+            'value' => 's:32:"[node:content-type]/[node:title]";',
+            'id' => 'node_article',
+            'forum_vocabulary' => FALSE,
+            'entity_type' => 'node',
+            'bundle' => 'article',
+            'weight' => 0,
+          ],
+        ],
+        'Count' => NULL,
+        'config' => [
+          'entity_type' => 'node',
+          'bundle' => 'article',
+        ],
+      ],
+      'Only the default pattern of taxonomy terms' => [
+        'Source' => self::TEST_DB,
+        'Expected' => [
+          [
+            'name' => 'pathauto_taxonomy_term_pattern',
+            'value' => 's:29:"[term:vocabulary]/[term:name]";',
+            'id' => 'taxonomy_term',
+            'forum_vocabulary' => FALSE,
+            'entity_type' => 'taxonomy_term',
+            'weight' => 1,
+            'bundle' => NULL,
+          ],
+        ],
+        'Count' => NULL,
+        'config' => [
+          'entity_type' => 'taxonomy_term',
+          'bundle' => FALSE,
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * The test DB.
+   *
+   * @const array[]
+   */
+  const TEST_DB = [
+    'system' => [
+      'forum' => [
+        'name' => 'forum',
+        'schema_version' => 7084,
+        'type' => 'module',
+        'status' => 1,
+      ],
+      'system' => [
+        'name' => 'system',
+        'schema_version' => 7084,
+        'type' => 'module',
+        'status' => 1,
+      ],
+      'pathauto' => [
+        'name' => 'pathauto',
+        'schema_version' => 7006,
+        'type' => 'module',
+        'status' => 1,
+      ],
+      'taxonomy' => [
+        'name' => 'taxonomy',
+        'schema_version' => 7084,
+        'type' => 'module',
+        'status' => 1,
+      ],
+      'token' => [
+        'name' => 'token',
+        'schema_version' => 7000,
+        'type' => 'module',
+        'status' => 1,
+      ],
+      'locale' => [
+        'name' => 'locale',
+        'schema_version' => 7005,
+        'type' => 'module',
+        'status' => 1,
+      ],
+    ],
+    'taxonomy_vocabulary' => [
+      [
+        'vid' => 2,
+        'name' => 'Sujet de discussion',
+        'machine_name' => 'sujet_de_discussion',
+        'description' => 'Forum navigation vocabulary',
+        'hierarchy' => 1,
+        'module' => 'forum',
+        'weight' => -10,
+        'language' => 'und',
+      ],
+    ],
+    'variable' => [
+      [
+        'name' => 'forum_nav_vocabulary',
+        'value' => 's:1:"2";',
+      ],
+      [
+        'name' => 'pathauto_blog_pattern',
+        'value' => 's:17:"blogs/[user:name]";',
+      ],
+      [
+        'name' => 'pathauto_case',
+        'value' => 's:1:"1";',
+      ],
+      [
+        'name' => 'pathauto_forum_pattern',
+        'value' => 's:29:"[term:vocabulary]/[term:name]";',
+      ],
+      [
+        'name' => 'pathauto_ignore_words',
+        'value' => 's:134:"a, an, as, at, before, but, by, for, from, is, in, into, like, of, off, on, onto, per, since, than, the, this, that, to, up, via, with";',
+      ],
+      [
+        'name' => 'pathauto_max_component_length',
+        'value' => 's:3:"100";',
+      ],
+      [
+        'name' => 'pathauto_max_length',
+        'value' => 's:3:"100";',
+      ],
+      [
+        'name' => 'pathauto_node_article_en_pattern',
+        'value' => 's:35:"[node:content-type]/en/[node:title]";',
+      ],
+      [
+        'name' => 'pathauto_node_article_fr_pattern',
+        'value' => 's:35:"[node:content-type]/fr/[node:title]";',
+      ],
+      [
+        'name' => 'pathauto_node_article_is_pattern',
+        'value' => 's:35:"[node:content-type]/is/[node:title]";',
+      ],
+      [
+        'name' => 'pathauto_node_article_pattern',
+        'value' => 's:32:"[node:content-type]/[node:title]";',
+      ],
+      [
+        'name' => 'pathauto_node_article_und_pattern',
+        'value' => 's:0:"";',
+      ],
+      [
+        'name' => 'pathauto_node_blog_en_pattern',
+        'value' => 's:0:"";',
+      ],
+      [
+        'name' => 'pathauto_node_blog_fr_pattern',
+        'value' => 's:0:"";',
+      ],
+      [
+        'name' => 'pathauto_node_blog_is_pattern',
+        'value' => 's:0:"";',
+      ],
+      [
+        'name' => 'pathauto_node_blog_pattern',
+        'value' => 's:37:"blogs/[node:author:name]/[node:title]";',
+      ],
+      [
+        'name' => 'pathauto_node_blog_und_pattern',
+        'value' => 's:0:"";',
+      ],
+      [
+        'name' => 'pathauto_node_book_pattern',
+        'value' => 's:0:"";',
+      ],
+      [
+        'name' => 'pathauto_node_et_en_pattern',
+        'value' => 's:0:"";',
+      ],
+      [
+        'name' => 'pathauto_node_et_fr_pattern',
+        'value' => 's:0:"";',
+      ],
+      [
+        'name' => 'pathauto_node_et_is_pattern',
+        'value' => 's:0:"";',
+      ],
+      [
+        'name' => 'pathauto_node_et_pattern',
+        'value' => 's:43:"[node:content-type]/[node:nid]/[node:title]";',
+      ],
+      [
+        'name' => 'pathauto_node_et_und_pattern',
+        'value' => 's:0:"";',
+      ],
+      [
+        'name' => 'pathauto_node_forum_pattern',
+        'value' => 's:0:"";',
+      ],
+      [
+        'name' => 'pathauto_node_page_en_pattern',
+        'value' => 's:0:"";',
+      ],
+      [
+        'name' => 'pathauto_node_page_fr_pattern',
+        'value' => 's:0:"";',
+      ],
+      [
+        'name' => 'pathauto_node_page_is_pattern',
+        'value' => 's:0:"";',
+      ],
+      [
+        'name' => 'pathauto_node_page_pattern',
+        'value' => 's:0:"";',
+      ],
+      [
+        'name' => 'pathauto_node_page_und_pattern',
+        'value' => 's:0:"";',
+      ],
+      [
+        'name' => 'pathauto_node_pattern',
+        'value' => 's:12:"[node:title]";',
+      ],
+      [
+        'name' => 'pathauto_node_test_content_type_en_pattern',
+        'value' => 's:0:"";',
+      ],
+      [
+        'name' => 'pathauto_node_test_content_type_fr_pattern',
+        'value' => 's:0:"";',
+      ],
+      [
+        'name' => 'pathauto_node_test_content_type_is_pattern',
+        'value' => 's:0:"";',
+      ],
+      [
+        'name' => 'pathauto_node_test_content_type_pattern',
+        'value' => 's:0:"";',
+      ],
+      [
+        'name' => 'pathauto_node_test_content_type_und_pattern',
+        'value' => 's:0:"";',
+      ],
+      [
+        'name' => 'pathauto_punctuation_ampersand',
+        'value' => 's:1:"0";',
+      ],
+      [
+        'name' => 'pathauto_punctuation_asterisk',
+        'value' => 's:1:"0";',
+      ],
+      [
+        'name' => 'pathauto_punctuation_at',
+        'value' => 's:1:"0";',
+      ],
+      [
+        'name' => 'pathauto_punctuation_backtick',
+        'value' => 's:1:"0";',
+      ],
+      [
+        'name' => 'pathauto_punctuation_back_slash',
+        'value' => 's:1:"0";',
+      ],
+      [
+        'name' => 'pathauto_punctuation_caret',
+        'value' => 's:1:"0";',
+      ],
+      [
+        'name' => 'pathauto_punctuation_colon',
+        'value' => 's:1:"0";',
+      ],
+      [
+        'name' => 'pathauto_punctuation_comma',
+        'value' => 's:1:"0";',
+      ],
+      [
+        'name' => 'pathauto_punctuation_dollar',
+        'value' => 's:1:"0";',
+      ],
+      [
+        'name' => 'pathauto_punctuation_double_quotes',
+        'value' => 's:1:"0";',
+      ],
+      [
+        'name' => 'pathauto_punctuation_equal',
+        'value' => 's:1:"0";',
+      ],
+      [
+        'name' => 'pathauto_punctuation_exclamation',
+        'value' => 's:1:"0";',
+      ],
+      [
+        'name' => 'pathauto_punctuation_greater_than',
+        'value' => 's:1:"0";',
+      ],
+      [
+        'name' => 'pathauto_punctuation_hash',
+        'value' => 's:1:"0";',
+      ],
+      [
+        'name' => 'pathauto_punctuation_hyphen',
+        'value' => 's:1:"1";',
+      ],
+      [
+        'name' => 'pathauto_punctuation_left_curly',
+        'value' => 's:1:"0";',
+      ],
+      [
+        'name' => 'pathauto_punctuation_left_parenthesis',
+        'value' => 's:1:"0";',
+      ],
+      [
+        'name' => 'pathauto_punctuation_left_square',
+        'value' => 's:1:"0";',
+      ],
+      [
+        'name' => 'pathauto_punctuation_less_than',
+        'value' => 's:1:"0";',
+      ],
+      [
+        'name' => 'pathauto_punctuation_percent',
+        'value' => 's:1:"0";',
+      ],
+      [
+        'name' => 'pathauto_punctuation_period',
+        'value' => 's:1:"0";',
+      ],
+      [
+        'name' => 'pathauto_punctuation_pipe',
+        'value' => 's:1:"0";',
+      ],
+      [
+        'name' => 'pathauto_punctuation_plus',
+        'value' => 's:1:"0";',
+      ],
+      [
+        'name' => 'pathauto_punctuation_question_mark',
+        'value' => 's:1:"0";',
+      ],
+      [
+        'name' => 'pathauto_punctuation_quotes',
+        'value' => 's:1:"0";',
+      ],
+      [
+        'name' => 'pathauto_punctuation_right_curly',
+        'value' => 's:1:"0";',
+      ],
+      [
+        'name' => 'pathauto_punctuation_right_parenthesis',
+        'value' => 's:1:"0";',
+      ],
+      [
+        'name' => 'pathauto_punctuation_right_square',
+        'value' => 's:1:"0";',
+      ],
+      [
+        'name' => 'pathauto_punctuation_semicolon',
+        'value' => 's:1:"0";',
+      ],
+      [
+        'name' => 'pathauto_punctuation_slash',
+        'value' => 's:1:"0";',
+      ],
+      [
+        'name' => 'pathauto_punctuation_tilde',
+        'value' => 's:1:"0";',
+      ],
+      [
+        'name' => 'pathauto_punctuation_underscore',
+        'value' => 's:1:"0";',
+      ],
+      [
+        'name' => 'pathauto_reduce_ascii',
+        'value' => 'i:0;',
+      ],
+      [
+        'name' => 'pathauto_separator',
+        'value' => 's:1:"-";',
+      ],
+      [
+        'name' => 'pathauto_taxonomy_term_sujet_de_discussion_pattern',
+        'value' => 's:0:"";',
+      ],
+      [
+        'name' => 'pathauto_taxonomy_term_tags_pattern',
+        'value' => 's:15:"tag/[term:name]";',
+      ],
+      [
+        'name' => 'pathauto_taxonomy_term_test_vocabulary_pattern',
+        'value' => 's:0:"";',
+      ],
+      [
+        'name' => 'pathauto_taxonomy_term_vocabfixed_pattern',
+        'value' => 's:0:"";',
+      ],
+      [
+        'name' => 'pathauto_taxonomy_term_vocablocalized_pattern',
+        'value' => 's:0:"";',
+      ],
+      [
+        'name' => 'pathauto_taxonomy_term_vocabtranslate_pattern',
+        'value' => 's:0:"";',
+      ],
+      [
+        'name' => 'pathauto_taxonomy_term_vocabulary_name_much_longer_than_thirty_two_characters_pattern',
+        'value' => 's:0:"";',
+      ],
+      [
+        'name' => 'pathauto_taxonomy_term_pattern',
+        'value' => 's:29:"[term:vocabulary]/[term:name]";',
+      ],
+      [
+        'name' => 'pathauto_transliterate',
+        'value' => 'i:1;',
+      ],
+      [
+        'name' => 'pathauto_update_action',
+        'value' => 's:1:"2";',
+      ],
+      [
+        'name' => 'pathauto_user_pattern',
+        'value' => 's:17:"users/[user:name]";',
+      ],
+      [
+        'name' => 'pathauto_verbose',
+        'value' => 'i:0;',
+      ],
+    ],
+    'languages' => [
+      [
+        'language' => 'en',
+        'enabled' => 1,
+      ],
+      [
+        'language' => 'fr',
+        'enabled' => 1,
+      ],
+      [
+        'language' => 'is',
+        'enabled' => 1,
+      ],
+    ],
+  ];
+
+}
diff --git a/tests/src/Kernel/Plugin/migrate/source/PathautoSourceTestBase.php b/tests/src/Kernel/Plugin/migrate/source/PathautoSourceTestBase.php
new file mode 100644
index 0000000..51d2d87
--- /dev/null
+++ b/tests/src/Kernel/Plugin/migrate/source/PathautoSourceTestBase.php
@@ -0,0 +1,210 @@
+<?php
+
+namespace Drupal\Tests\pathauto\Kernel\Plugin\migrate\source;
+
+use Drupal\Core\Database\Database;
+use Drupal\migrate\Plugin\MigrateDestinationInterface;
+use Drupal\migrate\Row;
+use Drupal\Tests\migrate\Kernel\MigrateSqlSourceTestBase;
+
+/**
+ * Base class for testing pathauto source plugins with native databases.
+ *
+ * Most of the methods are copied from MigrateTestBase and are slightly
+ * modified.
+ *
+ * @see \Drupal\Tests\migrate\Kernel\MigrateTestBase
+ */
+abstract class PathautoSourceTestBase extends MigrateSqlSourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   *
+   * @see https://www.drupal.org/node/2909426
+   * @todo This should be changed to "protected" after Drupal core 8.x security
+   *   support ends.
+   */
+  public static $modules = [
+    'ctools',
+    'path',
+    'path_alias',
+    'pathauto',
+    'system',
+    'token',
+    'migrate_drupal',
+  ];
+
+  /**
+   * The source database connection.
+   *
+   * @var \Drupal\Core\Database\Connection
+   */
+  protected $sourceDatabase;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    $destination_plugin = $this->prophesize(MigrateDestinationInterface::class);
+    $destination_plugin->getPluginId()
+      ->willReturn($this->randomMachineName(16));
+    $this->migration->getDestinationPlugin()->willReturn(
+      $destination_plugin->reveal()
+    );
+
+    $this->createMigrationConnection();
+    $this->sourceDatabase = Database::getConnection('default', 'migrate');
+  }
+
+  /**
+   * Changes the database connection to the prefixed one.
+   *
+   * @see \Drupal\Tests\migrate\Kernel\MigrateTestBase::createMigrationConnection()
+   *
+   * @todo Refactor when core doesn't use global.
+   *   https://www.drupal.org/node/2552791
+   */
+  private function createMigrationConnection() {
+    // If the backup already exists, something went terribly wrong.
+    // This case is possible, because database connection info is a static
+    // global state construct on the Database class, which at least persists
+    // for all test methods executed in one PHP process.
+    if (Database::getConnectionInfo('simpletest_original_migrate')) {
+      throw new \RuntimeException("Bad Database connection state: 'simpletest_original_migrate' connection key already exists. Broken test?");
+    }
+
+    // Clone the current connection and replace the current prefix.
+    $connection_info = Database::getConnectionInfo('migrate');
+    if ($connection_info) {
+      Database::renameConnection('migrate', 'simpletest_original_migrate');
+    }
+    $connection_info = Database::getConnectionInfo('default');
+    foreach ($connection_info as $target => $value) {
+      $prefix = is_array($value['prefix']) ? $value['prefix']['default'] : $value['prefix'];
+      // Simpletest uses 7 character prefixes at most so this can't cause
+      // collisions.
+      $connection_info[$target]['prefix'] = ['default' => $prefix . '0' ];
+
+      // Add the original simpletest prefix so SQLite can attach its database.
+      // @see \Drupal\Core\Database\Driver\sqlite\Connection::init()
+      $connection_info[$target]['prefix'][$prefix] = $prefix;
+    }
+    Database::addConnectionInfo('migrate', 'default', $connection_info['default']);
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * @see \Drupal\Tests\migrate\Kernel\MigrateTestBase::cleanupMigrateConnection()
+   */
+  protected function tearDown(): void {
+    $this->cleanupMigrateConnection();
+    parent::tearDown();
+  }
+
+  /**
+   * Cleans up the test migrate connection.
+   *
+   * @see \Drupal\Tests\migrate\Kernel\MigrateTestBase::cleanupMigrateConnection()
+   *
+   * @todo Refactor when core doesn't use global.
+   *   https://www.drupal.org/node/2552791
+   */
+  private function cleanupMigrateConnection() {
+    Database::removeConnection('migrate');
+    $original_connection_info = Database::getConnectionInfo('simpletest_original_migrate');
+    if ($original_connection_info) {
+      Database::renameConnection('simpletest_original_migrate', 'migrate');
+    }
+  }
+
+  /**
+   * Loads a database fixture into the source database connection.
+   *
+   * @param array $database
+   *   The source data, keyed by table name. Each table is an array containing
+   *   the rows in that table.
+   */
+  protected function importSourceDatabase(array $database): void {
+    // Create the tables and fill them with data.
+    foreach ($database as $table => $rows) {
+      // Use the biggest row to build the table schema.
+      $counts = array_map('count', $rows);
+      asort($counts);
+      end($counts);
+      $pilot = $rows[key($counts)];
+      $schema = array_map(function ($value) {
+        $type = is_numeric($value) && !is_float($value + 0)
+          ? 'int'
+          : 'text';
+        return ['type' => $type];
+      }, $pilot);
+
+      $this->sourceDatabase->schema()
+        ->createTable($table, [
+          'fields' => $schema,
+        ]);
+
+      $fields = array_keys($pilot);
+      $insert = $this->sourceDatabase->insert($table)->fields($fields);
+      array_walk($rows, [$insert, 'values']);
+      $insert->execute();
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * @dataProvider providerSource
+   */
+  public function testSource(array $source_data, array $expected_data, $expected_count = NULL, array $configuration = [], $high_water = NULL, $expected_cache_key = NULL) {
+    $this->importSourceDatabase($source_data);
+    $plugin = $this->getPlugin($configuration);
+    $clone_plugin = clone $plugin;
+
+    // All source plugins must define IDs.
+    $this->assertNotEmpty($plugin->getIds());
+
+    // If there is a high water mark, set it in the high water storage.
+    if (isset($high_water)) {
+      $this->container
+        ->get('keyvalue')
+        ->get('migrate:high_water')
+        ->set($this->migration->reveal()->id(), $high_water);
+    }
+
+    if (is_null($expected_count)) {
+      $expected_count = count($expected_data);
+    }
+    // If an expected count was given, assert it only if the plugin is
+    // countable.
+    if (is_numeric($expected_count)) {
+      assert($plugin instanceof \Countable);
+      $this->assertCount($expected_count, $plugin);
+    }
+
+    $i = 0;
+    $actual_source_data = [];
+    foreach ($plugin as $row) {
+      assert($row instanceof Row);
+      $actual_source_data[$i++] = $row->getSource();
+    }
+
+    $this->assertEquals($expected_data, $actual_source_data, "Source values are different then expected.");
+
+    // False positives occur if the foreach is not entered. So, confirm the
+    // foreach loop was entered if the expected count is greater than 0.
+    if ($expected_count > 0) {
+      $this->assertGreaterThan(0, $i);
+
+      // Test that we can skip all rows.
+      \Drupal::state()->set('migrate_skip_all_rows_test_migrate_prepare_row', TRUE);
+      foreach ($clone_plugin as $row) {
+        $this->fail('Row not skipped');
+      }
+    }
+  }
+
+}
diff --git a/tests/src/Traits/PathautoMigrationAssertionsTrait.php b/tests/src/Traits/PathautoMigrationAssertionsTrait.php
new file mode 100644
index 0000000..78560fc
--- /dev/null
+++ b/tests/src/Traits/PathautoMigrationAssertionsTrait.php
@@ -0,0 +1,500 @@
+<?php
+
+namespace Drupal\Tests\pathauto\Traits;
+
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\pathauto\Entity\PathautoPattern;
+use Drupal\pathauto\PathautoPatternInterface;
+
+/**
+ * Trait for testing pathauto migration results.
+ */
+trait PathautoMigrationAssertionsTrait {
+
+  /**
+   * List of pathauto pattern properties which are irrelevant.
+   *
+   * @var string[]
+   */
+  protected $pathautoPatternUnconcernedProperties = [
+    'uuid',
+    'dependencies',
+  ];
+
+  /**
+   * Tests pathauto settings.
+   */
+  protected function assertPathautoSettings() {
+    $raw_data = $this->config('pathauto.settings')->getRawData();
+    unset($raw_data['_core']);
+    $this->assertEquals([
+      'enabled_entity_types' => [
+        0 => 'user',
+      ],
+      'punctuation' => [
+        'hyphen' => 1,
+        'ampersand' => 0,
+        'asterisk' => 0,
+        'at' => 0,
+        'backtick' => 0,
+        'back_slash' => 0,
+        'caret' => 0,
+        'colon' => 0,
+        'comma' => 0,
+        'dollar' => 0,
+        'double_quotes' => 0,
+        'equal' => 0,
+        'exclamation' => 0,
+        'greater_than' => 0,
+        'hash' => 0,
+        'left_curly' => 0,
+        'left_parenthesis' => 0,
+        'left_square' => 0,
+        'less_than' => 0,
+        'percent' => 0,
+        'period' => 0,
+        'pipe' => 0,
+        'plus' => 0,
+        'question_mark' => 0,
+        'quotes' => 0,
+        'right_curly' => 0,
+        'right_parenthesis' => 0,
+        'right_square' => 0,
+        'semicolon' => 0,
+        'slash' => 0,
+        'tilde' => 0,
+        'underscore' => 0,
+      ],
+      'verbose' => FALSE,
+      'separator' => '-',
+      'max_length' => 100,
+      'max_component_length' => 100,
+      'transliterate' => TRUE,
+      'reduce_ascii' => FALSE,
+      'case' => TRUE,
+      'ignore_words' => 'a, an, as, at, before, but, by, for, from, is, in, into, like, of, off, on, onto, per, since, than, the, this, that, to, up, via, with',
+      'update_action' => 2,
+      'safe_tokens' => [
+        0 => 'alias',
+        1 => 'path',
+        2 => 'join-path',
+        3 => 'login-url',
+        4 => 'url',
+        5 => 'url-brief',
+      ],
+    ], $raw_data);
+  }
+
+  /**
+   * Tests article node's pattern.
+   */
+  protected function assertNodeArticlePattern(?int $uuid_index = NULL) {
+    $pattern = PathautoPattern::load('node_article');
+    assert($pattern instanceof PathautoPatternInterface);
+    if (!isset($uuid_index)) {
+      $uuid_index = $this->multilingual ? 7 : 4;
+    }
+    $this->assertEquals([
+      'langcode' => 'en',
+      'status' => TRUE,
+      'id' => 'node_article',
+      'label' => 'Content - Article',
+      'type' => 'canonical_entities:node',
+      'pattern' => '[node:content-type]/[node:title]',
+      'selection_criteria' => [
+        "uuid{$uuid_index}" => [
+          'uuid' => "uuid{$uuid_index}",
+          'id' => 'entity_bundle:node',
+          'bundles' => [
+            'article' => 'article',
+          ],
+          'negate' => FALSE,
+          'context_mapping' => [
+            'node' => 'node',
+          ],
+        ],
+      ],
+      'selection_logic' => 'and',
+      'weight' => 0,
+      'relationships' => [],
+    ], $this->getImportantEntityProperties($pattern));
+  }
+
+  /**
+   * Tests article node's English pattern.
+   */
+  protected function assertNodeArticleEnPattern(int $uuid_index = 1) {
+    $pattern = PathautoPattern::load('node_article_en');
+    assert($pattern instanceof PathautoPatternInterface);
+    $next_uuid_index = $uuid_index + 1;
+    $this->assertEquals([
+      'langcode' => 'en',
+      'status' => TRUE,
+      'id' => 'node_article_en',
+      'label' => 'Content - Article (en)',
+      'type' => 'canonical_entities:node',
+      'pattern' => '[node:content-type]/en/[node:title]',
+      'selection_criteria' => [
+        "uuid{$uuid_index}" => [
+          'uuid' => "uuid{$uuid_index}",
+          'id' => 'entity_bundle:node',
+          'bundles' => [
+            'article' => 'article',
+          ],
+          'negate' => FALSE,
+          'context_mapping' => [
+            'node' => 'node',
+          ],
+        ],
+        "uuid{$next_uuid_index}" => [
+          'uuid' => "uuid{$next_uuid_index}",
+          'id' => 'language',
+          'langcodes' => [
+            'en' => 'en',
+          ],
+          'negate' => FALSE,
+          'context_mapping' => [
+            'language' => 'node:langcode:language',
+          ],
+        ],
+      ],
+      'selection_logic' => 'and',
+      'weight' => -1,
+      'relationships' => ['node:langcode:language' => ['label' => 'Language']],
+    ], $this->getImportantEntityProperties($pattern));
+  }
+
+  /**
+   * Tests article node's French pattern.
+   */
+  protected function assertNodeArticleFrPattern(int $uuid_index = 3) {
+    $pattern = PathautoPattern::load('node_article_fr');
+    assert($pattern instanceof PathautoPatternInterface);
+    $next_uuid_index = $uuid_index + 1;
+    $this->assertEquals([
+      'langcode' => 'en',
+      'status' => TRUE,
+      'id' => 'node_article_fr',
+      'label' => 'Content - Article (fr)',
+      'type' => 'canonical_entities:node',
+      'pattern' => '[node:content-type]/fr/[node:title]',
+      'selection_criteria' => [
+        "uuid{$uuid_index}" => [
+          'uuid' => "uuid{$uuid_index}",
+          'id' => 'entity_bundle:node',
+          'bundles' => [
+            'article' => 'article',
+          ],
+          'negate' => FALSE,
+          'context_mapping' => [
+            'node' => 'node',
+          ],
+        ],
+        "uuid{$next_uuid_index}" => [
+          'uuid' => "uuid{$next_uuid_index}",
+          'id' => 'language',
+          'langcodes' => [
+            'fr' => 'fr',
+          ],
+          'negate' => FALSE,
+          'context_mapping' => [
+            'language' => 'node:langcode:language',
+          ],
+        ],
+      ],
+      'selection_logic' => 'and',
+      'weight' => -1,
+      'relationships' => ['node:langcode:language' => ['label' => 'Language']],
+    ], $this->getImportantEntityProperties($pattern));
+  }
+
+  /**
+   * Tests article node's Icelandic pattern.
+   */
+  protected function assertNodeArticleIsPattern(int $uuid_index = 5) {
+    $pattern = PathautoPattern::load('node_article_is');
+    assert($pattern instanceof PathautoPatternInterface);
+    $next_uuid_index = $uuid_index + 1;
+    $this->assertEquals([
+      'langcode' => 'en',
+      'status' => TRUE,
+      'id' => 'node_article_is',
+      'label' => 'Content - Article (is)',
+      'type' => 'canonical_entities:node',
+      'pattern' => '[node:content-type]/is/[node:title]',
+      'selection_criteria' => [
+        "uuid{$uuid_index}" => [
+          'uuid' => "uuid{$uuid_index}",
+          'id' => 'entity_bundle:node',
+          'bundles' => [
+            'article' => 'article',
+          ],
+          'negate' => FALSE,
+          'context_mapping' => [
+            'node' => 'node',
+          ],
+        ],
+        "uuid{$next_uuid_index}" => [
+          'uuid' => "uuid{$next_uuid_index}",
+          'id' => 'language',
+          'langcodes' => [
+            'is' => 'is',
+          ],
+          'negate' => FALSE,
+          'context_mapping' => [
+            'language' => 'node:langcode:language',
+          ],
+        ],
+      ],
+      'selection_logic' => 'and',
+      'weight' => -1,
+      'relationships' => ['node:langcode:language' => ['label' => 'Language']],
+    ], $this->getImportantEntityProperties($pattern));
+  }
+
+  /**
+   * Tests blog node's pattern.
+   */
+  protected function assertNodeBlogPattern(?int $uuid_index = NULL) {
+    $pattern = PathautoPattern::load('node_blog');
+    assert($pattern instanceof PathautoPatternInterface);
+    if (!isset($uuid_index)) {
+      $uuid_index = $this->multilingual ? 8 : 5;
+    }
+    $this->assertEquals([
+      'langcode' => 'en',
+      'status' => TRUE,
+      'id' => 'node_blog',
+      'label' => 'Content - Blog entry',
+      'type' => 'canonical_entities:node',
+      'pattern' => 'blogs/[node:author:name]/[node:title]',
+      'selection_criteria' => [
+        "uuid{$uuid_index}" => [
+          'uuid' => "uuid{$uuid_index}",
+          'id' => 'entity_bundle:node',
+          'bundles' => [
+            'blog' => 'blog',
+          ],
+          'negate' => FALSE,
+          'context_mapping' => [
+            'node' => 'node',
+          ],
+        ],
+      ],
+      'selection_logic' => 'and',
+      'weight' => 0,
+      'relationships' => [],
+    ], $this->getImportantEntityProperties($pattern));
+  }
+
+  /**
+   * Tests et node's pattern.
+   */
+  protected function assertNodeEtPattern(?int $uuid_index = NULL) {
+    $pattern = PathautoPattern::load('node_et');
+    assert($pattern instanceof PathautoPatternInterface);
+    if (!isset($uuid_index)) {
+      $uuid_index = $this->multilingual ? 9 : 6;
+    }
+    $this->assertEquals([
+      'langcode' => 'en',
+      'status' => TRUE,
+      'id' => 'node_et',
+      'label' => 'Content - Entity translation test',
+      'type' => 'canonical_entities:node',
+      'pattern' => '[node:content-type]/[node:nid]/[node:title]',
+      'selection_criteria' => [
+        "uuid{$uuid_index}" => [
+          'uuid' => "uuid{$uuid_index}",
+          'id' => 'entity_bundle:node',
+          'bundles' => [
+            'et' => 'et',
+          ],
+          'negate' => FALSE,
+          'context_mapping' => [
+            'node' => 'node',
+          ],
+        ],
+      ],
+      'selection_logic' => 'and',
+      'weight' => 0,
+      'relationships' => [],
+    ], $this->getImportantEntityProperties($pattern));
+  }
+
+  /**
+   * Tests default node pattern.
+   */
+  protected function assertNodePattern() {
+    $pattern = PathautoPattern::load('node');
+    assert($pattern instanceof PathautoPatternInterface);
+    $this->assertEquals([
+      'langcode' => 'en',
+      'status' => TRUE,
+      'id' => 'node',
+      'label' => 'Content - default',
+      'type' => 'canonical_entities:node',
+      'pattern' => '[node:title]',
+      'selection_criteria' => [],
+      'selection_logic' => 'and',
+      'weight' => 1,
+      'relationships' => [],
+    ], $this->getImportantEntityProperties($pattern));
+  }
+
+  /**
+   * Tests tags taxonomy term's pattern.
+   */
+  protected function assertTermTagsPattern(?int $uuid_index = NULL) {
+    $pattern = PathautoPattern::load('taxonomy_term_tags');
+    assert($pattern instanceof PathautoPatternInterface);
+    if (!isset($uuid_index)) {
+      $uuid_index = $this->multilingual ? 11 : ($this->withForum ? 8 : 7);
+    }
+    $this->assertEquals([
+      'langcode' => 'en',
+      'status' => TRUE,
+      'id' => 'taxonomy_term_tags',
+      'label' => 'Taxonomy term - Tags',
+      'type' => 'canonical_entities:taxonomy_term',
+      'pattern' => 'tag/[term:name]',
+      'selection_criteria' => [
+        "uuid{$uuid_index}" => [
+          'uuid' => "uuid{$uuid_index}",
+          'id' => 'entity_bundle:taxonomy_term',
+          'bundles' => [
+            'tags' => 'tags',
+          ],
+          'negate' => FALSE,
+          'context_mapping' => [
+            'taxonomy_term' => 'taxonomy_term',
+          ],
+        ],
+      ],
+      'selection_logic' => 'and',
+      'weight' => 0,
+      'relationships' => [],
+    ], $this->getImportantEntityProperties($pattern));
+  }
+
+  /**
+   * Tests forums taxonomy term's pattern.
+   */
+  protected function assertTermForumsPattern(?int $uuid_index = NULL) {
+    $pattern = PathautoPattern::load('forum');
+    assert($pattern instanceof PathautoPatternInterface);
+    if (!isset($uuid_index)) {
+      $uuid_index = $this->multilingual ? 10 : 7;
+    }
+    $this->assertEquals([
+      'langcode' => 'en',
+      'status' => TRUE,
+      'id' => 'forum',
+      'label' => 'Taxonomy term - Sujet de discussion',
+      'type' => 'canonical_entities:taxonomy_term',
+      'pattern' => '[term:vocabulary]/[term:name]',
+      'selection_criteria' => [
+        "uuid{$uuid_index}" => [
+          'uuid' => "uuid{$uuid_index}",
+          'id' => 'entity_bundle:taxonomy_term',
+          'bundles' => [
+            'forums' => 'forums',
+          ],
+          'negate' => FALSE,
+          'context_mapping' => [
+            'taxonomy_term' => 'taxonomy_term',
+          ],
+        ],
+      ],
+      'selection_logic' => 'and',
+      'weight' => 0,
+      'relationships' => [],
+    ], $this->getImportantEntityProperties($pattern));
+  }
+
+  /**
+   * Tests default taxonomy term pattern.
+   */
+  protected function assertTermPattern() {
+    $pattern = PathautoPattern::load('taxonomy_term');
+    assert($pattern instanceof PathautoPatternInterface);
+    $this->assertEquals([
+      'langcode' => 'en',
+      'status' => TRUE,
+      'id' => 'taxonomy_term',
+      'label' => 'Taxonomy term - default',
+      'type' => 'canonical_entities:taxonomy_term',
+      'pattern' => '[term:vocabulary]/[term:name]',
+      'selection_criteria' => [],
+      'selection_logic' => 'and',
+      'weight' => 1,
+      'relationships' => [],
+    ], $this->getImportantEntityProperties($pattern));
+  }
+
+  /**
+   * Tests user's pattern.
+   */
+  protected function assertUserPattern() {
+    $pattern = PathautoPattern::load('user');
+    assert($pattern instanceof PathautoPatternInterface);
+    $this->assertEquals([
+      'langcode' => 'en',
+      'status' => TRUE,
+      'id' => 'user',
+      'label' => 'User - default',
+      'type' => 'canonical_entities:user',
+      'pattern' => 'users/[user:name]',
+      'selection_criteria' => [],
+      'selection_logic' => 'and',
+      'weight' => 1,
+      'relationships' => [],
+    ], $this->getImportantEntityProperties($pattern));
+  }
+
+  /**
+   * Tests every pattern of article node's, including language-specific ones.
+   */
+  protected function assertAllNodeArticlePatterns() {
+    $this->assertNodeArticlePattern();
+    $this->assertNodeArticleEnPattern();
+    $this->assertNodeArticleFrPattern();
+    $this->assertNodeArticleIsPattern();
+    $pattern_und = PathautoPattern::load('node_article_und');
+    $this->assertNull($pattern_und);
+  }
+
+  /**
+   * Filters out unconcerned properties from an entity.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   An entity instance.
+   *
+   * @return array
+   *   The important entity property values as array.
+   */
+  protected function getImportantEntityProperties(EntityInterface $entity) {
+    $entity_type_id = $entity->getEntityTypeId();
+    $exploded = explode('_', $entity_type_id);
+    $prop_prefix = count($exploded) > 1
+      ? $exploded[0] . implode('', array_map('ucfirst', array_slice($exploded, 1)))
+      : $entity_type_id;
+    $property_filter_preset_property = "{$prop_prefix}UnconcernedProperties";
+    $entity_array = $entity->toArray();
+    $unconcerned_properties = property_exists(get_class($this), $property_filter_preset_property)
+      ? $this->$property_filter_preset_property
+      : [
+        'uuid',
+        'langcode',
+        'dependencies',
+        '_core',
+      ];
+
+    foreach ($unconcerned_properties as $item) {
+      unset($entity_array[$item]);
+    }
+
+    return $entity_array;
+  }
+
+}
diff --git a/tests/src/Unit/Plugin/migrate/process/PathautoPatternSelectionCriteriaTest.php b/tests/src/Unit/Plugin/migrate/process/PathautoPatternSelectionCriteriaTest.php
new file mode 100644
index 0000000..b029cf4
--- /dev/null
+++ b/tests/src/Unit/Plugin/migrate/process/PathautoPatternSelectionCriteriaTest.php
@@ -0,0 +1,183 @@
+<?php
+
+namespace Drupal\Tests\pathauto\Unit\Plugin\migrate\process;
+
+use Drupal\Component\Uuid\UuidInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\migrate\MigrateSkipProcessException;
+use Drupal\pathauto\Plugin\migrate\process\PathautoPatternSelectionCriteria;
+use Drupal\migrate\MigrateExecutableInterface;
+use Drupal\migrate\Row;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * Tests the "pathauto_pattern_selection_criteria" migrate process plugin.
+ *
+ * @coversDefaultClass \Drupal\pathauto\Plugin\migrate\process\PathautoPatternSelectionCriteria
+ * @group pathauto
+ */
+class PathautoPatternSelectionCriteriaTest extends UnitTestCase {
+
+  /**
+   * Tests the "pathauto_pattern_selection_criteria" migrate process plugin.
+   *
+   * @param array $source
+   *   The source value for the plugin.
+   * @param array|null $expected
+   *   The expected result.
+   * @param string[]|null $expected_exception
+   *   The expected exception's class and message, or NULL.
+   *
+   * @covers ::transform
+   *
+   * @dataProvider providerTestTransform
+   */
+  public function testTransform(array $source, $expected, $expected_exception) {
+    $executable = $this->prophesize(MigrateExecutableInterface::class)
+      ->reveal();
+    if (empty($row)) {
+      $row = $this->prophesize(Row::class)->reveal();
+    }
+
+    $uuid_generator = $this->prophesize(UuidInterface::class);
+    $uuid_generator->generate()->willReturn('uuid1', 'uuid2');
+
+    $test_entity_type_definition = $this->prophesize(EntityTypeInterface::class);
+    $test_entity_type_definition->getKey('langcode')->willReturn('test_langcode_property');
+
+    $node_definition = $this->prophesize(EntityTypeInterface::class);
+    $node_definition->getKey('langcode')->willReturn('langcode');
+
+    $entity_type_manager = $this->prophesize(EntityTypeManagerInterface::class);
+    $entity_type_manager->hasDefinition('test_entity_type')->willReturn(TRUE);
+    $entity_type_manager->getDefinition('test_entity_type')->willReturn($test_entity_type_definition->reveal());
+    $entity_type_manager->hasDefinition('node')->willReturn(TRUE);
+    $entity_type_manager->getDefinition('ndoe')->willReturn($node_definition->reveal());
+
+    $plugin = new PathautoPatternSelectionCriteria(
+      [],
+      'pathauto_pattern_selection_criteria',
+      [],
+      $entity_type_manager->reveal(),
+      $uuid_generator->reveal()
+    );
+
+    if ($expected_exception) {
+      [
+        'class' => $class,
+        'message' => $message,
+      ] = $expected_exception;
+      $this->expectException($class);
+      $this->expectExceptionMessage($message);
+    }
+    $actual = $plugin->transform($source, $executable, $row, 'destination_prop');
+    $this->assertSame($expected, $actual);
+  }
+
+  /**
+   * Data provider for ::testTransform.
+   */
+  public function providerTestTransform() {
+    return [
+      'Node with bundle, no language set' => [
+        'source' => [
+          'node',
+          'article',
+          NULL,
+        ],
+        'expected' => [
+          'uuid1' => [
+            'uuid' => 'uuid1',
+            'id' => 'entity_bundle:node',
+            'bundles' => [
+              'article' => 'article',
+            ],
+            'negate' => FALSE,
+            'context_mapping' => [
+              'node' => 'node',
+            ],
+          ],
+        ],
+        'exception' => NULL,
+      ],
+      'Entity with bundle, without language' => [
+        'source' => [
+          'test_entity_type',
+          'test_bundle',
+          NULL,
+        ],
+        'expected' => [
+          'uuid1' => [
+            'uuid' => 'uuid1',
+            'id' => 'entity_bundle:test_entity_type',
+            'bundles' => [
+              'test_bundle' => 'test_bundle',
+            ],
+            'negate' => FALSE,
+            'context_mapping' => [
+              'test_entity_type' => 'test_entity_type',
+            ],
+          ],
+        ],
+        'exception' => NULL,
+      ],
+      'Entity with bundle and language' => [
+        'source' => [
+          'test_entity_type',
+          'test_bundle',
+          'test_langcode',
+        ],
+        'expected' => [
+          'uuid1' => [
+            'uuid' => 'uuid1',
+            'id' => 'entity_bundle:test_entity_type',
+            'bundles' => [
+              'test_bundle' => 'test_bundle',
+            ],
+            'negate' => FALSE,
+            'context_mapping' => [
+              'test_entity_type' => 'test_entity_type',
+            ],
+          ],
+          'uuid2' => [
+            'uuid' => 'uuid2',
+            'id' => 'language',
+            'langcodes' => [
+              'test_langcode' => 'test_langcode',
+            ],
+            'negate' => FALSE,
+            'context_mapping' => [
+              'language' => 'test_entity_type:test_langcode_property:language',
+            ],
+          ],
+        ],
+        'exception' => NULL,
+      ],
+      'Exception - not enough parameters' => [
+        'source' => [
+          'test_entity_type',
+          NULL,
+        ],
+        'expected' => NULL,
+        'exception' => [
+          'class' => MigrateSkipProcessException::class,
+          'message' => 'The entity_type, the bundle, the langcode or more of these sources are missing.',
+        ],
+      ],
+      'Exception - entity_type is not a string' => [
+        'source' => [
+          NULL,
+          NULL,
+          NULL,
+        ],
+        'expected' => NULL,
+        'exception' => [
+          'class' => MigrateSkipProcessException::class,
+          'message' => 'The entity_type must be a string.',
+        ],
+      ],
+    ];
+  }
+
+}
