资讯详情

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

📅 2026/9/10 17:45:21 | 华诺云谱 👁 阅读
Metabase 数据库驱动测试指南:编写 Test Extensions 并通过核心测试套件
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/metabaseMetabase 通过一套庞大且自动化的测试套件来保证所有数据库驱动driver的行为一致性。本文面向想要向 Metabase 主仓库提交新驱动driver plugin的开发者完整讲解驱动测试扩展test extensions的编写方法、注册机制、数据集加载原理、连接信息配置以及如何在 GitHub Actions 中搭建针对新驱动的 CI 流水线。读完本文你将掌握如何让一个新驱动通过 Metabase 的核心测试套件并获得来自当前仓库源码级实现细节的深入理解。提交 PR 的前提条件如果你希望把驱动插件直接贡献到 Metabase 主仓库而不是维护在独立的仓库中官方要求满足两个前提条件见 driver-tests.md能够通过 Docker 在本地运行你的数据库确保你的驱动能通过 Metabase 的核心测试套件core test suite。在开始之前建议先阅读完整的 Metabase 驱动编写指南其中介绍了驱动开发环境的搭建devenv.md 指向的开发者环境文档、驱动基础、插件清单与驱动 multimethod 实现。本文是这条学习路径的第四步——让驱动通过测试并提交 PR。测试驱动需要做的三件事要让 Metabase 的测试套件跑起来覆盖你的新驱动你需要完成以下工作把插件移入仓库的modules/drivers目录——该目录存放所有官方维护的驱动模块如modules/drivers/mysql、modules/drivers/clickhouse、modules/drivers/sqlserver等为驱动编写 test extensions测试扩展编辑 .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.mdClojure 开发指南。文件组织方式驱动测试扩展通常位于名为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.cljtx/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-neededinterface.clj会检查该驱动是否已加载过测试扩展用一个has-loaded-extensionsatom 去重若未加载则按命名约定(require metabase.test.data.driver)若加载后仍未注册则会尝试加载其父驱动的测试扩展例如 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))假设我们用下面的命令启动测试DRIVERSmysql clojure -X:dev:drivers:drivers-dev:test整个执行流程如下加载测试扩展Metabase 检查:mysql的测试扩展是否已加载若没有则(require metabase.test.data.mysql)。创建并同步测试数据库Metabase 检查默认的test-data数据库是否已为 MySQL 创建、加载数据并同步。若没有调用测试扩展方法tx/load-data!创建test-data数据库并载入数据载入完成后对测试数据库执行同步。执行 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整理结果结果经过辅助函数rows和formatted-venues-rows处理只保留测试关心的部分。比对结果将整理后的结果与期望结果进行比对。这个流程对应了底层实现在 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。打开这个文件可以看到它只是一组表名、列名、类型以及几千行待载入的数据。文件头部的注释清晰列出了各表的规模users15 行含password敏感字段标记为:visibility-type :sensitivecategories75 行venues100 行price为 0-4 的整数0 表示未知checkins1000 行products200 行people2500 行reviews1112 行orders18760 行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.interfaceinterface.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-detailsMetabase 还需要知道如何连接到新建的数据库。具体来说它需要知道当把新建数据库保存为 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-varinterface.clj通过(keyword (format mb-%s-test-%s driver env-var))构造环境变量键并读取db-test-env-var-or-throwinterface.clj则在未找到时抛出异常。除了tx/db-test-env-varmetabase.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-typeinterface.clj声明id字段的base_type默认为:type/Integer若你的数据库主键是 BIGINT 则需要覆盖tx/sorts-nil-first?interface.clj声明 NULL 排序时排在前还是后默认truetx/aggregate-column-infointerface.clj声明聚合查询结果列的预期类型信息MySQL 就覆盖了:sum聚合以返回:type/Decimal结果mysql.clj数据库级生命周期钩子tx/before-run与tx/after-runinterface.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-nameinterface.clj用于生成test_data_venues这种带库名前缀的表名并断言结果标识符长度小于 30 个字符因为 Oracle 等数据库对标识符长度有限制。tx/single-db-qualified-name-componentsinterface.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/checkoutv6 - 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-driverMetabase 提供的内置复用 action负责执行实际测试并产出 JUnit 报告junit-name用于标识产物。对于自己的驱动你需要把上述配置中的服务镜像、端口、环境变量替换为你的数据库对应值并把DRIVERS设为你的驱动名。更多关于 GitHub Actions 工作流语法的细节可参考 GitHub 官方文档中的 Workflow syntax 说明。总结让一个新驱动通过 Metabase 核心测试套件本质上是实现一套以metabase.test.data.driver为命名空间的测试扩展 multimethod。核心要点可归纳为在modules/drivers中组织驱动模块并在test/metabase/test/data/下按命名约定放置测试扩展在命名空间顶部通过tx/add-test-extensions!/sql/add-test-extensions!/sql-jdbc.tx/add-test-extensions!之一完成注册实现建库、建表、载入数据所必需的方法如sql.tx/pk-sql-type、sql.tx/field-base-type-sql-type非 SQL 驱动则实现tx/load-data!实现tx/dbdef-connection-details区分:server/:db两种上下文返回连接信息并通过tx/db-test-env-var/tx/db-test-env-var-or-throw从MB_driver_TEST_property环境变量读取参数针对无法编程创建数据库的 DBMS通过sql.tx/qualified-name-components等机制用 schema 或表名前缀模拟多库最后在 .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),仅供参考
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。