Repository: ibis-project/ibis

Issue:
test: use pytest fixtures to initialize and populate the backends
I propose to port the click commands from `datamgr.py` and `impalamgr` to pytest fixtures (with `scope="session"` to execute once per pytest invocation).

The python testing suite is dependent on the files under the `ci/` directory. Examples:

`PYTEST_BACKEND=pandas pytest ibis/backends` or `pytest --enable-pandas ibis/backends` should:
1. Check that the testing data exists under the target directory
2. If not, download it otherwise skip
3. Execute the tests with pandas marker enabled

`PYTEST_BACKEND=clickhouse pytest ibis/backends` or `pytest --enable-clickhouse ibis/backends` should:
1. Check that the testing data exists under the target directory
2. If not, download it otherwise skip
3. Check that the required tables exist in clickhouse
4. If not, populate the tables otherwise skip
3. Execute the tests with clickhouse marker enabled

We can add pytest flags to `--force-download`/ `--skip-download` and `--force-populate`/`--skip-populate` the requested backends. 

Even though the first run would take more time since it would implicitly download the data (the second run would be instant), new contributors could use the a single `pytest` command to execute the testing suite. We can add additional logging / progress bar to inform the developer about the data[base] initialization. 


Discussion:
This makes a lot of sense to me. Another option I was thinking was to build docker images with the data already loaded. Instead of pytest fictures we would have docker files replacing datamgr.py. and we wouldn't need to run anything on the CI. Just load the docker container in services.

But the approach here is surely much better than what we've got now. Happy with it too.
That's a possible improvement, though we should evaluate how much time it takes to download the testing data with GHA's bandwidth. Also the docker containers must be downloaded at each run, so we may not benefit from it at all.
Agree. Not sure about time, whether the extra download time would be more or less that the time saved in loading the data to the engines. I think the main advantage would be the simplicity. Once the docker images are generated, we don't need to care about scripts for loading the data or anything related in the CI. Also, for local testing, would make things easier. But I agree, we should evaluate the implications, but worth considering IMO.
I think we should avoid making outbound connections to the internet a hard requirement for testing. I would like to be able to run tests with existing downloaded data, and not have to worry about an unreliable resource to run tests.

How about a middle ground where we keep `datamgr.py download` (maybe rename it to something else, and move it to a one line `curl` script?) but replace all the data loading with the proposed fixtures?
> I think we should avoid making outbound connections to the internet a hard requirement for testing. I would like to be able to run tests with existing downloaded data, and not have to worry about an unreliable resource to run tests.

there is probably a pytest-y way to do this, we have this really old decorator in pandas: https://github.com/pandas-dev/pandas/blob/master/pandas/_testing/_io.py#L104
Thanks Jeff for the reference! I'm fine with both solutions. 
that sounds very nice.

just an extra comment here, sometime ago I tried to use the fixture session approach for creating and removing database for tests, but it didn't work for me because it was conflicting with other things. 

in my case I needed to use: `pytest_sessionstart(session)`and `pytest_sessionfinish(session, exitstatus)`.
I would like to learn about Ibis, and have a bundle of experience with `pytest`. I would like to pick up this issue, if no one else is currently working on it.

The approach I currently want to take is the one outlined by @cpcloud:
> How about a middle ground where we keep datamgr.py download (maybe rename it to something else, and move it to a one line curl script?) but replace all the data loading with the proposed fixtures?

But, this may change after beginning to take a look at it. 

I want to evaluate this as part of the work:
> we should evaluate how much time it takes to download the testing data with GHA's bandwidth.

Heck yeah!

Welcome @anjakefala.

Right, `download` is very simple and does a `git clone` of `ibis-project/ibis-testing-data`. Feel free to take a look at it, but it's pretty bare bones.

The bulk of the work is going to be in figuring out how to move all the functionality in the `load` subcommands like

```python
python ci/datamgpr.py load postgres
python ci/datamgpr.py load duckdb
```

into a `pytest.fixture` (that was my initial thought, not set in stone in any way).
/take
There are a lot of configurable settings in the `click` `load`s.

For e.g. for the postgres:

```
@click.option(
    '-p',
    .....
    default=5432,
    envvar="PGPORT", "IBIS_TEST_POSTGRES_PORT"],
    ....
```

I am making my best guess on reasonable `pytest` defaults based on what is in the conftest.py, and what the default settings are in `click.options`. But, if we actually need some of these loads to be configurable, or different from my guess at the default, that is going to be something to discuss in the PR. 

Am I right to set reasonable `pytest` defaults or is this something that needs to be configurable (noting that for some of them, like port, I will keep references to the environment variables.)
Found testing defaults in `ibis/backends{backend_name}/tests/conftest.py` !

Resolving patch:
diff --git a/ci/datamgr.py b/ci/datamgr.py
--- a/ci/datamgr.py
+++ b/ci/datamgr.py
@@ -2,24 +2,16 @@
 
 from __future__ import annotations
 
-import collections
 import concurrent.futures
-import itertools
 import logging
 import os
 import shutil
 import subprocess
-import tempfile
 from pathlib import Path
-from typing import TYPE_CHECKING, Any, Iterable, Iterator
 
 import click
-import sqlalchemy as sa
 
-if TYPE_CHECKING:
-    import pyarrow as pa
-
-import ibis
+from ibis.backends.conftest import TEST_TABLES, read_tables
 
 IBIS_HOME = Path(__file__).parent.parent.absolute()
 SCRIPT_DIR = Path(__file__).parent.absolute()
@@ -34,78 +26,6 @@
 # appear to be configurable through fsspec
 URLLIB_DEFAULT_POOL_SIZE = 10
 
-TEST_TABLES = {
-    "functional_alltypes": ibis.schema(
-        {
-            "index": "int64",
-            "Unnamed: 0": "int64",
-            "id": "int32",
-            "bool_col": "boolean",
-            "tinyint_col": "int8",
-            "smallint_col": "int16",
-            "int_col": "int32",
-            "bigint_col": "int64",
-            "float_col": "float32",
-            "double_col": "float64",
-            "date_string_col": "string",
-            "string_col": "string",
-            "timestamp_col": "timestamp",
-            "year": "int32",
-            "month": "int32",
-        }
-    ),
-    "diamonds": ibis.schema(
-        {
-            "carat": "float64",
-            "cut": "string",
-            "color": "string",
-            "clarity": "string",
-            "depth": "float64",
-            "table": "float64",
-            "price": "int64",
-            "x": "float64",
-            "y": "float64",
-            "z": "float64",
-        }
-    ),
-    "batting": ibis.schema(
-        {
-            "playerID": "string",
-            "yearID": "int64",
-            "stint": "int64",
-            "teamID": "string",
-            "lgID": "string",
-            "G": "int64",
-            "AB": "int64",
-            "R": "int64",
-            "H": "int64",
-            "X2B": "int64",
-            "X3B": "int64",
-            "HR": "int64",
-            "RBI": "int64",
-            "SB": "int64",
-            "CS": "int64",
-            "BB": "int64",
-            "SO": "int64",
-            "IBB": "int64",
-            "HBP": "int64",
-            "SH": "int64",
-            "SF": "int64",
-            "GIDP": "int64",
-        }
-    ),
-    "awards_players": ibis.schema(
-        {
-            "playerID": "string",
-            "awardID": "string",
-            "yearID": "int64",
-            "lgID": "string",
-            "tie": "string",
-            "notes": "string",
-        }
-    ),
-}
-
 
 def get_logger(name, level=None, format=None, propagate=False):
     logging.basicConfig()
@@ -133,62 +53,6 @@ def get_logger(name, level=None, format=None, propagate=False):
 logger = get_logger(Path(__file__).with_suffix('').name)
 
 
-def impala_create_test_database(con, env):
-    con.drop_database(env.test_data_db, force=True)
-    con.create_database(env.test_data_db)
-    con.create_table(
-        'alltypes',
-        schema=ibis.schema(
-            [
-                ('a', 'int8'),
-                ('b', 'int16'),
-                ('c', 'int32'),
-                ('d', 'int64'),
-                ('e', 'float'),
-                ('f', 'double'),
-                ('g', 'string'),
-                ('h', 'boolean'),
-                ('i', 'timestamp'),
-            ]
-        ),
-        database=env.test_data_db,
-    )
-
-
-PARQUET_SCHEMAS = {
-    'functional_alltypes': TEST_TABLES["functional_alltypes"].delete(
-        ["index", "Unnamed: 0"]
-    ),
-    'tpch_region': ibis.schema(
-        [
-            ('r_regionkey', 'int16'),
-            ('r_name', 'string'),
-            ('r_comment', 'string'),
-        ]
-    ),
-}
-PARQUET_SCHEMAS.update(
-    (table, schema)
-    for table, schema in TEST_TABLES.items()
-    if table != "functional_alltypes"
-)
-
-
-AVRO_SCHEMAS = {
-    'tpch_region_avro': {
-        'type': 'record',
-        'name': 'a',
-        'fields': [
-            {'name': 'R_REGIONKEY', 'type': ['null', 'int']},
-            {'name': 'R_NAME', 'type': ['null', 'string']},
-            {'name': 'R_COMMENT', 'type': ['null', 'string']},
-        ],
-    }
-}
-
-ALL_SCHEMAS = collections.ChainMap(PARQUET_SCHEMAS, AVRO_SCHEMAS)
-
-
 @click.group()
 @click.option("-v", "--verbose", count=True)
 def cli(verbose):
@@ -213,107 +77,6 @@ def generate_parquet(ctx):
         executor.submit(pq.write_table, table, dirname / f"{name}.parquet")
 
 
-def impala_create_tables(con, env, *, executor=None):
-    test_data_dir = env.test_data_dir
-    avro_files = [
-        (con.avro_file, os.path.join(test_data_dir, 'avro', path))
-        for path in con.hdfs.ls(os.path.join(test_data_dir, 'avro'))
-    ]
-    parquet_files = [
-        (con.parquet_file, os.path.join(test_data_dir, 'parquet', path))
-        for path in con.hdfs.ls(os.path.join(test_data_dir, 'parquet'))
-    ]
-    for method, path in itertools.chain(parquet_files, avro_files):
-        logger.debug(os.path.basename(path))
-        yield executor.submit(
-            method,
-            path,
-            ALL_SCHEMAS.get(os.path.basename(path)),
-            name=os.path.basename(path),
-            database=env.test_data_db,
-            persist=True,
-        )
-
-
-def impala_build_and_upload_udfs(hdfs, env, *, fs):
-    logger.info("Building UDFs...")
-
-    cwd = str(IBIS_HOME / 'ci' / 'udf')
-    subprocess.run(["cmake", ".", "-G", "Ninja"], cwd=cwd)
-    subprocess.run(["ninja"], cwd=cwd)
-    build_dir = IBIS_HOME / 'ci' / 'udf' / 'build'
-    bitcode_dir = os.path.join(env.test_data_dir, 'udf')
-
-    hdfs.mkdir(bitcode_dir, create_parents=True)
-
-    for file in fs.find(build_dir):
-        bitcode_path = os.path.join(
-            bitcode_dir, os.path.relpath(file, build_dir)
-        )
-        logger.debug(f"{file} -> ${bitcode_path}")
-        yield hdfs.put_file, file, bitcode_path
-
-
-def recreate_database(
-    url: sa.engine.url.URL,
-    database: str,
-    **kwargs: Any,
-) -> None:
-    engine = sa.create_engine(url, **kwargs)
-
-    if url.database is not None:
-        with engine.connect() as conn:
-            conn.execute(f'DROP DATABASE IF EXISTS {database}')
-            conn.execute(f'CREATE DATABASE {database}')
-
-
-def init_database(
-    url: sa.engine.url.URL,
-    database: str,
-    schema: str | None = None,
-    recreate: bool = True,
-    **kwargs: Any,
-) -> sa.engine.Engine:
-    if recreate:
-        recreate_database(url, database, **kwargs)
-
-    try:
-        url.database = database
-    except AttributeError:
-        url = url.set(database=database)
-
-    engine = sa.create_engine(url, **kwargs)
-
-    if schema:
-        with engine.connect() as conn:
-            for stmt in filter(None, map(str.strip, schema.read().split(';'))):
-                conn.execute(stmt)
-
-    return engine
-
-
-def read_tables(
-    names: Iterable[str],
-    data_directory: Path,
-) -> Iterator[tuple[str, pa.Table]]:
-    import pyarrow.csv as pac
-
-    import ibis.backends.pyarrow.datatypes as pa_dt
-
-    for name in names:
-        schema = TEST_TABLES[name]
-        convert_options = pac.ConvertOptions(
-            column_types={
-                name: pa_dt.to_pyarrow_type(type)
-                for name, type in schema.items()
-            }
-        )
-        yield name, pac.read_csv(
-            data_directory / f'{name}.csv',
-            convert_options=convert_options,
-        )
-
-
 @cli.group()
 def load():
     pass
@@ -400,34 +163,21 @@ def postgres(
     tables,
     data_directory,
 ):
-    logger.info('Initializing PostgreSQL...')
-    engine = init_database(
-        url=sa.engine.make_url(
-            f"postgresql://{username}:{password}@{host}:{port}",
-        ),
-        database=database,
-        schema=schema,
-        isolation_level='AUTOCOMMIT',
+    from ibis.backends.postgres.tests.conftest import (
+        TestConf as PostgresTestConf,
     )
 
-    for table in tables:
-        src = data_directory / f'{table}.csv'
-        # Here we insert rows using COPY table FROM STDIN, by way of
-        # psycopg2's `copy_expert` API.
-        #
-        # We could use DataFrame.to_sql(method=callable), but that incurs
-        # an unnecessary round trip and requires more code: the `data_iter`
-        # argument would have to be turned back into a CSV before being
-        # passed to `copy_expert`.
-        sql = (
-            f"COPY {table} FROM STDIN "
-            "WITH (FORMAT CSV, HEADER TRUE, DELIMITER ',')"
-        )
-        with src.open('r') as file:
-            with engine.begin() as con, con.connection.cursor() as cur:
-                cur.copy_expert(sql=sql, file=file)
-
-    engine.execute('VACUUM FULL ANALYZE')
+    logger.info('Initializing PostgreSQL...')
+    kwargs = {
+        "host": host,
+        "port": port,
+        "username": username,
+        "password": password,
+        "database": database,
+        "schema": schema,
+        "tables": tables,
+    }
+    PostgresTestConf.load_data(data_directory, SCRIPT_DIR, **kwargs)
 
 
 @load.command()
@@ -464,33 +214,15 @@ def postgres(
     ),
 )
 def sqlite(database, schema, tables, data_directory):
-    logger.info('Initializing SQLite...')
-
-    init_database(
-        url=sa.engine.make_url("sqlite://"),
-        database=str(database.absolute()),
-        schema=schema,
-    )
+    from ibis.backends.sqlite.tests.conftest import TestConf as SqliteTestConf
 
-    with tempfile.TemporaryDirectory() as tempdir:
-        for table in tables:
-            basename = f"{table}.csv"
-            with Path(tempdir).joinpath(basename).open("w") as f:
-                with data_directory.joinpath(basename).open("r") as lines:
-                    # skip the first line
-                    f.write("".join(itertools.islice(lines, 1, None)))
-        subprocess.run(
-            ["sqlite3", database],
-            input="\n".join(
-                [
-                    ".separator ,",
-                    *(
-                        f".import {str(path.absolute())!r} {path.stem}"
-                        for path in Path(tempdir).glob("*.csv")
-                    ),
-                ]
-            ).encode(),
-        )
+    logger.info('Initializing SQLite...')
+    kwargs = {
+        "database": database,
+        "schema": schema,
+        "tables": tables,
+    }
+    SqliteTestConf.load_data(data_directory, SCRIPT_DIR, **kwargs)
 
 
 @load.command()
@@ -535,27 +267,20 @@ def mysql(
     tables,
     data_directory,
 ):
+    from ibis.backends.mysql.tests.conftest import TestConf as MySQLTestConf
+
     logger.info('Initializing MySQL...')
+    kwargs = {
+        "host": host,
+        "port": port,
+        "username": username,
+        "password": password,
+        "database": database,
+        "schema": schema,
+        "tables": tables,
+    }
 
-    engine = init_database(
-        url=sa.engine.make_url(
-            f"mysql+pymysql://{username}:{password}@{host}:{port}?local_infile=1",  # noqa: E501
-        ),
-        database=database,
-        schema=schema,
-        isolation_level='AUTOCOMMIT',
-    )
-    with engine.connect() as con:
-        for table in tables:
-            con.execute(
-                f"""\
-LOAD DATA LOCAL INFILE '{data_directory / f"{table}.csv"}'
-INTO TABLE {table}
-COLUMNS TERMINATED BY ','
-OPTIONALLY ENCLOSED BY '"'
-LINES TERMINATED BY '\\n'
-IGNORE 1 LINES"""
-            )
+    MySQLTestConf.load_data(data_directory, SCRIPT_DIR, **kwargs)
 
 
 @load.command()
@@ -591,32 +316,14 @@ def mysql(
     ),
 )
 def clickhouse(schema, tables, data_directory, **params):
-    import clickhouse_driver
+    from ibis.backends.clickhouse.tests.conftest import (
+        TestConf as ClickhouseTestConf,
+    )
 
     logger.info('Initializing ClickHouse...')
-    database = params.pop("database")
-    client = clickhouse_driver.Client(**params)
-
-    client.execute(f"DROP DATABASE IF EXISTS {database}")
-    client.execute(f"CREATE DATABASE {database}")
-    client.execute(f"USE {database}")
-
-    for stmt in filter(None, map(str.strip, schema.read().split(';'))):
-        client.execute(stmt)
-
-    for table, df in read_tables(tables, data_directory):
-        query = f"INSERT INTO {table} VALUES"
-        client.insert_dataframe(
-            query,
-            df.to_pandas(),
-            settings={"use_numpy": True},
-        )
-
-
-def hdfs_make_dir_and_put_file(fs, src, target):
-    logger.debug(f"{src} -> {target}")
-    fs.mkdir(os.path.dirname(target), create_parents=True)
-    fs.put_file(src, target)
+    params["schema"] = schema
+    params["tables"] = tables
+    ClickhouseTestConf.load_data(data_directory, SCRIPT_DIR, **params)
 
 
 @load.command()
@@ -624,76 +331,10 @@ def hdfs_make_dir_and_put_file(fs, src, target):
 @click.pass_context
 def impala(ctx, data_dir):
     """Load impala test data for Ibis."""
-    import fsspec
-
-    from ibis.backends.impala.tests.conftest import IbisTestEnv
+    from ibis.backends.impala.tests.conftest import TestConf as ImpalaTestConf
 
     logger.info('Initializing Impala...')
-    env = IbisTestEnv()
-    con = ibis.impala.connect(
-        host=env.impala_host,
-        port=env.impala_port,
-        hdfs_client=fsspec.filesystem(
-            env.hdfs_protocol,
-            host=env.nn_host,
-            port=env.hdfs_port,
-            user=env.hdfs_user,
-        ),
-        pool_size=URLLIB_DEFAULT_POOL_SIZE,
-    )
-
-    fs = fsspec.filesystem("file")
-
-    data_files = {
-        data_file
-        for data_file in fs.find(data_dir)
-        # ignore sqlite databases and markdown files
-        if not data_file.endswith((".db", ".md"))
-        # ignore files in the test data .git directory
-        if (
-            # ignore .git
-            os.path.relpath(data_file, data_dir).split(os.sep, 1)[0]
-            != ".git"
-        )
-    }
-
-    executor = ctx.obj["executor"]
-
-    hdfs = con.hdfs
-    tasks = {
-        # make the database
-        executor.submit(impala_create_test_database, con, env),
-        # build and upload UDFs
-        *itertools.starmap(
-            executor.submit,
-            impala_build_and_upload_udfs(hdfs, env, fs=fs),
-        ),
-        # upload data files
-        *(
-            executor.submit(
-                hdfs_make_dir_and_put_file,
-                hdfs,
-                data_file,
-                os.path.join(
-                    env.test_data_dir,
-                    os.path.relpath(data_file, data_dir),
-                ),
-            )
-            for data_file in data_files
-        ),
-    }
-
-    for future in concurrent.futures.as_completed(tasks):
-        future.result()
-
-    # create the tables and compute stats
-    for future in concurrent.futures.as_completed(
-        executor.submit(table_future.result().compute_stats)
-        for table_future in concurrent.futures.as_completed(
-            impala_create_tables(con, env, executor=executor)
-        )
-    ):
-        future.result()
+    ImpalaTestConf.load_data(data_dir, SCRIPT_DIR)
 
 
 @load.command()
@@ -754,18 +395,15 @@ def all(ctx):
     ),
 )
 def duckdb(schema, tables, data_directory, database, **_):
-    import duckdb  # noqa: F401
+    from ibis.backends.duckdb.tests.conftest import TestConf as DuckDBTestConf
 
     logger.info('Initializing DuckDB...')
-    conn = duckdb.connect(str(data_directory / f"{database}.ddb"))
-    for stmt in filter(None, map(str.strip, schema.read().split(';'))):
-        conn.execute(stmt)
-
-    for table in tables:
-        src = data_directory / f'{table}.csv'
-        conn.execute(
-            f"COPY {table} FROM '{src}' (DELIMITER ',', HEADER, SAMPLE_SIZE 1)"
-        )
+    kwargs = {
+        "schema": schema,
+        "tables": tables,
+        "database": database,
+    }
+    DuckDBTestConf.load_data(data_directory, SCRIPT_DIR, **kwargs)
 
 
 if __name__ == '__main__':
diff --git a/ibis/backends/conftest.py b/ibis/backends/conftest.py
--- a/ibis/backends/conftest.py
+++ b/ibis/backends/conftest.py
@@ -5,10 +5,15 @@
 import platform
 from functools import lru_cache
 from pathlib import Path
-from typing import Any
+from typing import TYPE_CHECKING, Any, Iterable, Iterator, TextIO
 
 import _pytest
 import pandas as pd
+import sqlalchemy as sa
+
+if TYPE_CHECKING:
+    import pyarrow as pa
+
 import pytest
 
 import ibis
@@ -20,6 +25,91 @@
     import importlib_metadata
 
 
+TEST_TABLES = {
+    "functional_alltypes": ibis.schema(
+        {
+            "index": "int64",
+            "Unnamed: 0": "int64",
+            "id": "int32",
+            "bool_col": "boolean",
+            "tinyint_col": "int8",
+            "smallint_col": "int16",
+            "int_col": "int32",
+            "bigint_col": "int64",
+            "float_col": "float32",
+            "double_col": "float64",
+            "date_string_col": "string",
+            "string_col": "string",
+            "timestamp_col": "timestamp",
+            "year": "int32",
+            "month": "int32",
+        }
+    ),
+    "diamonds": ibis.schema(
+        {
+            "carat": "float64",
+            "cut": "string",
+            "color": "string",
+            "clarity": "string",
+            "depth": "float64",
+            "table": "float64",
+            "price": "int64",
+            "x": "float64",
+            "y": "float64",
+            "z": "float64",
+        }
+    ),
+    "batting": ibis.schema(
+        {
+            "playerID": "string",
+            "yearID": "int64",
+            "stint": "int64",
+            "teamID": "string",
+            "lgID": "string",
+            "G": "int64",
+            "AB": "int64",
+            "R": "int64",
+            "H": "int64",
+            "X2B": "int64",
+            "X3B": "int64",
+            "HR": "int64",
+            "RBI": "int64",
+            "SB": "int64",
+            "CS": "int64",
+            "BB": "int64",
+            "SO": "int64",
+            "IBB": "int64",
+            "HBP": "int64",
+            "SH": "int64",
+            "SF": "int64",
+            "GIDP": "int64",
+        }
+    ),
+    "awards_players": ibis.schema(
+        {
+            "playerID": "string",
+            "awardID": "string",
+            "yearID": "int64",
+            "lgID": "string",
+            "tie": "string",
+            "notes": "string",
+        }
+    ),
+}
+
+
+@pytest.fixture(scope='session')
+def script_directory() -> Path:
+    """Return the test script directory.
+
+    Returns
+    -------
+    Path
+        Test script directory
+    """
+    return Path(__file__).absolute().parents[2] / "ci"
+
+
 @pytest.fixture(scope='session')
 def data_directory() -> Path:
     """Return the test data directory.
@@ -29,7 +119,7 @@ def data_directory() -> Path:
     Path
         Test data directory
     """
-    root = Path(__file__).absolute().parent.parent.parent
+    root = Path(__file__).absolute().parents[2]
 
     return Path(
         os.environ.get(
@@ -39,6 +129,98 @@ def data_directory() -> Path:
     )
 
 
+def recreate_database(
+    url: sa.engine.url.URL,
+    database: str,
+    **kwargs: Any,
+) -> None:
+    """Drop the {database} at {url}, if it exists.
+
+    Create a new, blank database with the same name.
+
+    Parameters
+    ----------
+    url : url.sa.engine.url.URL
+        Connection url to the database
+    database : str
+        Name of the database to be dropped.
+    """
+    engine = sa.create_engine(url, **kwargs)
+
+    if url.database is not None:
+        with engine.connect() as conn:
+            conn.execute(f'DROP DATABASE IF EXISTS {database}')
+            conn.execute(f'CREATE DATABASE {database}')
+
+
+def init_database(
+    url: sa.engine.url.URL,
+    database: str,
+    schema: TextIO | None = None,
+    recreate: bool = True,
+    **kwargs: Any,
+) -> sa.engine.Engine:
+    """Initialise {database} at {url} with {schema}.
+
+    If {recreate}, drop the {database} at {url}, if it exists.
+
+    Parameters
+    ----------
+    url : url.sa.engine.url.URL
+        Connection url to the database
+    database : str
+        Name of the database to be dropped
+    schema : TextIO
+        File object containing schema to use
+    recreate : bool
+        If true, drop the database if it exists
+
+    Returns
+    -------
+    sa.engine.Engine for the database created
+    """
+    if recreate:
+        recreate_database(url, database, **kwargs)
+
+    try:
+        url.database = database
+    except AttributeError:
+        url = url.set(database=database)
+
+    engine = sa.create_engine(url, **kwargs)
+
+    if schema:
+        with engine.connect() as conn:
+            for stmt in filter(None, map(str.strip, schema.read().split(';'))):
+                conn.execute(stmt)
+
+    return engine
+
+
+def read_tables(
+    names: Iterable[str],
+    data_dir: Path,
+) -> Iterator[tuple[str, pa.Table]]:
+    """For each csv {names} in {data_dir} return a pyarrow.Table"""
+
+    import pyarrow.csv as pac
+
+    import ibis.backends.pyarrow.datatypes as pa_dt
+
+    for name in names:
+        schema = TEST_TABLES[name]
+        convert_options = pac.ConvertOptions(
+            column_types={
+                name: pa_dt.to_pyarrow_type(type)
+                for name, type in schema.items()
+            }
+        )
+        yield name, pac.read_csv(
+            data_dir / f'{name}.csv',
+            convert_options=convert_options,
+        )
+
+
 def _random_identifier(suffix: str) -> str:
     return f"__ibis_test_{suffix}_{util.guid()}"
 
@@ -259,9 +441,24 @@ def pytest_runtest_call(item):
 
 
 @pytest.fixture(params=_get_backends_to_test(), scope='session')
-def backend(request, data_directory):
-    """Return an instance of BackendTest."""
+def backend(
+    request, data_directory, script_directory, tmp_path_factory, worker_id
+):
+    """Return an instance of BackendTest, loaded with data."""
+    from filelock import FileLock
+
     cls = _get_backend_conf(request.param)
+
+    # handling for multi-processes pytest
+
+    # get the temp directory shared by all workers
+    root_tmp_dir = tmp_path_factory.getbasetemp().parent
+
+    fn = root_tmp_dir / f"lockfile_{request.param}"
+    with FileLock(str(fn) + ".lock"):
+        if not fn.is_file():
+            cls.load_data(data_directory, script_directory)
+            fn.touch()
     return cls(data_directory)
 
 


Test patch:
diff --git a/docs/contribute/04_backend_tests.md b/docs/contribute/04_backend_tests.md
--- a/docs/contribute/04_backend_tests.md
+++ b/docs/contribute/04_backend_tests.md
@@ -72,23 +72,16 @@ success
 
 Congrats, you now have a PostgreSQL server running and are ready to run tests!
 
-#### Load Data
+#### Download Data
 
-The backend needs to be populated with test data:
+The backend needs to be populated with test data.
+The data will be loaded automatically, when the test is run, but it needs to be downloaded first.
 
-1.  Download the data
+To download the data
 
-    ```sh
-    python ci/datamgr.py download
-    ```
-
-2.  In the original terminal, run
-
-    ```sh
-    python ci/datamgr.py load postgres
-    ```
-
-    You should see a bit of logging, and the command should complete shortly thereafter.
+```sh
+python ci/datamgr.py download
+```
 
 #### Run the test suite
 
diff --git a/ibis/backends/clickhouse/tests/conftest.py b/ibis/backends/clickhouse/tests/conftest.py
--- a/ibis/backends/clickhouse/tests/conftest.py
+++ b/ibis/backends/clickhouse/tests/conftest.py
@@ -6,6 +6,7 @@
 
 import ibis
 import ibis.expr.types as ir
+from ibis.backends.conftest import TEST_TABLES, read_tables
 from ibis.backends.tests.base import (
     BackendTest,
     RoundHalfToEven,
@@ -27,6 +28,49 @@ class TestConf(UnorderedComparator, BackendTest, RoundHalfToEven):
     supports_floating_modulus = False
     bool_is_int = True
 
+    @staticmethod
+    def load_data(data_dir: Path, script_dir: Path, **kwargs) -> None:
+        """Load testdata into a clikhouse backend.
+
+        Parameters
+        ----------
+        data_dir : Path
+            Location of testdata
+        script_dir : Path
+            Location of scripts defining schemas
+        """
+        import clickhouse_driver
+
+        host = kwargs.get("host", CLICKHOUSE_HOST)
+        port = kwargs.get("port", CLICKHOUSE_PORT)
+        user = kwargs.get("user", CLICKHOUSE_USER)
+        password = kwargs.get("password", CLICKHOUSE_PASS)
+        database = kwargs.get("database", IBIS_TEST_CLICKHOUSE_DB)
+
+        tables = list(TEST_TABLES.keys())
+        client = clickhouse_driver.Client(
+            host=host,
+            port=port,
+            user=user,
+            password=password,
+        )
+
+        client.execute(f"DROP DATABASE IF EXISTS {database}")
+        client.execute(f"CREATE DATABASE {database}")
+        client.execute(f"USE {database}")
+
+        with open(script_dir / 'schema' / 'clickhouse.sql') as schema:
+            for stmt in filter(None, map(str.strip, schema.read().split(";"))):
+                client.execute(stmt)
+
+        for table, df in read_tables(tables, data_dir):
+            query = f"INSERT INTO {table} VALUES"
+            client.insert_dataframe(
+                query,
+                df.to_pandas(),
+                settings={"use_numpy": True},
+            )
+
     @staticmethod
     def connect(data_directory: Path):
         pytest.importorskip("clickhouse_driver")
diff --git a/ibis/backends/dask/tests/conftest.py b/ibis/backends/dask/tests/conftest.py
--- a/ibis/backends/dask/tests/conftest.py
+++ b/ibis/backends/dask/tests/conftest.py
@@ -21,6 +21,10 @@ def npartitions():
 class TestConf(PandasTest):
     supports_structs = False
 
+    @staticmethod
+    def load_data(data_dir, script_dir, **kwargs):
+        """No-op to allow pytest -m dask"""
+
     @staticmethod
     def connect(data_directory: Path):
         # Note - we use `dd.from_pandas(pd.read_csv(...))` instead of
diff --git a/ibis/backends/datafusion/tests/conftest.py b/ibis/backends/datafusion/tests/conftest.py
--- a/ibis/backends/datafusion/tests/conftest.py
+++ b/ibis/backends/datafusion/tests/conftest.py
@@ -17,6 +17,10 @@ class TestConf(BackendTest, RoundAwayFromZero):
     bool_is_int = True
     supports_structs = False
 
+    @staticmethod
+    def load_data(data_dir, script_dir, **kwargs):
+        """No-op to allow pytest -m datafusion"""
+
     @staticmethod
     def connect(data_directory: Path):
         # can be various types:
diff --git a/ibis/backends/duckdb/tests/conftest.py b/ibis/backends/duckdb/tests/conftest.py
--- a/ibis/backends/duckdb/tests/conftest.py
+++ b/ibis/backends/duckdb/tests/conftest.py
@@ -2,6 +2,7 @@
 from pathlib import Path
 
 import ibis
+from ibis.backends.conftest import TEST_TABLES
 from ibis.backends.tests.base import BackendTest, RoundAwayFromZero
 
 
@@ -9,6 +10,36 @@ class TestConf(BackendTest, RoundAwayFromZero):
     def __init__(self, data_directory: Path) -> None:
         self.connection = self.connect(data_directory)
 
+    @staticmethod
+    def load_data(data_dir, script_dir, **kwargs):
+        """Load testdata into an impala backend.
+
+        Parameters
+        ----------
+        data_dir : Path
+            Location of testdata
+        script_dir : Path
+            Location of scripts defining schemas
+        """
+
+        database = kwargs.get("database", "ibis_testing")
+
+        schema = (script_dir / 'schema' / 'duckdb.sql').read_text()
+        tables = TEST_TABLES
+
+        import duckdb  # noqa: F401
+
+        conn = duckdb.connect(str(data_dir / f"{database}.ddb"))
+        for stmt in filter(None, map(str.strip, schema.split(';'))):
+            conn.execute(stmt)
+
+        for table in tables:
+            src = data_dir / f'{table}.csv'
+            conn.execute(
+                f"COPY {table} FROM '{src}'"
+                " (DELIMITER ',', HEADER, SAMPLE_SIZE 1)"
+            )
+
     @staticmethod
     @lru_cache(maxsize=None)
     def connect(data_directory: Path):
diff --git a/ibis/backends/impala/tests/conftest.py b/ibis/backends/impala/tests/conftest.py
--- a/ibis/backends/impala/tests/conftest.py
+++ b/ibis/backends/impala/tests/conftest.py
@@ -1,6 +1,10 @@
 from __future__ import annotations
 
+import collections
+import concurrent.futures
+import itertools
 import os
+import subprocess
 from pathlib import Path
 
 import pytest
@@ -9,6 +13,7 @@
 import ibis.expr.types as ir
 import ibis.util as util
 from ibis import options
+from ibis.backends.conftest import TEST_TABLES
 from ibis.backends.impala.compiler import ImpalaCompiler, ImpalaExprTranslator
 from ibis.backends.tests.base import (
     BackendTest,
@@ -26,6 +31,101 @@ class TestConf(UnorderedComparator, BackendTest, RoundAwayFromZero):
     returned_timestamp_unit = 's'
     supports_structs = False
 
+    @staticmethod
+    def load_data(data_dir, script_dir, **kwargs):
+        """Load testdata into an impala backend.
+
+        Parameters
+        ----------
+        data_dir : Path
+            Location of testdata
+        script_dir : Path
+            Location of scripts defining schemas
+        """
+        import fsspec
+
+        # without setting the pool size
+        # connections are dropped from the urllib3
+        # connection pool when the number of workers exceeds this value.
+        # this doesn't appear to be configurable through fsspec
+        URLLIB_DEFAULT_POOL_SIZE = 10
+
+        env = IbisTestEnv()
+        try:
+            con = ibis.impala.connect(
+                host=env.impala_host,
+                port=env.impala_port,
+                hdfs_client=fsspec.filesystem(
+                    env.hdfs_protocol,
+                    host=env.nn_host,
+                    port=env.hdfs_port,
+                    user=env.hdfs_user,
+                ),
+                pool_size=URLLIB_DEFAULT_POOL_SIZE,
+            )
+        except Exception:
+            raise
+
+        fs = fsspec.filesystem("file")
+
+        data_files = {
+            data_file
+            for data_file in fs.find(data_dir)
+            # ignore sqlite databases and markdown files
+            if not data_file.endswith((".db", ".md"))
+            # ignore files in the test data .git directory
+            if (
+                # ignore .git
+                os.path.relpath(data_file, data_dir).split(os.sep, 1)[0]
+                != ".git"
+            )
+        }
+
+        executor = concurrent.futures.ThreadPoolExecutor(
+            max_workers=int(
+                os.environ.get(
+                    "IBIS_DATA_MAX_WORKERS",
+                    URLLIB_DEFAULT_POOL_SIZE,
+                )
+            )
+        )
+
+        hdfs = con.hdfs
+        tasks = {
+            # make the database
+            executor.submit(impala_create_test_database, con, env),
+            # build and upload UDFs
+            *itertools.starmap(
+                executor.submit,
+                impala_build_and_upload_udfs(hdfs, env, fs=fs),
+            ),
+            # upload data files
+            *(
+                executor.submit(
+                    hdfs_make_dir_and_put_file,
+                    hdfs,
+                    data_file,
+                    os.path.join(
+                        env.test_data_dir,
+                        os.path.relpath(data_file, data_dir),
+                    ),
+                )
+                for data_file in data_files
+            ),
+        }
+
+        for future in concurrent.futures.as_completed(tasks):
+            future.result()
+
+        # create the tables and compute stats
+        for future in concurrent.futures.as_completed(
+            executor.submit(table_future.result().compute_stats)
+            for table_future in concurrent.futures.as_completed(
+                impala_create_tables(con, env, executor=executor)
+            )
+        ):
+            future.result()
+
     @staticmethod
     def connect(data_directory: Path):
         fsspec = pytest.importorskip("fsspec")
@@ -384,3 +484,102 @@ def translate(expr, context=None, named=False):
         context = ImpalaCompiler.make_context()
     translator = ImpalaExprTranslator(expr, context=context, named=named)
     return translator.get_result()
+
+
+def impala_build_and_upload_udfs(hdfs, env, *, fs):
+    IBIS_HOME = Path(__file__).absolute().parents[4]
+    cwd = str(IBIS_HOME / "ci" / "udf")
+    subprocess.run(["cmake", ".", "-G", "Ninja"], cwd=cwd)
+    subprocess.run(["ninja"], cwd=cwd)
+    build_dir = IBIS_HOME / "ci" / "udf" / "build"
+    bitcode_dir = os.path.join(env.test_data_dir, "udf")
+
+    hdfs.mkdir(bitcode_dir, create_parents=True)
+
+    for file in fs.find(build_dir):
+        bitcode_path = os.path.join(
+            bitcode_dir, os.path.relpath(file, build_dir)
+        )
+        yield hdfs.put_file, file, bitcode_path
+
+
+def hdfs_make_dir_and_put_file(fs, src, target):
+    fs.mkdir(os.path.dirname(target), create_parents=True)
+    fs.put_file(src, target)
+
+
+def impala_create_test_database(con, env):
+    con.drop_database(env.test_data_db, force=True)
+    con.create_database(env.test_data_db)
+    con.create_table(
+        'alltypes',
+        schema=ibis.schema(
+            [
+                ('a', 'int8'),
+                ('b', 'int16'),
+                ('c', 'int32'),
+                ('d', 'int64'),
+                ('e', 'float'),
+                ('f', 'double'),
+                ('g', 'string'),
+                ('h', 'boolean'),
+                ('i', 'timestamp'),
+            ]
+        ),
+        database=env.test_data_db,
+    )
+
+
+PARQUET_SCHEMAS = {
+    'functional_alltypes': TEST_TABLES["functional_alltypes"].delete(
+        ["index", "Unnamed: 0"]
+    ),
+    "tpch_region": ibis.schema(
+        [
+            ("r_regionkey", "int16"),
+            ("r_name", "string"),
+            ("r_comment", "string"),
+        ]
+    ),
+}
+
+PARQUET_SCHEMAS.update(
+    (table, schema)
+    for table, schema in TEST_TABLES.items()
+    if table != "functional_alltypes"
+)
+
+AVRO_SCHEMAS = {
+    "tpch_region_avro": {
+        "type": "record",
+        "name": "a",
+        "fields": [
+            {"name": "R_REGIONKEY", "type": ["null", "int"]},
+            {"name": "R_NAME", "type": ["null", "string"]},
+            {"name": "R_COMMENT", "type": ["null", "string"]},
+        ],
+    }
+}
+
+ALL_SCHEMAS = collections.ChainMap(PARQUET_SCHEMAS, AVRO_SCHEMAS)
+
+
+def impala_create_tables(con, env, *, executor=None):
+    test_data_dir = env.test_data_dir
+    avro_files = [
+        (con.avro_file, os.path.join(test_data_dir, 'avro', path))
+        for path in con.hdfs.ls(os.path.join(test_data_dir, 'avro'))
+    ]
+    parquet_files = [
+        (con.parquet_file, os.path.join(test_data_dir, 'parquet', path))
+        for path in con.hdfs.ls(os.path.join(test_data_dir, 'parquet'))
+    ]
+    for method, path in itertools.chain(parquet_files, avro_files):
+        yield executor.submit(
+            method,
+            path,
+            ALL_SCHEMAS.get(os.path.basename(path)),
+            name=os.path.basename(path),
+            database=env.test_data_db,
+            persist=True,
+        )
diff --git a/ibis/backends/mysql/tests/conftest.py b/ibis/backends/mysql/tests/conftest.py
--- a/ibis/backends/mysql/tests/conftest.py
+++ b/ibis/backends/mysql/tests/conftest.py
@@ -1,10 +1,13 @@
 import os
+import textwrap
 from pathlib import Path
 
 import pytest
+import sqlalchemy as sa
 from pkg_resources import parse_version
 
 import ibis
+from ibis.backends.conftest import TEST_TABLES, init_database
 from ibis.backends.tests.base import BackendTest, RoundHalfToEven
 
 MYSQL_USER = os.environ.get('IBIS_TEST_MYSQL_USER', 'ibis')
@@ -49,6 +52,49 @@ def __init__(self, data_directory: Path) -> None:
             # mysql supports window operations after version 8
             self.__class__.supports_window_operations = True
 
+    @staticmethod
+    def load_data(data_dir: Path, script_dir: Path, **kwargs) -> None:
+        """Load testdata into a mysql backend.
+
+        Parameters
+        ----------
+        data_dir : Path
+            Location of testdata
+        script_dir : Path
+            Location of scripts defining schemas
+        """
+        user = kwargs.get("username", MYSQL_USER)
+        password = kwargs.get("password", MYSQL_PASS)
+        host = kwargs.get("host", MYSQL_HOST)
+        port = kwargs.get("port", MYSQL_PORT)
+        database = kwargs.get("database", IBIS_TEST_MYSQL_DB)
+
+        tables = list(TEST_TABLES.keys())
+        with open(script_dir / 'schema' / 'mysql.sql') as schema:
+
+            engine = init_database(
+                url=sa.engine.make_url(
+                    f"mysql+pymysql://{user}:{password}@{host}:{port}?local_infile=1",  # noqa: E501
+                ),
+                database=database,
+                schema=schema,
+                isolation_level="AUTOCOMMIT",
+            )
+            with engine.connect() as con:
+                for table in tables:
+                    con.execute(
+                        textwrap.dedent(
+                            f"""\
+                        LOAD DATA LOCAL INFILE\
+                        '{data_dir / f"{table}.csv"}'
+                        INTO TABLE {table}
+                        COLUMNS TERMINATED BY ','
+                        OPTIONALLY ENCLOSED BY '"'
+                        LINES TERMINATED BY '\\n'
+                        IGNORE 1 LINES"""
+                        )
+                    )
+
     @staticmethod
     def connect(_: Path):
         return ibis.mysql.connect(
diff --git a/ibis/backends/pandas/tests/conftest.py b/ibis/backends/pandas/tests/conftest.py
--- a/ibis/backends/pandas/tests/conftest.py
+++ b/ibis/backends/pandas/tests/conftest.py
@@ -18,6 +18,10 @@ class TestConf(BackendTest, RoundHalfToEven):
     supports_divide_by_zero = True
     returned_timestamp_unit = 'ns'
 
+    @staticmethod
+    def load_data(data_dir, script_dir, **kwargs):
+        '''No-op to allow pytest -m pandas'''
+
     @staticmethod
     def connect(data_directory: Path):
         return ibis.pandas.connect(
diff --git a/ibis/backends/postgres/tests/conftest.py b/ibis/backends/postgres/tests/conftest.py
--- a/ibis/backends/postgres/tests/conftest.py
+++ b/ibis/backends/postgres/tests/conftest.py
@@ -18,8 +18,10 @@
 from typing import Generator
 
 import pytest
+import sqlalchemy as sa
 
 import ibis
+from ibis.backends.conftest import TEST_TABLES, init_database
 from ibis.backends.tests.base import BackendTest, RoundHalfToEven
 
 PG_USER = os.environ.get(
@@ -46,6 +48,54 @@ class TestConf(BackendTest, RoundHalfToEven):
     returned_timestamp_unit = 's'
     supports_structs = False
 
+    @staticmethod
+    def load_data(data_dir: Path, script_dir: Path, **kwargs) -> None:
+        """Load testdata into the postgres backend.
+
+        Parameters
+        ----------
+        data_dir : Path
+            Location of testdata
+        script_dir : Path
+            Location of scripts defining schemas
+        """
+        user = kwargs.get("username", PG_USER)
+        password = kwargs.get("password", PG_PASS)
+        host = kwargs.get("host", PG_HOST)
+        port = kwargs.get("port", PG_PORT)
+        database = kwargs.get("database", IBIS_TEST_POSTGRES_DB)
+
+        tables = list(TEST_TABLES) + ['geo']
+        with open(script_dir / 'schema' / 'postgresql.sql') as schema:
+
+            engine = init_database(
+                url=sa.engine.make_url(
+                    f"postgresql://{user}:{password}@{host}:{port}",
+                ),
+                database=database,
+                schema=schema,
+                isolation_level='AUTOCOMMIT',
+            )
+
+        for table in tables:
+            src = data_dir / f'{table}.csv'
+            # Here we insert rows using COPY table FROM STDIN, by way of
+            # psycopg2's `copy_expert` API.
+            #
+            # We could use DataFrame.to_sql(method=callable), but that incurs
+            # an unnecessary round trip and requires more code: the `data_iter`
+            # argument would have to be turned back into a CSV before being
+            # passed to `copy_expert`.
+            sql = (
+                f"COPY {table} FROM STDIN "
+                "WITH (FORMAT CSV, HEADER TRUE, DELIMITER ',')"
+            )
+            with src.open('r') as file:
+                with engine.begin() as con, con.connection.cursor() as cur:
+                    cur.copy_expert(sql=sql, file=file)
+
+        engine.execute('VACUUM FULL ANALYZE')
+
     @staticmethod
     def connect(data_directory: Path):
         user = os.environ.get(
diff --git a/ibis/backends/pyspark/tests/conftest.py b/ibis/backends/pyspark/tests/conftest.py
--- a/ibis/backends/pyspark/tests/conftest.py
+++ b/ibis/backends/pyspark/tests/conftest.py
@@ -226,6 +226,10 @@ def get_pyspark_testing_client(data_directory):
 class TestConf(BackendTest, RoundAwayFromZero):
     supported_to_timestamp_units = {'s'}
 
+    @staticmethod
+    def load_data(data_dir, script_dir, **kwargs):
+        """No-op to allow pytest -m pyspark"""
+
     @staticmethod
     def connect(data_directory):
         return get_pyspark_testing_client(data_directory)
diff --git a/ibis/backends/sqlite/tests/conftest.py b/ibis/backends/sqlite/tests/conftest.py
--- a/ibis/backends/sqlite/tests/conftest.py
+++ b/ibis/backends/sqlite/tests/conftest.py
@@ -1,10 +1,15 @@
+import itertools
 import os
+import subprocess
+import tempfile
 from pathlib import Path
 
 import pytest
+import sqlalchemy as sa
 
 import ibis
 import ibis.expr.types as ir
+from ibis.backends.conftest import TEST_TABLES, init_database
 from ibis.backends.tests.base import BackendTest, RoundAwayFromZero
 
 
@@ -16,6 +21,53 @@ class TestConf(BackendTest, RoundAwayFromZero):
     returned_timestamp_unit = 's'
     supports_structs = False
 
+    @staticmethod
+    def load_data(data_dir: Path, script_dir: Path, **kwargs) -> None:
+        """Load testdata into the SQLite backend.
+
+        Parameters
+        ----------
+        data_dir : Path
+            Location of testdata
+        script_dir : Path
+            Location of scripts defining schemas
+        """
+        IBIS_TEST_SQLITE_DB = Path(
+            os.environ.get(
+                "IBIS_TEST_SQLITE_DATABASE", data_dir / "ibis_testing.db"
+            )
+        )
+        database = kwargs.get("database", IBIS_TEST_SQLITE_DB)
+
+        tables = list(TEST_TABLES.keys())
+        with open(script_dir / 'schema' / 'sqlite.sql') as schema:
+
+            init_database(
+                url=sa.engine.make_url("sqlite://"),
+                database=str(database.absolute()),
+                schema=schema,
+            )
+
+        with tempfile.TemporaryDirectory() as tempdir:
+            for table in tables:
+                basename = f"{table}.csv"
+                with Path(tempdir).joinpath(basename).open("w") as f:
+                    with data_dir.joinpath(basename).open("r") as lines:
+                        # skip the first line
+                        f.write("".join(itertools.islice(lines, 1, None)))
+            subprocess.run(
+                ["sqlite3", database],
+                input="\n".join(
+                    [
+                        ".separator ,",
+                        *(
+                            f".import {str(path.absolute())!r} {path.stem}"
+                            for path in Path(tempdir).glob("*.csv")
+                        ),
+                    ]
+                ).encode(),
+            )
+
     @staticmethod
     def connect(data_directory: Path):
         path = Path(
diff --git a/ibis/backends/tests/base.py b/ibis/backends/tests/base.py
--- a/ibis/backends/tests/base.py
+++ b/ibis/backends/tests/base.py
@@ -89,6 +89,11 @@ def name(cls) -> str:
     def connect(data_directory: Path):
         """Return a connection with data loaded from `data_directory`."""
 
+    @staticmethod
+    def load_data(data_dir: Path, script_dir: Path, **kwargs) -> None:
+        """Load testdata from `data_dir` into
+        the backend using scripts in `script_dir`."""
+
     @classmethod
     def assert_series_equal(
         cls, left: pd.Series, right: pd.Series, *args: Any, **kwargs: Any