Testing with LocalClient

Copy Markdown View Source

InfluxElixir.Client.Local is an in-memory InfluxDB client that parses real line protocol, stores data in ETS, and responds with the same shapes as the HTTP client. It enables fast, isolated tests with async: true and no external dependencies.

Choosing a Profile

LocalClient enforces an InfluxDB version profile matching your production backend. This ensures your tests fail if you use operations your real InfluxDB doesn't support.

ProfileOperations
:v3_corewrite, SQL queries, InfluxQL, database CRUD
:v3_enterpriseeverything in v3_core + token management
:v2write, Flux queries, bucket CRUD

Setup

1. Add the dependency

# mix.exs
defp deps do
  [
    {:influx_elixir, "~> 0.1"}
  ]
end

2. Configure LocalClient for tests

# config/test.exs
config :influx_elixir, :client, InfluxElixir.Client.Local

3. Write your test setup

defmodule MyApp.InfluxTest do
  use ExUnit.Case, async: true

  alias InfluxElixir.Client.Local

  setup do
    # Match your production InfluxDB version
    {:ok, conn} = Local.start(
      databases: ["myapp_test"],
      profile: :v3_core
    )
    on_exit(fn -> Local.stop(conn) end)
    {:ok, conn: conn}
  end

  test "writes and queries data", %{conn: conn} do
    {:ok, :written} = Local.write(
      conn,
      "sensors,location=lab temp=22.5",
      database: "myapp_test"
    )

    {:ok, [row]} = Local.query_sql(
      conn,
      "SELECT * FROM sensors WHERE location = 'lab' LIMIT 1",
      database: "myapp_test"
    )

    assert row["temp"] == 22.5
    assert row["location"] == "lab"
  end
end

4. Use the shared case template (optional)

If you have many test modules that need InfluxDB, create a shared setup:

# test/support/my_influx_case.ex
defmodule MyApp.InfluxCase do
  use ExUnit.CaseTemplate

  using do
    quote do
      alias InfluxElixir.Client.Local
    end
  end

  setup do
    {:ok, conn} = InfluxElixir.Client.Local.start(
      databases: ["test_db"],
      profile: :v3_core
    )
    on_exit(fn -> InfluxElixir.Client.Local.stop(conn) end)
    {:ok, conn: conn}
  end
end

Then use it in tests:

defmodule MyApp.SensorTest do
  use MyApp.InfluxCase, async: true

  test "stores sensor readings", %{conn: conn} do
    {:ok, :written} = Local.write(conn, "sensors temp=22.5", database: "test_db")
    # ...
  end
end

Connection-Level Default Database

Both Client.HTTP and Client.Local honour the :database config key as a connection-level default. When the caller doesn't pass database: in opts, this value is used:

{:ok, conn} = Local.start(database: "myapp_test")

# No explicit `database:` opt — uses "myapp_test"
{:ok, :written} = Local.write(conn, "sensors temp=22.5")
{:ok, rows} = Local.query_sql(conn, "SELECT * FROM sensors")

:databases (list) is also accepted by both implementations: Client.Local pre-creates each entry; Client.HTTP uses the first entry as the default when :database is not set. This makes a config like database: "primary", databases: ["primary", "backup"] a true drop-in across implementations.

Profile Enforcement

If you pick the wrong profile, operations fail the same way they would against the real backend:

# Your production InfluxDB is v3 Core — no Flux support
{:ok, conn} = Local.start(profile: :v3_core)

# This returns {:error, :unsupported_operation}
Local.query_flux(conn, "from(bucket: \"test\") |> range(start: -1h)")

This catches profile mismatches in tests, before they reach production.

Checking Support at Runtime

Use supports?/2 if you need to conditionally execute operations:

if Local.supports?(conn, :query_flux) do
  Local.query_flux(conn, flux_query)
else
  # fall back or skip
end

Running Contract Tests

The library includes a shared contract test template at InfluxElixir.ClientContract. You can use it to verify that your own adapters or wrappers conform to the InfluxDB client contract:

defmodule MyApp.ContractTest do
  use ExUnit.Case, async: true
  use InfluxElixir.ClientContract,
    client: InfluxElixir.Client.Local,
    profile: :v3_core

  alias InfluxElixir.Client.Local

  setup do
    {:ok, conn} = Local.start(databases: ["contract_db"], profile: :v3_core)
    on_exit(fn -> Local.stop(conn) end)
    {:ok, conn: conn, database: "contract_db", query_delay: 0}
  end
end

The contract tests verify health, write, query, admin, and round-trip operations. They run the same assertions against every backend — if both LocalClient and real InfluxDB pass, LocalClient is proven faithful.

Named Connections via the Facade

LocalClient works seamlessly with the facade's named connection system. When Client.Local is the configured client, ConnectionSupervisor calls Local.init_connection/1 to create an ETS-backed connection and registers it under the given name. All facade functions then work transparently:

# config/test.exs
config :influx_elixir, :client, InfluxElixir.Client.Local

config :influx_elixir, :connections,
  test_db: [
    databases: ["myapp_test"],
    profile: :v3_core
  ]
defmodule MyApp.FacadeTest do
  use ExUnit.Case

  test "write and query via named connection" do
    {:ok, :written} = InfluxElixir.write(
      :test_db,
      "sensors temp=22.5",
      database: "myapp_test"
    )

    {:ok, [row]} = InfluxElixir.query_sql(
      :test_db,
      "SELECT * FROM sensors LIMIT 1",
      database: "myapp_test"
    )

    assert row["temp"] == 22.5
  end
end

This lets you test your application code that uses InfluxElixir.write/3 and InfluxElixir.query_sql/3 without any code changes — just swap the client in config.

Aggregate Queries

LocalClient supports DATE_BIN time-bucketed aggregate queries — the same pattern used in InfluxDB v3 SQL:

test "hourly average temperature", %{conn: conn} do
  # Write some data points
  lines = """
  sensors,location=lab temp=20.0 1000000000000
  sensors,location=lab temp=22.0 2000000000000
  sensors,location=lab temp=24.0 5000000000000
  """
  {:ok, :written} = Local.write(conn, lines, database: "test_db")

  sql = """
  SELECT
    DATE_BIN(INTERVAL '1 hour', time) AS time,
    AVG(temp) AS avg_temp
  FROM "sensors"
  WHERE location = 'lab'
  GROUP BY DATE_BIN(INTERVAL '1 hour', time)
  ORDER BY time ASC
  """

  {:ok, rows} = Local.query_sql(conn, sql, database: "test_db")
  assert [%{"time" => _, "avg_temp" => _} | _] = rows
end

Supported aggregate functions: AVG, SUM, COUNT, MIN, MAX. Ordered aggregates: first(field, time), last(field, time) — for OHLCV candle queries. Supported interval units: seconds, minutes, hours, days.

GROUP BY DATE_BIN is optional. When omitted, aggregate queries return a single scalar row over all matching points:

sql = """
SELECT AVG(net_value) AS average_balance
FROM account_balances
WHERE account_id = 'abc'
"""

{:ok, [%{"average_balance" => avg}]} = Local.query_sql(conn, sql, database: "test_db")

COUNT over zero matching rows returns 0; other aggregates return nil, matching real InfluxDB SQL semantics.

Multi-Column Projection

Specific columns can be projected by name (with optional AS alias):

sql = """
SELECT net_value, total_balance, time
FROM account_balances
WHERE account_id = 'abc'
ORDER BY time DESC
LIMIT 1
"""

{:ok, [row]} = Local.query_sql(conn, sql, database: "test_db")
# => row has keys "net_value", "total_balance", "time" only

Both fields and tags are selectable. Aliasing renames the output key: SELECT net_value AS nv FROM x produces rows keyed by "nv".

IN / NOT IN Clauses

WHERE col IN (...) and WHERE col NOT IN (...) are supported on tags and fields. Combine with binary operators via AND:

sql = """
SELECT * FROM holdings
WHERE ticker IN ('AAPL', 'MSFT') AND shares > 5
"""

{:ok, rows} = Local.query_sql(conn, sql, database: "test_db")

IN () (empty list) matches no rows; NOT IN () matches all rows.

Time Filters with Bare Dates

WHERE time accepts three formats: integer nanoseconds, full ISO-8601 datetimes, and bare ISO dates (interpreted as midnight UTC):

# All three are equivalent if your data is at exactly the day boundary:
"WHERE time >= '2026-03-31'"
"WHERE time >= '2026-03-31T00:00:00Z'"
"WHERE time >= 1774915200000000000"

Unparseable date strings filter out all rows (fail-closed) instead of silently producing wrong results via Elixir term ordering.

Key Differences from Real InfluxDB

  • No WAL flush delay: Writes are immediately queryable (set query_delay: 0)
  • In-memory only: Data is lost when stop/1 is called
  • Simplified SQL parser: Supports SELECT *, multi-column projection (with optional AS alias), WHERE with binary ops + IN / NOT IN, ORDER BY time, LIMIT, DATE_BIN + aggregate functions (AVG, SUM, COUNT, MIN, MAX, first, last) with optional GROUP BY DATE_BIN
  • No authentication: All operations succeed regardless of token
  • ETS-based: Each start/1 creates an isolated ETS table