diff --git a/src/Form/MigrateUpgradeForm.php b/src/Form/MigrateUpgradeForm.php
index e4cd6fe..a77ba0f 100644
--- a/src/Form/MigrateUpgradeForm.php
+++ b/src/Form/MigrateUpgradeForm.php
@@ -738,7 +738,13 @@ class MigrateUpgradeForm extends FormBase implements ConfirmFormInterface {
 
     $drivers = drupal_get_database_types();
     $drivers_keys = array_keys($drivers);
+    // @todo https://www.drupal.org/node/2678510 Because this is a multi-step
+    //   form, the form is not rebuilt during submission. Ideally we would get
+    //   the chosen driver from form input, if available, in order to use
+    //   #limit_validation_errors in the same way
+    //   \Drupal\Core\Installer\Form\SiteSettingsForm does.
     $default_driver = current($drivers_keys);
+
     $default_options = [];
 
     $form['database'] = [
@@ -763,6 +769,12 @@ class MigrateUpgradeForm extends FormBase implements ConfirmFormInterface {
       $form['database']['driver']['#options'][$key] = $driver->name();
 
       $form['database']['settings'][$key] = $driver->getFormOptions($default_options);
+      // @todo https://www.drupal.org/node/2678510 Using
+      //   #limit_validation_errors in the submit does not work so it is not
+      //   possible to require the database and username for mysql and pgsql.
+      //   This is because this is a multi-step form.
+      $form['database']['settings'][$key]['database']['#required'] = FALSE;
+      $form['database']['settings'][$key]['username']['#required'] = FALSE;
       $form['database']['settings'][$key]['#prefix'] = '<h2 class="js-hide">' . $this->t('@driver_name settings', ['@driver_name' => $driver->name()]) . '</h2>';
       $form['database']['settings'][$key]['#type'] = 'container';
       $form['database']['settings'][$key]['#tree'] = TRUE;
@@ -808,10 +820,6 @@ class MigrateUpgradeForm extends FormBase implements ConfirmFormInterface {
       '#type' => 'submit',
       '#value' => $this->t('Review upgrade'),
       '#button_type' => 'primary',
-      '#limit_validation_errors' => [
-        ['driver'],
-        [$default_driver],
-      ],
       '#validate' => ['::validateCredentialForm'],
       '#submit' => ['::submitCredentialForm'],
     ];
diff --git a/src/Tests/MigrateUpgradeTestBase.php b/src/Tests/MigrateUpgradeTestBase.php
new file mode 100644
index 0000000..527b3d8
--- /dev/null
+++ b/src/Tests/MigrateUpgradeTestBase.php
@@ -0,0 +1,186 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\migrate_upgrade\Tests\MigrateUpgradeTestBase.
+ */
+
+namespace Drupal\migrate_upgrade\Tests;
+
+use Drupal\Core\Database\Database;
+use Drupal\simpletest\WebTestBase;
+
+/**
+ * Provides a base class for testing migration upgrades in the UI.
+ */
+abstract class MigrateUpgradeTestBase extends WebTestBase {
+
+  /**
+   * Use the Standard profile to test help implementations of many core modules.
+   */
+  protected $profile = 'standard';
+
+  /**
+   * The source database connection.
+   *
+   * @var \Drupal\Core\Database\Connection
+   */
+  protected $sourceDatabase;
+
+  /**
+   * Modules to enable.
+   *
+   * @var array
+   */
+  public static $modules = ['migrate_upgrade'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $this->createMigrationConnection();
+    $this->sourceDatabase = Database::getConnection('default', 'migrate_upgrade');
+
+    // Create and log in as user 1. Migrations in the UI can only be performed
+    // as user 1.
+    $this->drupalLogin($this->rootUser);
+  }
+
+  /**
+   * Loads a database fixture into the source database connection.
+   *
+   * @param string $path
+   *   Path to the dump file.
+   */
+  protected function loadFixture($path) {
+    $default_db = Database::getConnection()->getKey();
+    Database::setActiveConnection($this->sourceDatabase->getKey());
+
+    if (substr($path, -3) == '.gz') {
+      $path = 'compress.zlib://' . $path;
+    }
+    require $path;
+
+    Database::setActiveConnection($default_db);
+  }
+
+  /**
+   * Changes the database connection to the prefixed one.
+   *
+   * @todo Remove when we don't use global. https://www.drupal.org/node/2552791
+   */
+  protected function createMigrationConnection() {
+    $connection_info = Database::getConnectionInfo('default')['default'];
+    if ($connection_info['driver'] === 'sqlite') {
+      // Create database file in the test site's public file directory so that
+      // \Drupal\simpletest\TestBase::restoreEnvironment() will delete this once
+      // the test is complete.
+      $file = $this->publicFilesDirectory . '/' . $this->testId . '-migrate.db.sqlite';
+      touch($file);
+      $connection_info['database'] = $file;
+      $connection_info['prefix'] = '';
+    }
+    else {
+      $prefix = is_array($connection_info['prefix']) ? $connection_info['prefix']['default'] : $connection_info['prefix'];
+      // Simpletest uses fixed length prefixes. Create a new prefix for the
+      // source database. Adding to the end of the prefix ensures that
+      // \Drupal\simpletest\TestBase::restoreEnvironment() will remove the
+      // additional tables.
+      $connection_info['prefix'] = $prefix . '0';
+    }
+
+    Database::addConnectionInfo('migrate_upgrade', 'default', $connection_info);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function tearDown() {
+    Database::removeConnection('migrate_upgrade');
+    parent::tearDown();
+  }
+
+  /**
+   * Executes all steps of migrations upgrade.
+   */
+  protected function testMigrateUpgrade() {
+    $connection_options = $this->sourceDatabase->getConnectionOptions();
+
+    foreach (array_keys(\Drupal::entityTypeManager()->getDefinitions()) as $entity_type) {
+      $original_counts[$entity_type] = count(\Drupal::entityTypeManager()->getStorage($entity_type)->loadMultiple());
+    }
+
+    $this->drupalGet('/upgrade');
+    $this->assertText('Upgrade a Drupal site by importing it into a clean and empty new install of Drupal 8. You will lose any existing configuration once you import your site into it. See the upgrading handbook for more detailed information.');
+
+    $this->drupalPostForm(NULL, [], t('Continue'));
+    $this->assertText('Provide credentials for the database of the Drupal site you want to upgrade.');
+    $this->assertFieldByName('mysql[host]');
+
+    $driver = $connection_options['driver'];
+    $connection_options['prefix'] = $connection_options['prefix']['default'];
+
+    // 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']);
+    $edits = $this->translatePostValues([
+      'driver' => $driver,
+      $driver => $connection_options,
+      'source_base_path' => $this->getSourceBasePath(),
+    ]);
+
+    $this->drupalPostForm(NULL, $edits, t('Review upgrade'));
+    $this->assertResponse(200);
+    $this->assertText('Are you sure?');
+    $this->drupalPostForm(NULL, [], t('Perform upgrade'));
+    $this->assertText(t('Congratulations, you upgraded Drupal!'));
+
+    // Have to reset all the statics after migration to ensure entities are
+    // loadable.
+    $this->resetAll();
+
+    $expected_counts = $this->getEntityCounts();
+    foreach (array_keys(\Drupal::entityTypeManager()->getDefinitions()) as $entity_type) {
+      $real_count = count(\Drupal::entityTypeManager()->getStorage($entity_type)->loadMultiple());
+      $expected_count = isset($expected_counts[$entity_type]) ? $expected_counts[$entity_type] : 0;
+      $this->assertEqual($expected_count, $real_count, "Found $real_count $entity_type entities, expected $expected_count.");
+    }
+
+    // Test the rollback process.
+    $this->drupalGet('/upgrade');
+    $this->assertText('An upgrade has already been performed on this site.');
+
+    $edits = ['upgrade_option' => 2];
+    $this->drupalPostForm(NULL, $edits, t('Continue'));
+    $this->assertText('All previously-imported content, as well as configuration such as field definitions, will be removed.');
+
+    $this->drupalPostForm(NULL, [], t('Perform rollback'));
+    $this->assertText('Rollback of the upgrade is complete - you may now start the upgrade process from scratch.');
+
+    foreach (array_keys(\Drupal::entityTypeManager()->getDefinitions()) as $entity_type) {
+      $real_count = count(\Drupal::entityTypeManager()->getStorage($entity_type)->loadMultiple());
+      $expected_count = isset($original_counts[$entity_type]) ? $original_counts[$entity_type] : 0;
+      $this->assertEqual($expected_count, $real_count, "Found $real_count $entity_type entities, expected $expected_count.");
+    }
+  }
+
+  /**
+   * Gets the source base path for the concrete test.
+   *
+   * @return string
+   *   The source base path.
+   */
+  abstract protected function getSourceBasePath();
+
+  /**
+   * Gets the expected number of entities per entity type after migration.
+   *
+   * @return int[]
+   *   An array of expected counts keyed by entity type ID.
+   */
+  abstract protected function getEntityCounts();
+
+}
diff --git a/src/Tests/d6/MigrateUpgrade6Test.php b/src/Tests/d6/MigrateUpgrade6Test.php
new file mode 100644
index 0000000..0e20b8f
--- /dev/null
+++ b/src/Tests/d6/MigrateUpgrade6Test.php
@@ -0,0 +1,78 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\migrate_upgrade\Tests\d6\MigrateUpgrade6Test.
+ */
+
+namespace Drupal\migrate_upgrade\Tests\d6;
+
+use Drupal\migrate_upgrade\Tests\MigrateUpgradeTestBase;
+
+/**
+ * Tests Drupal 6 upgrade using the migrate UI.
+ *
+ * The test method is provided by the MigrateUpgradeTestBase class.
+ *
+ * @group migrate_upgrade
+ */
+class MigrateUpgrade6Test extends MigrateUpgradeTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $this->loadFixture(drupal_get_path('module', 'migrate_drupal') . '/tests/fixtures/drupal6.php');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getSourceBasePath() {
+    return __DIR__ . '/files';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getEntityCounts() {
+    return [
+      'block' => 30,
+      'block_content' => 2,
+      'block_content_type' => 1,
+      'comment' => 3,
+      'comment_type' => 2,
+      'contact_form' => 5,
+      'editor' => 2,
+      'field_config' => 61,
+      'field_storage_config' => 42,
+      'file' => 4,
+      'filter_format' => 8,
+      'image_style' => 5,
+      'migration' => 105,
+      'node' => 9,
+      'node_type' => 11,
+      'rdf_mapping' => 5,
+      'search_page' => 2,
+      'shortcut' => 2,
+      'shortcut_set' => 1,
+      'action' => 22,
+      'menu' => 8,
+      'taxonomy_term' => 6,
+      'taxonomy_vocabulary' => 6,
+      'tour' => 1,
+      'user' => 7,
+      'user_role' => 6,
+      'menu_link_content' => 4,
+      'view' => 12,
+      'date_format' => 11,
+      'entity_form_display' => 15,
+      'entity_form_mode' => 1,
+      'entity_view_display' => 32,
+      'entity_view_mode' => 12,
+      'base_field_override' => 33,
+    ];
+  }
+
+}
diff --git a/src/Tests/d7/MigrateUpgrade7Test.php b/src/Tests/d7/MigrateUpgrade7Test.php
new file mode 100644
index 0000000..d9d57c4
--- /dev/null
+++ b/src/Tests/d7/MigrateUpgrade7Test.php
@@ -0,0 +1,78 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\migrate_upgrade\Tests\d7\MigrateUpgrade7Test.
+ */
+
+namespace Drupal\migrate_upgrade\Tests\d7;
+
+use Drupal\migrate_upgrade\Tests\MigrateUpgradeTestBase;
+
+/**
+ * Tests Drupal 7 upgrade using the migrate UI.
+ *
+ * The test method is provided by the MigrateUpgradeTestBase class.
+ *
+ * @group migrate_upgrade
+ */
+class MigrateUpgrade7Test extends MigrateUpgradeTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $this->loadFixture(drupal_get_path('module', 'migrate_drupal') . '/tests/fixtures/drupal7.php');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getSourceBasePath() {
+    return __DIR__ . '/files';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getEntityCounts() {
+    return [
+      'block' => 25,
+      'block_content' => 1,
+      'block_content_type' => 1,
+      'comment' => 1,
+      'comment_type' => 7,
+      'contact_form' => 3,
+      'editor' => 2,
+      'field_config' => 40,
+      'field_storage_config' => 30,
+      'file' => 0,
+      'filter_format' => 7,
+      'image_style' => 6,
+      'migration' => 59,
+      'node' => 2,
+      'node_type' => 6,
+      'rdf_mapping' => 5,
+      'search_page' => 2,
+      'shortcut' => 6,
+      'shortcut_set' => 2,
+      'action' => 18,
+      'menu' => 10,
+      'taxonomy_term' => 18,
+      'taxonomy_vocabulary' => 3,
+      'tour' => 1,
+      'user' => 3,
+      'user_role' => 4,
+      'menu_link_content' => 9,
+      'view' => 12,
+      'date_format' => 11,
+      'entity_form_display' => 15,
+      'entity_form_mode' => 1,
+      'entity_view_display' => 22,
+      'entity_view_mode' => 10,
+      'base_field_override' => 7,
+    ];
+  }
+
+}
