☰
Metabase 数据库驱动测试指南:编写 Test Extensions 并通过核心测试套件
2026/10/8 11:38:49 网站建设 项目流程

Metabase 数据库驱动测试指南:编写 Test Extensions 并通过核心测试套件

【免费下载链接】metabaseThe easy-to-use open source Business Intelligence and Embedded Analytics tool that lets everyone work with data :bar_chart:项目地址: https://gitcode.com/GitHub_Trending/me/metabase

Metabase 通过一套庞大且自动化的测试套件来保证所有数据库驱动(driver)的行为一致性。本文面向想要向 Metabase 主仓库提交新驱动(driver plugin)的开发者,完整讲解驱动测试扩展(test extensions)的编写方法、注册机制、数据集加载原理、连接信息配置,以及如何在 GitHub Actions 中搭建针对新驱动的 CI 流水线。读完本文,你将掌握如何让一个新驱动通过 Metabase 的核心测试套件,并获得来自当前仓库源码级实现细节的深入理解。

提交 PR 的前提条件

如果你希望把驱动插件直接贡献到 Metabase 主仓库(而不是维护在独立的仓库中),官方要求满足两个前提条件(见 driver-tests.md):

  1. 能够通过 Docker 在本地运行你的数据库;
  2. 确保你的驱动能通过 Metabase 的核心测试套件(core test suite)。

在开始之前,建议先阅读完整的 Metabase 驱动编写指南,其中介绍了驱动开发环境的搭建(devenv.md 指向的开发者环境文档)、驱动基础、插件清单与驱动 multimethod 实现。本文是这条学习路径的第四步——让驱动通过测试并提交 PR。

测试驱动需要做的三件事

要让 Metabase 的测试套件跑起来覆盖你的新驱动,你需要完成以下工作:

  1. 把插件移入仓库的modules/drivers目录——该目录存放所有官方维护的驱动模块(如modules/drivers/mysql、modules/drivers/clickhouse、modules/drivers/sqlserver等);
  2. 为驱动编写 test extensions(测试扩展);
  3. 编辑 .github/workflows/drivers.yml,告诉 GitHub Actions 如何为你的数据库启动 Docker 镜像并运行测试。

其中第 2 步是核心工作:Metabase 定义了一整套巨大的测试套件,会自动针对所有驱动运行,包括你的新驱动。Test extensions 就是告诉 Metabase "如何为某个驱动创建数据库、载入测试数据、以及连接它" 的一组特殊 multimethod 实现。

Test Extensions 是什么

Test extensions 做的事情包括:创建新数据库、为给定的database definition(数据库定义)加载数据,并提供 Metabase 从该数据库中能预期到什么样的信息。它们本质上是仅供测试使用的额外 multimethod,与核心驱动 multimethod 一样,按驱动名(keyword,如:mysql)进行 dispatch。

理解 multimethod 的分派机制是编写驱动的关键,可参考 multimethods.md 与 clojure.md(Clojure 开发指南)。

文件组织方式

驱动测试扩展通常位于名为metabase.test.data.<driver>的命名空间。以 SQLite 驱动为例,完整的文件布局如下:

metabase/modules/drivers/sqlite/deps.edn ; <- 依赖放在这里 metabase/modules/drivers/sqlite/resources/metabase-plugin.yaml ; <- 插件清单 metabase/modules/drivers/sqlite/src/metabase/driver/sqlite.clj ; <- 主驱动命名空间 metabase/modules/drivers/sqlite/test/metabase/test/data/sqlite.clj ; <- 测试扩展

你需要在test/metabase/test/data/下创建对应的测试扩展文件。注意命名空间模式metabase.test.data.<driver>是必须遵守的——Metabase 在需要时会通过查找该命名空间来加载测试扩展(详见下文"注册测试扩展"一节)。

Test Extension 方法定义在哪里

所有测试扩展方法都定义在metabase.test.data.interface命名空间中,对应源码文件 test/metabase/test/data/interface.clj。与核心驱动方法类似,:sql和:jdbc-sql驱动父类自己实现了部分测试扩展,但同时定义了一批必须由子驱动实现的附加方法——这些定义在:

  • test/metabase/test/data/sql.clj(:sql测试扩展)
  • test/metabase/test/data/sql_jdbc.clj(:sql-jdbc测试扩展)

在你的测试扩展命名空间中,需要按如下别名 require 这三个命名空间:

(require '[metabase.test.data.interface :as tx]) ; tx = test extensions (require '[metabase.test.data.sql :as sql.tx]) ; sql test extensions (require '[metabase.test.data.sql-jdbc :as sql-jdbc.tx]) ; sql-jdbc test extensions

从源码看(interface.clj),tx/dbdef->connection-details、tx/create-db!、tx/destroy-db!等核心方法都以dispatch-on-driver-with-test-extensions作为 dispatch 函数,并且:hierarchy指向driver/hierarchy——这意味着它们在分派前会自动加载对应驱动的测试扩展命名空间。

注册测试扩展

与驱动本身一样,你需要注册"该驱动已拥有测试扩展"这一事实,这样 Metabase 就不会重复加载。注册函数会根据你的驱动继承自哪个父类而不同:

;; 非 SQL 驱动 (tx/add-test-extensions! :mongo) ;; 非 JDBC 的 SQL 驱动 (sql/add-test-extensions! :bigquery) ;; JDBC SQL 驱动 (sql-jdbc.tx/add-test-extensions! :mysql)

只需要调用其中一个即可——对于:sql-jdbc驱动的子驱动,无需三个都调用(:sql-jdbc内部已经分别注册了:sql和:sql-jdbc/test-extensions父类)。该调用应放在测试扩展命名空间的最前面,例如 MySQL 的真实实现 test/metabase/test/data/mysql.clj:

(ns metabase.test.data.mysql (:require [metabase.test.data.sql-jdbc :as sql-jdbc.tx])) (sql-jdbc.tx/add-test-extensions! :mysql)

底层机制:注册与懒加载

从 interface.clj 的源码可以看到注册的底层实现:

(driver/register! ::test-extensions, :abstract? true) (defn has-test-extensions? [driver] (isa? driver/hierarchy driver ::test-extensions)) (defn add-test-extensions! [driver] (when-not *compile-files* (driver/add-parent! driver ::test-extensions) (log/infof "Added test extensions for %s 💯" driver)))

它本质上是在驱动层次结构中把::test-extensions挂为驱动的父类。而加载则是懒进行的:load-test-extensions-namespace-if-needed(interface.clj)会:

  1. 检查该驱动是否已加载过测试扩展(用一个has-loaded-extensionsatom 去重);
  2. 若未加载,则按命名约定(require 'metabase.test.data.<driver>);
  3. 若加载后仍未注册,则会尝试加载其父驱动的测试扩展(例如 Redshift 复用 Postgres 的测试扩展)并重试,最终仍失败则抛出异常No test extensions found for <driver>。

sql/add-test-extensions!(sql.clj)与sql-jdbc.tx/add-test-extensions!(sql_jdbc.clj)的原理相同,只是分别把:sql/test-extensions和:sql-jdbc/test-extensions挂为父类。

剖析一个 Metabase 测试

理解 Metabase 测试的运行机制,是编写 test extensions 的关键。下面是一个真实的测试用例(摘自 driver-tests.md 中的示例):

;; expect-with-non-timeseries-dbs = 针对 `DRIVERS` 环境变量列出的所有驱动运行, ;; 但排除 Druid 等时序数据库 (expect-with-non-timeseries-dbs ;; 期望结果 [[ 5 "Brite Spot Family Restaurant" 20 34.0778 -118.261 2] [ 7 "Don Day Korean Restaurant" 44 34.0689 -118.305 2] [17 "Ruen Pair Thai Restaurant" 71 34.1021 -118.306 2] [45 "Tu Lan Restaurant" 4 37.7821 -122.41 1] [55 "Dal Rae Restaurant" 67 33.983 -118.096 4]] ;; 实际结果 (-> (data/run-mbql-query venues {:filter [:ends-with $name "Restaurant"] :order-by [[:asc $id]]}) rows formatted-venues-rows))

假设我们用下面的命令启动测试:

DRIVERS=mysql clojure -X:dev:drivers:drivers-dev:test

整个执行流程如下:

  1. 加载测试扩展:Metabase 检查:mysql的测试扩展是否已加载,若没有则(require 'metabase.test.data.mysql)。

  2. 创建并同步测试数据库:Metabase 检查默认的test-data数据库是否已为 MySQL 创建、加载数据并同步。若没有,调用测试扩展方法tx/load-data!创建test-data数据库并载入数据;载入完成后对测试数据库执行同步。

  3. 执行 MBQL 查询:对 MySQLtest-data数据库的venues表运行 MBQL 查询。run-mbql-query宏是一个测试辅助宏,它会根据$前缀的符号名查找 Field ID。实际执行的查询大致如下:

    {:database 100 ; MySQL test-data 数据库的 ID :type :query :query {:source-table 20 ; 表 20 = MySQL test-data.venues :filter [:ends-with [:field-id 555] "Restaurant"] ; 字段 555 = MySQL test-data.venues.name :order-by [[:asc [:field-id 556]]]}} ; 字段 556 = MySQL test-data.venues.id
  4. 整理结果:结果经过辅助函数rows和formatted-venues-rows处理,只保留测试关心的部分。

  5. 比对结果:将整理后的结果与期望结果进行比对。

这个流程对应了底层实现:在 test/metabase/test/data/impl/get_or_create.clj 的get-or-create-database!逻辑中,Metabase 会检查测试数据库是否已存在,若存在则跳过创建、必要时重新加载数据(load-dataset-data-if-needed!),并更新created_at时间戳避免重复执行初始化。

加载数据:Database Definitions

为了在不同驱动间保持行为一致,Metabase 测试套件会从一组共享的 Database Definitions(数据库定义)创建新数据库并载入数据。这意味着无论测试跑在 MySQL、Postgres、SQL Server 还是 MongoDB 上,同一个测试都能断言得到完全一致的结果。

数据集定义文件

绝大多数数据库定义存放在 EDN 文件中,位于 test/metabase/test/data/dataset_definitions/。绝大多数测试针对名为test-data的测试数据库运行,其定义见 test/metabase/test/data/dataset_definitions/test-data.edn。打开这个文件可以看到:它只是一组表名、列名、类型,以及几千行待载入的数据。文件头部的注释清晰列出了各表的规模:

  • users:15 行(含password敏感字段,标记为:visibility-type :sensitive)
  • categories:75 行
  • venues:100 行(price为 0-4 的整数,0 表示未知)
  • checkins:1000 行
  • products:200 行
  • people:2500 行
  • reviews:1112 行
  • orders:18760 行

EDN 中的每条记录格式为[表名 [字段定义...] [行...]],例如users表的定义片段:

[["users" [{:field-name "name" :base-type :type/Text} {:field-name "last_login" :base-type :type/DateTime} {:field-name "password" :base-type :type/Text :visibility-type :sensitive}] [["Plato Yeshua" #t "2014-04-01T08:30" "4be68cda-6fd5-4ba7-944e-2b475600bda5"] ...]]

DatabaseDefinition的 schema 同样定义在metabase.test.data.interface(interface.clj):每个字段可配置:base-type、:not-null?、:unique?、:pk?、:default-expr、:generated-expr、:indexed?、:semantic-type、:effective-type、:coercion-strategy、:visibility-type、:fk、:field-comment、:nested-fields等属性;数据库级还支持:options(如:native-ddl原生 DDL、:disable-fk-checks在载入时禁用外键检查、:static标记静态数据集不受定期 GC 影响)。

核心任务:编写加载数据的方法

作为测试扩展编写者,最大的工作就是实现这些方法:接收一个 database definition,创建带相应表和列的新数据库,并载入数据。

  • 非 SQL 驱动:需要实现tx/load-data!;
  • :sql与:sql-jdbc驱动:共享父类实现,子驱动只需实现它们自己定义的一组测试扩展方法。例如,:sql(以及:sql-jdbc)负责生成建表 DDL,但主键的类型必须由你告诉它,因此需要实现sql.tx/pk-sql-type(定义于 sql.clj):
(defmethod sql.tx/pk-sql-type :mysql [_] "INTEGER NOT NULL AUTO_INCREMENT")

MySQL 的真实实现见 test/metabase/test/data/mysql.clj:

(defmethod sql.tx/pk-sql-type :mysql [_] "INTEGER NOT NULL AUTO_INCREMENT")

字段类型映射

对于 SQL 驱动,另一个常见任务是实现sql.tx/field-base-type->sql-type,把 Metabase 的抽象 base type(如:type/DateTime)映射为数据库的原生 SQL 类型。MySQL 的实现(mysql.clj)是一个很好的参考:

(doseq [[base-type database-type] {:type/BigInteger "BIGINT" :type/Boolean "BOOLEAN" :type/Date "DATE" :type/DateTime "DATETIME(3)" ; (3) = 毫秒精度 :type/DateTimeWithTZ "TIMESTAMP(3) DEFAULT '1970-01-01 00:00:01'" :type/Decimal "DECIMAL" :type/Float "DOUBLE" :type/Integer "INTEGER" :type/JSON "JSON" :type/Text "TEXT" :type/Time "TIME(3)"}] (defmethod sql.tx/field-base-type->sql-type [:mysql base-type] [_ _] database-type))

注意其中对 MySQL 的兼容性处理:MySQL 不允许同一张表存在两个没有默认值的TIMESTAMP列,因此:type/DateTimeWithTZ映射为带默认值的TIMESTAMP(3)。如果你需要逐一定义每个测试扩展方法,建议直接阅读对应测试扩展命名空间中的源码文档(每个方法都有 docstring 说明),并参考其他相似驱动(如 sql.clj 与 sql_jdbc.clj 中已有的实现)。

连接信息:dbdef->connection-details

Metabase 还需要知道如何连接到新建的数据库。具体来说,它需要知道当把新建数据库保存为 Metabase 的Database对象时,连接:detailsmap 里应该存什么。所有拥有测试扩展的驱动都需要实现tx/dbdef->connection-details,为给定的 database definition 返回合适的:details。例如 MySQL 的实现(mysql.clj):

(defmethod tx/dbdef->connection-details :mysql [_ context {:keys [database-name]}] (merge {:host (tx/db-test-env-var-or-throw :mysql :host "localhost") :port (tx/db-test-env-var-or-throw :mysql :port 3306) :user (tx/db-test-env-var :mysql :user "root")} (when-let [password (tx/db-test-env-var :mysql :password)] {:password password}) (when (= context :db) {:db database-name})))

Connection context 参数

tx/dbdef->connection-details会在两种上下文(context)中被调用:

  • 创建数据库时;
  • 载入数据并同步时。

大多数数据库不允许连接到一个尚不存在的数据库——比如CREATE DATABASE "test-data";这类语句必须在不指定test-data的情况下连接执行。因此就有了context参数,它只有两个取值:

  • :server——返回连接 DBMS 服务器(但不连接具体数据库)所需的 details;
  • :db——返回连接具体数据库所需的 details。

以 MySQL 为例,当context为:db时才追加:db连接属性(interface.clj 中对该方法的 docstring 也做了同样的说明)。

从环境变量读取连接参数

你几乎肯定会在本地 Docker 容器里运行数据库。与其硬编码连接参数(用户名、主机、端口……),更灵活的做法是允许通过环境变量指定,以便其他人针对不同的容器、非容器环境或另一台机器运行测试。使用tx/db-test-env-var即可从环境变量读取:

(tx/db-test-env-var :mysql :user "root")

这会告诉 Metabase 查找环境变量MB_MYSQL_TEST_USER,若未设置则默认取"root"。环境变量名的规则是MB_<driver>_TEST_<property>,即函数的第一、二个参数。tx/db-test-env-var的默认值参数是可选的:如果某个属性(如user)是可选项,且MB_MYSQL_TEST_USER未设置,那么连接 details 中就不必包含它。

对于必须提供但缺少合理默认值的属性,使用tx/db-test-env-var-or-throw:如果对应环境变量未设置,它会抛出异常,最终导致测试失败:

;; 若 MB_SQLSERVER_TEST_USER 未设置,测试套件会退出并提示类似 ;; "MB_SQLSERVER_TEST_USER is required to run tests against :sqlserver" 的消息 (tx/db-test-env-var-or-throw :sqlserver :user)

注意:tx/dbdef->connection-details根本不会为你未针对其运行测试的驱动(即未列入DRIVERS环境变量的驱动)被调用,所以比如你在跑 Mongo 的测试时,不会看到 SQL Server 的报错。

从源码看,db-test-env-var(interface.clj)通过(keyword (format "mb-%s-test-%s" driver env-var))构造环境变量键并读取;db-test-env-var-or-throw(interface.clj)则在未找到时抛出异常。

除了tx/db-test-env-var,metabase.test.data.interface还提供了若干其他实用工具函数。建议通读该命名空间;如果数据库使用 SQL 还应阅读metabase.test.data.sql,如果使用 JDBC 驱动则应阅读metabase.test.data.sql-jdbc。

其他需要实现的测试扩展

比对测试结果时,Metabase 还需要知道一些其他信息。例如,不同数据库对表和列的命名方式不同——某些数据库把所有标识符转为大写,那么test-data定义中的venues表在数据库中可能变成VENUES。Metabase 提供了一系列方法让你声明这种差异(这类细微的命名差异被视为"同一张表"):

  • tx/format-name(默认实现见 interface.clj,它通过ddl.i/format-name提供):用于格式化表名/字段名;
  • tx/id-field-type(interface.clj):声明id字段的base_type,默认为:type/Integer,若你的数据库主键是 BIGINT 则需要覆盖;
  • tx/sorts-nil-first?(interface.clj):声明 NULL 排序时排在前还是后,默认true;
  • tx/aggregate-column-info(interface.clj):声明聚合查询结果列的预期类型信息,MySQL 就覆盖了:sum聚合以返回:type/Decimal结果(mysql.clj);
  • 数据库级生命周期钩子:tx/before-run与tx/after-run(interface.clj)分别在测试前后执行一次性初始化/清理;tx/gc-orphans!用于清理共享云仓库中残留的孤儿测试数据(仅在 CI 任务被取消、after-run未触发时执行,见 interface.clj)。

请查看tx/format-name等方法的定义,判断你的驱动需要实现哪些。每个方法在 interface.clj 中都有详尽的 docstring。

无法以编程方式创建数据库的 DBMS 怎么办

这其实是个常见问题,Metabase 社区已经摸索出了解决方案。通常的做法是:

  • 用不同的 schema代替不同的数据库;
  • 或者给表名加上数据库名前缀,全部建在同一个数据库里。

对于基于 SQL 的数据库,可以实现sql.tx/qualified-name-components(定义于 sql.clj),让测试使用不同的标识符。默认实现不注入 schema:

(defmethod qualified-name-components :sql/test-extensions ([_ db-name] [db-name]) ([_ _db-name table-name] [table-name]) ([_ _db-name table-name field-name] [table-name field-name]))

而像 SQL Server 和 Oracle 这类驱动则覆盖了该方法。tx/db-qualified-table-name(interface.clj)用于生成test_data_venues这种带库名前缀的表名,并断言结果标识符长度小于 30 个字符(因为 Oracle 等数据库对标识符长度有限制)。tx/single-db-qualified-name-components(interface.clj)则提供了"单库模拟多库"的完整实现:使用一个"会话 schema"保证各测试运行相互隔离,同时把数据库名嵌入表名(如test_data_categories与tupac_sightings_categories)。从qualified-name-components的默认实现可以看到,覆盖后可以注入 schema 名:

;; (qualified-name-components [driver "my-db" "my-table"]) -> ["my-db" "dbo" "my-table"]

借助这种机制,"test-data".venues.id可以被改写为"shared_db"."test-data_venues".id。SQL Server 和 Oracle 的测试扩展正是这种"魔法"的典范实现,可直接参考 test/metabase/test/data/sql_jdbc/ 目录下的相关代码。

搭建 CI:在 GitHub Actions 中运行驱动测试

当所有测试通过后,你需要在 GitHub Actions 中配置针对新驱动的测试任务。Metabase 的驱动 CI 定义在 .github/workflows/drivers.yml,该工作流支持workflow_call复用,并通过workflow_dispatch支持按需手动触发单个驱动任务(可选项包括mysql-mariadb、postgres、sqlite、mongo、clickhouse、sqlserver、oracle等,还支持通过tests参数指定只跑部分测试、通过profile参数生成火焰图)。你需要在其中添加一个新 job 来针对你的数据库运行测试。

下面是文档中给出的 PostgreSQL 配置示例:

be-tests-postgres-latest-ee: needs: files-changed if: github.event.pull_request.draft == false && needs.files-changed.outputs.backend_all == 'true' runs-on: ${{ vars.DEFAULT_RUNNER_KEY }} timeout-minutes: 40 env: CI: "true" DRIVERS: postgres MB_DB_TYPE: postgres MB_DB_PORT: 5432 MB_DB_HOST: localhost MB_DB_DBNAME: circle_test MB_DB_USER: circle_test MB_POSTGRESQL_TEST_USER: circle_test MB_POSTGRES_SSL_TEST_SSL: true MB_POSTGRES_SSL_TEST_SSL_MODE: verify-full MB_POSTGRES_SSL_TEST_SSL_ROOT_CERT_PATH: "test-resources/certificates/us-east-2-bundle.pem" services: postgres: image: circleci/postgres:latest ports: - "5432:5432" env: POSTGRES_USER: circle_test POSTGRES_DB: circle_test POSTGRES_HOST_AUTH_METHOD: trust steps: - uses: actions/checkout@v6 - name: Test Postgres driver (latest) uses: ./.github/actions/test-driver with: junit-name: "be-tests-postgres-latest-ee"

这个配置展示了几个关键点:

  • DRIVERS环境变量:声明本次运行针对哪个驱动,测试套件只会为列出的驱动加载测试扩展并运行对应测试;
  • MB_<driver>_TEST_<property>环境变量:与前面tx/db-test-env-var的规则一一对应,CI 中通过它们注入连接参数(这里同时演示了MB_POSTGRESQL_TEST_USER和 SSL 相关的MB_POSTGRES_SSL_TEST_*变量);
  • services段:通过 Docker 容器启动数据库服务,circleci/postgres:latest镜像暴露 5432 端口供测试连接;
  • .github/actions/test-driver:Metabase 提供的内置复用 action,负责执行实际测试并产出 JUnit 报告(junit-name用于标识产物)。

对于自己的驱动,你需要把上述配置中的服务镜像、端口、环境变量替换为你的数据库对应值,并把DRIVERS设为你的驱动名。更多关于 GitHub Actions 工作流语法的细节,可参考 GitHub 官方文档中的 Workflow syntax 说明。

总结

让一个新驱动通过 Metabase 核心测试套件,本质上是实现一套以metabase.test.data.<driver>为命名空间的测试扩展 multimethod。核心要点可归纳为:

  1. 在modules/drivers中组织驱动模块,并在test/metabase/test/data/下按命名约定放置测试扩展;
  2. 在命名空间顶部通过tx/add-test-extensions!/sql/add-test-extensions!/sql-jdbc.tx/add-test-extensions!之一完成注册;
  3. 实现建库、建表、载入数据所必需的方法(如sql.tx/pk-sql-type、sql.tx/field-base-type->sql-type,非 SQL 驱动则实现tx/load-data!);
  4. 实现tx/dbdef->connection-details区分:server/:db两种上下文返回连接信息,并通过tx/db-test-env-var/tx/db-test-env-var-or-throw从MB_<driver>_TEST_<property>环境变量读取参数;
  5. 针对无法编程创建数据库的 DBMS,通过sql.tx/qualified-name-components等机制用 schema 或表名前缀模拟多库;
  6. 最后在 .github/workflows/drivers.yml 中为你的数据库添加 GitHub Actions 测试任务。

遵循这一套件,你的驱动就能与 MySQL、Postgres 等官方驱动一样,被 Metabase 庞大的跨驱动一致性测试所覆盖,保证查询结果在不同数据库间的行为完全一致。

【免费下载链接】metabaseThe easy-to-use open source Business Intelligence and Embedded Analytics tool that lets everyone work with data :bar_chart:项目地址: https://gitcode.com/GitHub_Trending/me/metabase

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询