Writing a Driver: Adding Test Extensions, Tests, and Setting up CI
This chapter really only applies to drivers you intend to submit as a PR. If you do not intend to submit your driver as a PR, whether it works is not my concern 😉, and you can feel free to write as many or as few tests as you like.
Running the core test suite, as described below, is a good way to check that your driver is doing what's expected, but only works out of the box for drivers that live in the core Metabase repository (as opposed to ones shipped as separate plugins). We'll need to use the include-all-drivers
Leiningen profile to have access to both the core Metabase project and the driver project's source-paths
and test-paths
.
Of course, you can create a new Leinigen profile that pulls the test-paths
from the Metabase core project into yours, but explaining how to do this is beyond the scope of this guide. If you are an advanced Clojure user, something like that should be no problem, so have at it; if your eyes glazed over reading this, your best bet is to move your driver into the Metabase repo and test it that way.
Before submitting a PR to add a driver, you'll need to make sure it's tested. Luckily, you don't need to write a big set of tests yourself -- Metabase defines a huge suite of tests that automatically run against all drivers, and can run against yours as well. To run the test suite with your driver, Metabase needs to know how to create new databases and load them with test data, and some information about what it can expect from the created database; like the driver itself, you'll need to write a series of method implementations for special test extension multimethods.
Test extensions are simply additional multimethods used only by tests; like the core driver multimethods, they dispatch on the driver name as a keyword, e.g. :mysql
.
Test extensions for a driver usually live in a namespace called metabase.test.data.<driver>
. If you intend to submit your driver as a PR, you've already organized your files the way you're required to organize them for drivers submitted as PRs as described in Chapter 2, and your files should look something like:
metabase/modules/drivers/mongo/project.clj ; <- deps go in here
metabase/modules/drivers/mongo/resources/metabase-plugin.yaml ; <- plugin manifest
metabase/modules/drivers/mongo/src/metabase/driver/mongo.clj ; <- main driver namespace
To that, we'll add:
metabase/modules/drivers/mongo/test/metabase/test/data/mongo.clj ; <- test extensions
Your method implementations should live there.
Metabase test extensions live in the metabase.test.data.interface
namespace. Like the core driver methods, :sql
and :jdbc-sql
implement some of the test extensions themselves, but define additional methods you must implement to use them; see the metabase.test.data.sql
and metabase.test.data.sql-jdbc
namespaces.
These namespaces are named metabase.test.data.*
for legacy reasons and may be changed in the future to something that actually includes the term test-extensions
; in the meantime, please future-proof your code by requiring those namespaces using the following aliases:
(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])
Like the driver itself, you need to register the fact that your driver has test extensions, so Metabase knows it doesn't need to try to load them a second time. (If they're not loaded yet, Metabase will load them when needed by looking for a namespace named metabase.test.data.<driver>
, which is why you need to follow that naming pattern.) The :sql
and :sql-jdbc
drivers have their own sets of test extensions, so depending on which parent you're using for your driver, register test extensions with:
# Non-SQL drivers
(tx/add-test-extensions! :mongo)
# non-JDBC SQL
(sql/add-test-extensions! :bigquery)
# JDBC SQL
(sql-jdbc.tx/add-test-extensions! :mysql)
You only need one call -- there's no need to do all three for a :sql-jdbc
driver, for example. This call should go at the beginning of your test extension namespace, like this:
(ns metabase.test.data.mysql
(:require [metabase.test.data.sql-jdbc :as sql-jdbc.tx]))
(sql-jdbc.tx/register-test-extensions! :mysql)
Let's look at an real-life Metabase test so we can understand how it works and what exactly we need to do to power it:
;; expect-with-non-timeseries-dbs = run against all drivers listed in `DRIVERS` env var except timeseries ones like Druid
(expect-with-non-timeseries-dbs
;; expected results
[[ 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]]
;; actual results
(-> (data/run-mbql-query venues
{:filter [:ends-with $name "Restaurant"]
:order-by [[:asc $id]]})
rows formatted-venues-rows))
Let's say we launch tests with DRIVERS=mysql lein test
. When this test is ran, this is what happens:
- Metabase will check and see if test extensions for
:mysql
are loaded. If not, it will(require 'metabase.test.data.mysql)
. - Metabase will check to see if the default
test-data
database has been created for MySQL, loaded with data, and synced. If not, it will call the test extension methodtx/load-data!
to create thetest-data
database and load data into it. After loading the data, Metabase syncs the test database. (This is discussed in more detail below.) - Metabase runs an MBQL query against the
venues
table of the MySQLtest-data
database. Therun-mbql-query
macro is a helper for writing tests that looks up Field IDs based on names for symbols that have$
in from of them. Don't worry too much about that right now; just know the actual query that is ran will look something like:{:database 100 ; ID of MySQL test-data database :type :query :query {:source-table 20 ; Table 20 = MySQL test-data.venues :filter [:ends-with [:field-id 555] "Restaurant"] ; Field 555 = MySQL test-data.venues.name :order-by [[:asc [:field-id 556]]]}} ; Field 556 = MySQL test-data.venues.id
- The results are ran through helper functions
rows
andformatted-venues-rows
which return only the parts of the query results we care about - Those results are compared against the expected results.
That's about as much as you'd need to know about the internals of how Metabase tests work; now that we've covered that, let's take a look at how we can empower Metabase to do what it needs to do.
In order to ensure consistent behavior across different drivers, the Metabase test suite creates new databases and load datas into them from a set of shared Database Definitions. That means whether we're running a test against MySQL, Postgres, SQL Server, or MongoDB, a single test can check that we get the exact same results for every driver!
Most of these database definitions live in EDN files; the majority of tests run against a test database named "test data", whose definition can be found here. Take a look at that file -- it's just a simple set of tables names, column names and types, and then a few thousand rows of data to load into those tables.
Like test extension method definitions, schemas for DatabaseDefinition
live in metabase.test.data.interface
-- you can take a look and see exactly what a database definition is supposed to look like.
Your biggest job as a writer of test definitions is to write the methods needed to take a database definition, create a new database with the appropriate tables and columns, and load data into it. For non-SQL drivers, you'll need to implement tx/load-data!
; :sql
and :sql-jdbc
have a shared implementation used by child drivers, but define their own set of test extension methods. For example, :sql
(and :sql-jdbc
) will handle the DDL statements for creating tables, but need to know what type it should use for the primary key, so you'll need to implement sql.tx/pk-sql-type
:
(defmethod sql.tx/pk-sql-type :mysql [_] "INTEGER NOT NULL AUTO_INCREMENT")
I'd like to document every single test extension method in detail here, but until I find the time to do that, the methods are all documented in the codebase itself; take a look at the appropriate test extension namespaces and see which methods you'll need to implement. You can also refer to the test extensions written for other similar drivers to get a picture of what exactly it is you need to be doing.
Of course, Metabase also needs to know how it can connect to your newly created database. Specifically, it needs to know what it should save as part of the connection :details
map when it saves the newly created database as a Database
object. All drivers with test extensions need to implement tx/dbdef->connection-details
to return an appropriate set of :details
for a given database definition. For example:
(defmethod tx/dbdef->connection-details :mysql [_ context {:keys [database-name]}]
(merge
{:host (tx/db-test-env-var :mysql :host "localhost")
:port (tx/db-test-env-var :mysql :port 3306)
:user (tx/db-test-env-var :mysql :user "root")
;; :timezone :America/Los_Angeles
:serverTimezone "UTC"}
(when-let [password (tx/db-test-env-var :mysql :password)]
{:password password})
(when (= context :db)
{:db database-name})))
Let's take a look at what's going on here.
tx/dbdef->connection-details
is called in two different contexts: when creating a database, and when loading data into one and syncing. Most databases won't let you connect to a database that hasn't been created yet, meaning something like a CREATE DATABASE "test-data";
statement would have to be ran without specifying test-data
as part of the connection. Thus, the context
parameter. context
is either :server
, meaning "give me details for connecting to the DBMS server, but not to a specific database", or :db
, meaning "give me details for connecting to a specific database". In MySQL's case, it adds the :db
connection property whenever context is :db
.
What's tx/db-test-env-var
about? As previously mentioned, you'll almost certainly be running your database in a local Docker container. Rather than hardcode the username, host, port, etc. for the Docker container, we'd like to be flexible, and let people specify those in environment variables, in case they're running against a different container or are just running the database outside of a container or on another computer entirely. Metabase handily provides the aformentioned function to get such details from environment variables. For example,
(tx/db-test-env-var :mysql :user "root")
Tells Metabase to look for the environment variable MB_MYSQL_TEST_USER
; if not found, default to "root"
. The name of the environment variable follows the pattern MB_<driver>_TEST_<property>
, as passed into the function as first and second args, respectively. You don't need to specify a default value for tx/db-test-env-var
; perhaps user
is an optional parameter; and if MB_MYSQL_TEST_USER
isn't specified, you don't need to specify it in the connection details.
But what about properties you want to require, but do not have sane defaults? In those cases, you can use tx/db-test-env-var-or-throw
. It the corresponding enviornment variable isn't set, these will throw an Exception, ultimately causing tests to fail.
;; If MB_SQLSERVER_TEST_USER is unset, the test suite will quit with a message saying something like
;; "MB_SQLSERVER_TEST_USER is required to run tests against :sqlserver"
(tx/db-test-env-var-or-throw :sqlserver :user)
Note that tx/dbdef->connection-details
won't get called in the first place for drivers you aren't running tests against (i.e., drivers not listed in the DRIVERS
env var), so you wouldn't see that SQL Server error message when running tests against Mongo, for example.
Besides tx/db-test-env-var
, metabase.test.data.interface
has several other helpful utility functions. Take a good look at that namespace as well as metabase.test.data.sql
if your database uses SQL and metabase.test.data.sql-jdbc
if your database uses a JDBC driver.
There's a few other things Metabase needs to know when comparing test results. For example, different databases name tables and columns in different ways; methods exist to let Metabase know it should expect something like the venues
table in the test-data
Database Definition to come back as VENUES
for a database that uppercases everything. (We consider such minor variations in naming to still mean the same thing.) Take a look at tx/format-name
and other methods like that and see which ones you need to implement.
This is actually a common problem, and luckily we have figured out how to work around it. The solution is usually something like using different schemas in place of different databases, or prefixing table names with the database name, and creating everything in the same database. For SQL-based databases, you can implement sql.tx/qualified-name-components
to have tests use a different identifier instead of what they would normally use, for example "shared_db"."test-data_venues".id
instead of "test-data".venues.id
. The SQL Server and Oracle test extensions are good examples of such black magic in action.
Now that you've written all your tests, running them should be a piece of cake. Simply include all the drivers you'd like to run tests against in the DRIVERS
environment variable and run lein test
:
DRIVERS=mongo lein test
You can run tests against multiple drivers by separating names with a comma.
Run the linters as follows:
lein eastwood && lein docstring-checker && lein bikeshed && ./bin/reflection-linter
The linters use the include-all-drivers
profile to automatically include your driver's source paths.
Make sure tests are passing for your driver and the linters don't find any problems before submitting a PR.
Now that you've got tests passing, your final step before submitting a PR is helping us get those tests set up for CI. To make things run on CI, you simply need to add a step to ./circleci/config.yml
to run tests against your database, and then add that step to the "workflows" at the bottom of the config file. Explaining how CircleCI 2.0 Workflows work is way outside the scope of this guide, but you should be able to figure out what you need to add by copying over the job for another driver. For example, here's a job to run Mongo tests:
be-tests-mongo:
working_directory: /home/circleci/metabase/metabase/
docker:
- image: circleci/clojure:lein-2.8.1-node-browsers
- image: circleci/mongo:3.2
steps:
- attach_workspace:
at: /home/circleci/
- restore_cache:
<<: *restore-be-deps-cache
- run:
name: Run backend unit tests (MongoDB 3.2)
environment:
DRIVERS: h2,mongo
command: >
/home/circleci/metabase/metabase/.circleci/skip-driver-tests.sh mongo ||
lein with-profile +ci test
no_output_timeout: 5m
This is telling Circle to start two docker images -- one with Clojure, Leiningen, node, and browsers; the other with MongoDB 3.2. Then it attaches the Metabase source repository to the first Docker instance, restores cached Clojure dependnencies, and finally runs tests with DRIVERS=h2,mongo lein test
. Don't worry too much about the other stuff going on; we have some clever logic for skipping driver tests like this when we don't need to run them; don't worry about how it works.
All you really need to change for your job definition is the docker image (circleci/mongo:3.2
) and mongo
where it appears to the name of driver. After you do that, double-check that you've added the job to the workflows at the end of the file and you're all set.
We're really trying hard not to add support for new databases that can't be ran locally with Docker. They're really hard for us to test and a headache to maintain in general. If your database can't be run locally, a driver for it might not belong in the core Metabase repository.
In rare cases, we'll support a database if the people behind it will provide us with a free partner/developer account -- if that can be done, be sure to let us know the situation when you open your PR, and we'll see whether it still makes sense. If that can't be done (i.e., Metabase would have to pay for an account in order to test your driver on CI), your driver is best shipped as a 3rd-party driver rather than as a part of the core Metabase product.
If the database can be ran locally, but you can't find a Docker image, please create one yourself. It's really not too difficult, I promise! It will save us a lot of time having to do it ourselves.
- Metabase can automatically test your driver, but you need to add test extensions first.
- You can run tests against a certain driver by using the
DRIVERS
environment variable. - Test extensions do things like create new databases and load data for given database definitions.
- Tell CircleCI how to set up a docker image for your database and run tests against it by editing
.circleci/config.yml
- Make sure all tests and linters are passing and that you've added CI instructions to
.circleci/config.yml
before submitting a PR. - Don't submit PRs for drivers for databases that we can't run locally with Docker.
- Backend
- Metabase Developer Reference
- Product Management
- QA and Testing
- Writing A Driver
- Driver Notices
- REST API Notices
- Writing style guide for documentation and blog posts (WIP)