diff --git a/frontend/test/__support__/e2e/cypress.js b/frontend/test/__support__/e2e/cypress.js
index 8f7aca0ff1e2d81015c063d833688900341f5b2f..774b673abac8bb0bcc847723296500fc3c10e763 100644
--- a/frontend/test/__support__/e2e/cypress.js
+++ b/frontend/test/__support__/e2e/cypress.js
@@ -2,372 +2,21 @@ import "@testing-library/cypress/add-commands";
 import "cypress-real-events/support";
 import "@cypress/skip-test/support";
 import "./commands";
-import _ from "underscore";
 
 export const version = require("../../../../version.json");
 
-export function snapshot(name) {
-  cy.request("POST", `/api/testing/snapshot/${name}`);
-}
-
-export function restore(name = "default") {
-  cy.log("Restore Data Set");
-  cy.request("POST", `/api/testing/restore/${name}`);
-}
-
-// various Metabase-specific "scoping" functions like inside popover/modal/navbar/main/sidebar content area
-export function popover() {
-  return cy.get(".PopoverContainer.PopoverContainer--open");
-}
-
-export function modal() {
-  return cy.get(".ModalContainer .ModalContent");
-}
-
-export function nav() {
-  return cy.get("nav");
-}
-
-export function main() {
-  return cy.get("nav").next();
-}
-
-export function sidebar() {
-  return cy.get("aside");
-}
-
-export function browse() {
-  // takes you to `/browse` (reflecting changes made in `0.38-collection-redesign)
-  return cy.get(".Nav .Icon-table_spaced");
-}
-
-/**
- * Get the `fieldset` HTML element that we use as a filter widget container.
- *
- * @returns HTMLFieldSetElement
- *
- * @example
- * // Simple SQL filter widget (works for "Text" and "Number" SQL variable types)
- * filterWidget().type("123");
- *
- * @example
- * // Filter widget that opens some other type of a filter picker (search, dropdown, input)
- * filterWidget()
- *  .contains("Search")
- *  .click();
- *
- * @todo Add the ability to choose between multiple widgets using their index.
- * @todo Add the ability to alias the chosen filter widget.
- * @todo Extract into a separate helper file.
- */
-export function filterWidget() {
-  return cy.get("fieldset");
-}
-
-// Metabase utility functions for commonly-used patterns
-export function selectDashboardFilter(selection, filterName) {
-  selection.contains("Select…").click();
-  popover()
-    .contains(filterName)
-    .click({ force: true });
-}
-
-export function openTable({ database = 1, table, mode = null } = {}) {
-  const url = "/question/new?";
-  const params = new URLSearchParams({ database, table });
-
-  if (mode === "notebook") {
-    params.append("mode", mode);
-  }
-
-  cy.visit(url + params.toString());
-}
-
-export function openProductsTable({ mode } = {}) {
-  return openTable({ table: 1, mode });
-}
-
-export function openOrdersTable({ mode } = {}) {
-  return openTable({ table: 2, mode });
-}
-
-export function openPeopleTable({ mode } = {}) {
-  return openTable({ table: 3, mode });
-}
-
-export function openReviewsTable({ mode } = {}) {
-  return openTable({ table: 4, mode });
-}
-
-export function setupLocalHostEmail() {
-  // Email info
-  cy.findByPlaceholderText("smtp.yourservice.com").type("localhost");
-  cy.findByPlaceholderText("587").type("1025");
-  cy.findByText("None").click();
-  // Leaves password and username blank
-  cy.findByPlaceholderText("metabase@yourcompany.com").type("test@local.host");
-
-  // *** Unnecessary click (metabase#12692)
-  cy.findByPlaceholderText("smtp.yourservice.com").click();
-
-  cy.findByText("Save changes").click();
-  cy.findByText("Changes saved!");
-
-  cy.findByText("Send test email").click();
-}
-
-// Find a text field by label text, type it in, then blur the field.
-// Commonly used in our Admin section as we auto-save settings.
-export function typeAndBlurUsingLabel(label, value) {
-  cy.findByLabelText(label)
-    .clear()
-    .type(value)
-    .blur();
-}
+export * from "./helpers/e2e-setup-helpers";
+export * from "./helpers/e2e-ui-elements-helpers";
+export * from "./helpers/e2e-dashboard-helpers";
+export * from "./helpers/e2e-open-table-helpers";
+export * from "./helpers/e2e-database-metadata-helpers";
+export * from "./helpers/e2e-qa-databases-helpers";
+export * from "./helpers/e2e-ad-hoc-question-helpers";
+export * from "./helpers/e2e-enterprise-helpers";
+export * from "./helpers/e2e-mock-app-settings-helpers";
+export * from "./helpers/e2e-assertion-helpers";
+export * from "./helpers/e2e-data-model-helpers";
+export * from "./helpers/e2e-misc-helpers";
+export * from "./helpers/e2e-deprecated-helpers";
 
 Cypress.on("uncaught:exception", (err, runnable) => false);
-
-export function withDatabase(databaseId, f) {
-  cy.request("GET", `/api/database/${databaseId}/metadata`).then(({ body }) => {
-    const database = {};
-    for (const table of body.tables) {
-      const fields = {};
-      for (const field of table.fields) {
-        fields[field.name.toUpperCase()] = field.id;
-      }
-      database[table.name.toUpperCase()] = fields;
-      database[table.name.toUpperCase() + "_ID"] = table.id;
-    }
-    f(database);
-  });
-}
-
-export function withSampleDataset(f) {
-  return withDatabase(1, f);
-}
-
-export function visitAlias(alias) {
-  cy.get(alias).then(url => {
-    cy.visit(url);
-  });
-}
-
-export function createNativeQuestion(name, query) {
-  return cy.request("POST", "/api/card", {
-    name,
-    dataset_query: {
-      type: "native",
-      native: { query },
-      database: 1,
-    },
-    display: "table",
-    visualization_settings: {},
-  });
-}
-
-export const describeWithToken = Cypress.env("HAS_ENTERPRISE_TOKEN")
-  ? describe
-  : describe.skip;
-
-// TODO: does this really need to be a global helper function?
-export function createBasicAlert({ firstAlert, includeNormal } = {}) {
-  cy.get(".Icon-bell").click();
-  if (firstAlert) {
-    cy.findByText("Set up an alert").click();
-  }
-  cy.findByText("Let's set up your alert");
-  if (includeNormal) {
-    cy.findByText("Email alerts to:")
-      .parent()
-      .children()
-      .last()
-      .click();
-    cy.findByText("Robert Tableton").click();
-  }
-  cy.findByText("Done").click();
-  cy.findByText("Let's set up your alert").should("not.exist");
-}
-
-export function setupDummySMTP() {
-  cy.log("Set up dummy SMTP server");
-  cy.request("PUT", "/api/setting", {
-    "email-smtp-host": "smtp.foo.test",
-    "email-smtp-port": "587",
-    "email-smtp-security": "none",
-    "email-smtp-username": "nevermind",
-    "email-smtp-password": "it-is-secret-NOT",
-    "email-from-address": "nonexisting@metabase.test",
-  });
-}
-
-export function expectedRouteCalls({ route_alias, calls } = {}) {
-  const requestsCount = alias =>
-    cy.state("requests").filter(req => req.alias === alias);
-  // It is hard and unreliable to assert that something didn't happen in Cypress
-  // This solution was the only one that worked out of all others proposed in this SO topic: https://stackoverflow.com/a/59302542/8815185
-  cy.get("@" + route_alias).then(() => {
-    expect(requestsCount(route_alias)).to.have.length(calls);
-  });
-}
-
-export function remapDisplayValueToFK({ display_value, name, fk } = {}) {
-  // Both display_value and fk are expected to be field IDs
-  // You can get them from frontend/test/__support__/e2e/cypress_sample_dataset.json
-  cy.request("POST", `/api/field/${display_value}/dimension`, {
-    field_id: display_value,
-    name,
-    human_readable_field_id: fk,
-    type: "external",
-  });
-}
-
-/*****************************************
- **            QA DATABASES             **
- ******************************************/
-
-export function addMongoDatabase(name = "QA Mongo4") {
-  // https://hub.docker.com/layers/metabase/qa-databases/mongo-sample-4.0/images/sha256-3f568127248b6c6dba0b114b65dc3b3bf69bf4c804310eb57b4e3de6eda989cf
-  addQADatabase("mongo", name, 27017);
-}
-
-export function addPostgresDatabase(name = "QA Postgres12") {
-  // https://hub.docker.com/layers/metabase/qa-databases/postgres-sample-12/images/sha256-80bbef27dc52552d6dc64b52796ba356d7541e7bba172740336d7b8a64859cf8
-  addQADatabase("postgres", name, 5432);
-}
-
-export function addMySQLDatabase(name = "QA MySQL8") {
-  // https://hub.docker.com/layers/metabase/qa-databases/mysql-sample-8/images/sha256-df67db50379ec59ac3a437b5205871f85ab519ce8d2cdc526e9313354d00f9d4
-  addQADatabase("mysql", name, 3306);
-}
-
-function addQADatabase(engine, db_display_name, port) {
-  const PASS_KEY = engine === "mongo" ? "pass" : "password";
-  const AUTH_DB = engine === "mongo" ? "admin" : null;
-  const OPTIONS = engine === "mysql" ? "allowPublicKeyRetrieval=true" : null;
-
-  cy.log(`**-- Adding ${engine.toUpperCase()} DB --**`);
-  cy.request("POST", "/api/database", {
-    engine: engine,
-    name: db_display_name,
-    details: {
-      dbname: "sample",
-      host: "localhost",
-      port: port,
-      user: "metabase",
-      [PASS_KEY]: "metasample123", // NOTE: we're inconsistent in where we use `pass` vs `password` as a key
-      authdb: AUTH_DB,
-      "additional-options": OPTIONS,
-      "use-srv": false,
-      "tunnel-enabled": false,
-    },
-    auto_run_queries: true,
-    is_full_sync: true,
-    schedules: {
-      cache_field_values: {
-        schedule_day: null,
-        schedule_frame: null,
-        schedule_hour: 0,
-        schedule_type: "daily",
-      },
-      metadata_sync: {
-        schedule_day: null,
-        schedule_frame: null,
-        schedule_hour: null,
-        schedule_type: "hourly",
-      },
-    },
-  }).then(({ status }) => {
-    expect(status).to.equal(200);
-  });
-
-  // Make sure we have all the metadata because we'll need to use it in tests
-  cy.request("POST", "/api/database/2/sync_schema").then(({ status }) => {
-    expect(status).to.equal(200);
-  });
-  cy.request("POST", "/api/database/2/rescan_values").then(({ status }) => {
-    expect(status).to.equal(200);
-  });
-}
-
-export function adhocQuestionHash(question) {
-  if (question.display) {
-    // without "locking" the display, the QB will run its picking logic and override the setting
-    question = Object.assign({}, question, { displayIsLocked: true });
-  }
-  return btoa(unescape(encodeURIComponent(JSON.stringify(question))));
-}
-
-export function visitQuestionAdhoc(question) {
-  cy.visit("/question#" + adhocQuestionHash(question));
-}
-
-export function getIframeBody(selector = "iframe") {
-  return cy
-    .get(selector)
-    .its("0.contentDocument")
-    .should("exist")
-    .its("body")
-    .should("not.be.null")
-    .then(cy.wrap);
-}
-
-function mockProperty(propertyOrObject, value, url) {
-  cy.intercept("GET", url, req => {
-    req.reply(res => {
-      if (typeof propertyOrObject === "object") {
-        Object.assign(res.body, propertyOrObject);
-      }
-      {
-        res.body[propertyOrObject] = value;
-      }
-    });
-  });
-}
-
-export function mockSessionProperty(propertyOrObject, value) {
-  mockProperty(propertyOrObject, value, "/api/session/properties");
-}
-
-export function mockCurrentUserProperty(propertyOrObject, value) {
-  mockProperty(propertyOrObject, value, "/api/user/current");
-}
-
-export function showDashboardCardActions(index = 0) {
-  cy.get(".DashCard")
-    .eq(index)
-    .realHover();
-}
-
-export function generateUsers(count, groupIds) {
-  const users = _.range(count).map(index => ({
-    first_name: `FirstName ${index}`,
-    last_name: `LastName ${index}`,
-    email: `user_${index}@metabase.com`,
-    password: `secure password ${index}`,
-    groupIds,
-  }));
-
-  users.forEach(u => cy.createUserFromRawData(u));
-
-  return users;
-}
-
-export function enableSharingQuestion(id) {
-  cy.request("POST", `/api/card/${id}/public_link`);
-}
-
-/**
- * Open native (SQL) editor and alias it.
- *
- * @param {string} alias - The alias that can be used later in the test as `cy.get("@" + alias)`.
- * @example
- * openNativeEditor().type("SELECT 123");
- */
-export function openNativeEditor(alias = "editor") {
-  cy.visit("/");
-  cy.icon("sql").click();
-  return cy
-    .get(".ace_content")
-    .as(alias)
-    .should("be.visible");
-}
diff --git a/frontend/test/__support__/e2e/helpers/e2e-ad-hoc-question-helpers.js b/frontend/test/__support__/e2e/helpers/e2e-ad-hoc-question-helpers.js
new file mode 100644
index 0000000000000000000000000000000000000000..70638edc1c7dc864568c9b392f882bfe059daff2
--- /dev/null
+++ b/frontend/test/__support__/e2e/helpers/e2e-ad-hoc-question-helpers.js
@@ -0,0 +1,11 @@
+export function adhocQuestionHash(question) {
+  if (question.display) {
+    // without "locking" the display, the QB will run its picking logic and override the setting
+    question = Object.assign({}, question, { displayIsLocked: true });
+  }
+  return btoa(unescape(encodeURIComponent(JSON.stringify(question))));
+}
+
+export function visitQuestionAdhoc(question) {
+  cy.visit("/question#" + adhocQuestionHash(question));
+}
diff --git a/frontend/test/__support__/e2e/helpers/e2e-assertion-helpers.js b/frontend/test/__support__/e2e/helpers/e2e-assertion-helpers.js
new file mode 100644
index 0000000000000000000000000000000000000000..b4c81d9b00e837f5f3d66e2cde6501a4b176b782
--- /dev/null
+++ b/frontend/test/__support__/e2e/helpers/e2e-assertion-helpers.js
@@ -0,0 +1,9 @@
+export function expectedRouteCalls({ route_alias, calls } = {}) {
+  const requestsCount = alias =>
+    cy.state("requests").filter(req => req.alias === alias);
+  // It is hard and unreliable to assert that something didn't happen in Cypress
+  // This solution was the only one that worked out of all others proposed in this SO topic: https://stackoverflow.com/a/59302542/8815185
+  cy.get("@" + route_alias).then(() => {
+    expect(requestsCount(route_alias)).to.have.length(calls);
+  });
+}
diff --git a/frontend/test/__support__/e2e/helpers/e2e-dashboard-helpers.js b/frontend/test/__support__/e2e/helpers/e2e-dashboard-helpers.js
new file mode 100644
index 0000000000000000000000000000000000000000..4d59621126c421e2824349c22396bd774da6816f
--- /dev/null
+++ b/frontend/test/__support__/e2e/helpers/e2e-dashboard-helpers.js
@@ -0,0 +1,15 @@
+import { popover } from "./e2e-ui-elements-helpers";
+
+// Metabase utility functions for commonly-used patterns
+export function selectDashboardFilter(selection, filterName) {
+  selection.contains("Select…").click();
+  popover()
+    .contains(filterName)
+    .click({ force: true });
+}
+
+export function showDashboardCardActions(index = 0) {
+  cy.get(".DashCard")
+    .eq(index)
+    .realHover();
+}
diff --git a/frontend/test/__support__/e2e/helpers/e2e-data-model-helpers.js b/frontend/test/__support__/e2e/helpers/e2e-data-model-helpers.js
new file mode 100644
index 0000000000000000000000000000000000000000..5b9a6eebec283cb9b433b7ac4ecdbfbc3f80f056
--- /dev/null
+++ b/frontend/test/__support__/e2e/helpers/e2e-data-model-helpers.js
@@ -0,0 +1,10 @@
+export function remapDisplayValueToFK({ display_value, name, fk } = {}) {
+  // Both display_value and fk are expected to be field IDs
+  // You can get them from frontend/test/__support__/e2e/cypress_sample_dataset.json
+  cy.request("POST", `/api/field/${display_value}/dimension`, {
+    field_id: display_value,
+    name,
+    human_readable_field_id: fk,
+    type: "external",
+  });
+}
diff --git a/frontend/test/__support__/e2e/helpers/e2e-database-metadata-helpers.js b/frontend/test/__support__/e2e/helpers/e2e-database-metadata-helpers.js
new file mode 100644
index 0000000000000000000000000000000000000000..7b22d930b4188e44fc935f61ca395e044a491249
--- /dev/null
+++ b/frontend/test/__support__/e2e/helpers/e2e-database-metadata-helpers.js
@@ -0,0 +1,18 @@
+export function withDatabase(databaseId, f) {
+  cy.request("GET", `/api/database/${databaseId}/metadata`).then(({ body }) => {
+    const database = {};
+    for (const table of body.tables) {
+      const fields = {};
+      for (const field of table.fields) {
+        fields[field.name.toUpperCase()] = field.id;
+      }
+      database[table.name.toUpperCase()] = fields;
+      database[table.name.toUpperCase() + "_ID"] = table.id;
+    }
+    f(database);
+  });
+}
+
+export function withSampleDataset(f) {
+  return withDatabase(1, f);
+}
diff --git a/frontend/test/__support__/e2e/helpers/e2e-deprecated-helpers.js b/frontend/test/__support__/e2e/helpers/e2e-deprecated-helpers.js
new file mode 100644
index 0000000000000000000000000000000000000000..335ce2ef7f673f574afad3436f8612af8158c984
--- /dev/null
+++ b/frontend/test/__support__/e2e/helpers/e2e-deprecated-helpers.js
@@ -0,0 +1,60 @@
+// TODO: does this really need to be a global helper function?
+export function createBasicAlert({ firstAlert, includeNormal } = {}) {
+  cy.get(".Icon-bell").click();
+  if (firstAlert) {
+    cy.findByText("Set up an alert").click();
+  }
+  cy.findByText("Let's set up your alert");
+  if (includeNormal) {
+    cy.findByText("Email alerts to:")
+      .parent()
+      .children()
+      .last()
+      .click();
+    cy.findByText("Robert Tableton").click();
+  }
+  cy.findByText("Done").click();
+  cy.findByText("Let's set up your alert").should("not.exist");
+}
+
+export function setupDummySMTP() {
+  cy.log("Set up dummy SMTP server");
+  cy.request("PUT", "/api/setting", {
+    "email-smtp-host": "smtp.foo.test",
+    "email-smtp-port": "587",
+    "email-smtp-security": "none",
+    "email-smtp-username": "nevermind",
+    "email-smtp-password": "it-is-secret-NOT",
+    "email-from-address": "nonexisting@metabase.test",
+  });
+}
+
+export function createNativeQuestion(name, query) {
+  return cy.request("POST", "/api/card", {
+    name,
+    dataset_query: {
+      type: "native",
+      native: { query },
+      database: 1,
+    },
+    display: "table",
+    visualization_settings: {},
+  });
+}
+
+export function setupLocalHostEmail() {
+  // Email info
+  cy.findByPlaceholderText("smtp.yourservice.com").type("localhost");
+  cy.findByPlaceholderText("587").type("1025");
+  cy.findByText("None").click();
+  // Leaves password and username blank
+  cy.findByPlaceholderText("metabase@yourcompany.com").type("test@local.host");
+
+  // *** Unnecessary click (metabase#12692)
+  cy.findByPlaceholderText("smtp.yourservice.com").click();
+
+  cy.findByText("Save changes").click();
+  cy.findByText("Changes saved!");
+
+  cy.findByText("Send test email").click();
+}
diff --git a/frontend/test/__support__/e2e/helpers/e2e-enterprise-helpers.js b/frontend/test/__support__/e2e/helpers/e2e-enterprise-helpers.js
new file mode 100644
index 0000000000000000000000000000000000000000..18d38a69744b64882ac74c4603a7f391c3913220
--- /dev/null
+++ b/frontend/test/__support__/e2e/helpers/e2e-enterprise-helpers.js
@@ -0,0 +1,3 @@
+export const describeWithToken = Cypress.env("HAS_ENTERPRISE_TOKEN")
+  ? describe
+  : describe.skip;
diff --git a/frontend/test/__support__/e2e/helpers/e2e-misc-helpers.js b/frontend/test/__support__/e2e/helpers/e2e-misc-helpers.js
new file mode 100644
index 0000000000000000000000000000000000000000..2b941d96b1111921a4cf9aba0045615735f4a390
--- /dev/null
+++ b/frontend/test/__support__/e2e/helpers/e2e-misc-helpers.js
@@ -0,0 +1,30 @@
+// Find a text field by label text, type it in, then blur the field.
+// Commonly used in our Admin section as we auto-save settings.
+export function typeAndBlurUsingLabel(label, value) {
+  cy.findByLabelText(label)
+    .clear()
+    .type(value)
+    .blur();
+}
+
+export function visitAlias(alias) {
+  cy.get(alias).then(url => {
+    cy.visit(url);
+  });
+}
+
+/**
+ * Open native (SQL) editor and alias it.
+ *
+ * @param {string} alias - The alias that can be used later in the test as `cy.get("@" + alias)`.
+ * @example
+ * openNativeEditor().type("SELECT 123");
+ */
+export function openNativeEditor(alias = "editor") {
+  cy.visit("/");
+  cy.icon("sql").click();
+  return cy
+    .get(".ace_content")
+    .as(alias)
+    .should("be.visible");
+}
diff --git a/frontend/test/__support__/e2e/helpers/e2e-mock-app-settings-helpers.js b/frontend/test/__support__/e2e/helpers/e2e-mock-app-settings-helpers.js
new file mode 100644
index 0000000000000000000000000000000000000000..90b079eb3d57a60f11f6e9d716efcdc5c467a697
--- /dev/null
+++ b/frontend/test/__support__/e2e/helpers/e2e-mock-app-settings-helpers.js
@@ -0,0 +1,20 @@
+function mockProperty(propertyOrObject, value, url) {
+  cy.intercept("GET", url, req => {
+    req.reply(res => {
+      if (typeof propertyOrObject === "object") {
+        Object.assign(res.body, propertyOrObject);
+      }
+      {
+        res.body[propertyOrObject] = value;
+      }
+    });
+  });
+}
+
+export function mockSessionProperty(propertyOrObject, value) {
+  mockProperty(propertyOrObject, value, "/api/session/properties");
+}
+
+export function mockCurrentUserProperty(propertyOrObject, value) {
+  mockProperty(propertyOrObject, value, "/api/user/current");
+}
diff --git a/frontend/test/__support__/e2e/helpers/e2e-open-table-helpers.js b/frontend/test/__support__/e2e/helpers/e2e-open-table-helpers.js
new file mode 100644
index 0000000000000000000000000000000000000000..77567b9311e63d4dcee674f6e8deb0328596fff3
--- /dev/null
+++ b/frontend/test/__support__/e2e/helpers/e2e-open-table-helpers.js
@@ -0,0 +1,26 @@
+export function openTable({ database = 1, table, mode = null } = {}) {
+  const url = "/question/new?";
+  const params = new URLSearchParams({ database, table });
+
+  if (mode === "notebook") {
+    params.append("mode", mode);
+  }
+
+  cy.visit(url + params.toString());
+}
+
+export function openProductsTable({ mode } = {}) {
+  return openTable({ table: 1, mode });
+}
+
+export function openOrdersTable({ mode } = {}) {
+  return openTable({ table: 2, mode });
+}
+
+export function openPeopleTable({ mode } = {}) {
+  return openTable({ table: 3, mode });
+}
+
+export function openReviewsTable({ mode } = {}) {
+  return openTable({ table: 4, mode });
+}
diff --git a/frontend/test/__support__/e2e/helpers/e2e-qa-databases-helpers.js b/frontend/test/__support__/e2e/helpers/e2e-qa-databases-helpers.js
new file mode 100644
index 0000000000000000000000000000000000000000..1ecda7aaf79383a88e489956ac5a5df587098798
--- /dev/null
+++ b/frontend/test/__support__/e2e/helpers/e2e-qa-databases-helpers.js
@@ -0,0 +1,67 @@
+/*****************************************
+ **            QA DATABASES             **
+ ******************************************/
+
+export function addMongoDatabase(name = "QA Mongo4") {
+  // https://hub.docker.com/layers/metabase/qa-databases/mongo-sample-4.0/images/sha256-3f568127248b6c6dba0b114b65dc3b3bf69bf4c804310eb57b4e3de6eda989cf
+  addQADatabase("mongo", name, 27017);
+}
+
+export function addPostgresDatabase(name = "QA Postgres12") {
+  // https://hub.docker.com/layers/metabase/qa-databases/postgres-sample-12/images/sha256-80bbef27dc52552d6dc64b52796ba356d7541e7bba172740336d7b8a64859cf8
+  addQADatabase("postgres", name, 5432);
+}
+
+export function addMySQLDatabase(name = "QA MySQL8") {
+  // https://hub.docker.com/layers/metabase/qa-databases/mysql-sample-8/images/sha256-df67db50379ec59ac3a437b5205871f85ab519ce8d2cdc526e9313354d00f9d4
+  addQADatabase("mysql", name, 3306);
+}
+
+function addQADatabase(engine, db_display_name, port) {
+  const PASS_KEY = engine === "mongo" ? "pass" : "password";
+  const AUTH_DB = engine === "mongo" ? "admin" : null;
+  const OPTIONS = engine === "mysql" ? "allowPublicKeyRetrieval=true" : null;
+
+  cy.log(`**-- Adding ${engine.toUpperCase()} DB --**`);
+  cy.request("POST", "/api/database", {
+    engine: engine,
+    name: db_display_name,
+    details: {
+      dbname: "sample",
+      host: "localhost",
+      port: port,
+      user: "metabase",
+      [PASS_KEY]: "metasample123", // NOTE: we're inconsistent in where we use `pass` vs `password` as a key
+      authdb: AUTH_DB,
+      "additional-options": OPTIONS,
+      "use-srv": false,
+      "tunnel-enabled": false,
+    },
+    auto_run_queries: true,
+    is_full_sync: true,
+    schedules: {
+      cache_field_values: {
+        schedule_day: null,
+        schedule_frame: null,
+        schedule_hour: 0,
+        schedule_type: "daily",
+      },
+      metadata_sync: {
+        schedule_day: null,
+        schedule_frame: null,
+        schedule_hour: null,
+        schedule_type: "hourly",
+      },
+    },
+  }).then(({ status }) => {
+    expect(status).to.equal(200);
+  });
+
+  // Make sure we have all the metadata because we'll need to use it in tests
+  cy.request("POST", "/api/database/2/sync_schema").then(({ status }) => {
+    expect(status).to.equal(200);
+  });
+  cy.request("POST", "/api/database/2/rescan_values").then(({ status }) => {
+    expect(status).to.equal(200);
+  });
+}
diff --git a/frontend/test/__support__/e2e/helpers/e2e-setup-helpers.js b/frontend/test/__support__/e2e/helpers/e2e-setup-helpers.js
new file mode 100644
index 0000000000000000000000000000000000000000..35c3d4ecf83b6dfdda281dc2fe1d9a78ee538a99
--- /dev/null
+++ b/frontend/test/__support__/e2e/helpers/e2e-setup-helpers.js
@@ -0,0 +1,8 @@
+export function snapshot(name) {
+  cy.request("POST", `/api/testing/snapshot/${name}`);
+}
+
+export function restore(name = "default") {
+  cy.log("Restore Data Set");
+  cy.request("POST", `/api/testing/restore/${name}`);
+}
diff --git a/frontend/test/__support__/e2e/helpers/e2e-ui-elements-helpers.js b/frontend/test/__support__/e2e/helpers/e2e-ui-elements-helpers.js
new file mode 100644
index 0000000000000000000000000000000000000000..6dd1c3b704658f0eed8cbbbe0c5e4da686b5e6a4
--- /dev/null
+++ b/frontend/test/__support__/e2e/helpers/e2e-ui-elements-helpers.js
@@ -0,0 +1,40 @@
+// various Metabase-specific "scoping" functions like inside popover/modal/navbar/main/sidebar content area
+export function popover() {
+  return cy.get(".PopoverContainer.PopoverContainer--open");
+}
+
+export function modal() {
+  return cy.get(".ModalContainer .ModalContent");
+}
+
+export function sidebar() {
+  return cy.get("aside");
+}
+
+export function browse() {
+  // takes you to `/browse` (reflecting changes made in `0.38-collection-redesign)
+  return cy.get(".Nav .Icon-table_spaced");
+}
+
+/**
+ * Get the `fieldset` HTML element that we use as a filter widget container.
+ *
+ * @returns HTMLFieldSetElement
+ *
+ * @example
+ * // Simple SQL filter widget (works for "Text" and "Number" SQL variable types)
+ * filterWidget().type("123");
+ *
+ * @example
+ * // Filter widget that opens some other type of a filter picker (search, dropdown, input)
+ * filterWidget()
+ *  .contains("Search")
+ *  .click();
+ *
+ * @todo Add the ability to choose between multiple widgets using their index.
+ * @todo Add the ability to alias the chosen filter widget.
+ * @todo Extract into a separate helper file.
+ */
+export function filterWidget() {
+  return cy.get("fieldset");
+}
diff --git a/frontend/test/metabase/scenarios/admin/people/people.cy.spec.js b/frontend/test/metabase/scenarios/admin/people/people.cy.spec.js
index 93ba92c62402dbaf2f2a2315c754e106459c6e37..d7cd4928a6d5680c85205c66ff8166cc7b971bce 100644
--- a/frontend/test/metabase/scenarios/admin/people/people.cy.spec.js
+++ b/frontend/test/metabase/scenarios/admin/people/people.cy.spec.js
@@ -1,9 +1,5 @@
-import {
-  restore,
-  popover,
-  setupDummySMTP,
-  generateUsers,
-} from "__support__/e2e/cypress";
+import _ from "underscore";
+import { restore, popover, setupDummySMTP } from "__support__/e2e/cypress";
 import { USERS, USER_GROUPS } from "__support__/e2e/cypress_data";
 
 const { normal, admin } = USERS;
@@ -288,3 +284,17 @@ function clickButton(button_name) {
 function assertTableRowsCount(length) {
   cy.get(".ContentTable tbody tr").should("have.length", length);
 }
+
+function generateUsers(count, groupIds) {
+  const users = _.range(count).map(index => ({
+    first_name: `FirstName ${index}`,
+    last_name: `LastName ${index}`,
+    email: `user_${index}@metabase.com`,
+    password: `secure password ${index}`,
+    groupIds,
+  }));
+
+  users.forEach(u => cy.createUserFromRawData(u));
+
+  return users;
+}
diff --git a/frontend/test/metabase/scenarios/question/public/question.cy.spec.js b/frontend/test/metabase/scenarios/question/public/question.cy.spec.js
index e51ad8ceba495e21bfab1385d6a13242ea5c1661..197ee73554a4b360e321246657cc29527d62fb4c 100644
--- a/frontend/test/metabase/scenarios/question/public/question.cy.spec.js
+++ b/frontend/test/metabase/scenarios/question/public/question.cy.spec.js
@@ -1,4 +1,4 @@
-import { enableSharingQuestion, restore } from "__support__/e2e/cypress";
+import { restore } from "__support__/e2e/cypress";
 import { SAMPLE_DATASET } from "__support__/e2e/cypress_sample_dataset";
 
 const { PEOPLE } = SAMPLE_DATASET;
@@ -69,3 +69,7 @@ const visitPublicURL = () => {
       cy.visit(publicURL);
     });
 };
+
+const enableSharingQuestion = id => {
+  cy.request("POST", `/api/card/${id}/public_link`);
+};
diff --git a/frontend/test/metabase/scenarios/visualizations/pivot_tables.cy.spec.js b/frontend/test/metabase/scenarios/visualizations/pivot_tables.cy.spec.js
index d9eead13a2dee945a248f201c0ccd43cfbbc8591..b2219b6b38a9c052e953910f7a2206d9092054b1 100644
--- a/frontend/test/metabase/scenarios/visualizations/pivot_tables.cy.spec.js
+++ b/frontend/test/metabase/scenarios/visualizations/pivot_tables.cy.spec.js
@@ -1,10 +1,10 @@
 import {
   restore,
   visitQuestionAdhoc,
-  getIframeBody,
   popover,
   sidebar,
 } from "__support__/e2e/cypress";
+
 import { SAMPLE_DATASET } from "__support__/e2e/cypress_sample_dataset";
 
 const {
@@ -927,3 +927,13 @@ function dragField(startIndex, dropIndex) {
     .eq(dropIndex)
     .trigger("drop");
 }
+
+function getIframeBody(selector = "iframe") {
+  return cy
+    .get(selector)
+    .its("0.contentDocument")
+    .should("exist")
+    .its("body")
+    .should("not.be.null")
+    .then(cy.wrap);
+}