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 (one argument). Ordered aggregates use the InfluxDB v3 SQL (DataFusion) spelling: first_value(field ORDER BY col [ASC|DESC]) and last_value(field ORDER BY col [ASC|DESC]) — for OHLCV candles and "latest value per group" queries:

sql = """
SELECT symbol, last_value(price ORDER BY time) AS price
FROM prices
WHERE time >= $start
GROUP BY symbol, provider
"""

The ORDER BY inside the call is required. Without it the real engine returns an arbitrary row from each group, which the double cannot reproduce, so it rejects the query instead of certifying a non-deterministic result. InfluxQL-style FIRST(field, time) / LAST(field, time) are rejected too: InfluxDB v3 SQL has no such functions (the real engine fails planning with Invalid function 'last'), and a double that accepted them would pass tests for a query that 400s in production.

Supported interval units: seconds, minutes, hours, days.

Anything outside the supported subset is rejected with {:error, %{status: 400, body: "Client.Local: ..."}}. The Client.Local: prefix tells you the double, not InfluxDB, refused the query.

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.

GROUP BY Tag/Field Columns

GROUP BY on bare tag/field columns is supported alongside DATE_BIN time bucketing. The grouping columns can also appear in the SELECT list (with optional AS alias):

sql = """
SELECT ticker, AVG(value) AS average_balance, holding_type
FROM account_holdings
WHERE account_id = 'abc'
GROUP BY ticker, holding_type
"""

{:ok, rows} = Local.query_sql(conn, sql, database: "test_db")
# => one row per unique (ticker, holding_type) pair

Flux Queries (:v2 profile)

query_flux/3 returns the same long rows a real InfluxDB 2.x returns: one row per field, with _field / _value, _measurement, _time (a DateTime), the tags, result, and a table index per series:

{:ok, conn} = Local.start(profile: :v2)
:ok = Local.create_bucket(conn, "metrics")
{:ok, :written} = Local.write(conn, "cpu,host=web01 value=1.0,count=3i", database: "metrics")

{:ok, rows} =
  Local.query_flux(conn, """
  from(bucket: "metrics")
    |> range(start: -1h)
    |> filter(fn: (r) => r._measurement == "cpu")
    |> filter(fn: (r) => r._field == "value")
  """)

assert [%{"_field" => "value", "_value" => 1.0, "host" => "web01", "table" => 0}] = rows

Supported predicates: from(bucket:), range(start: -N[smhd]), r._measurement == "...", r._field == "...", and r.<tag_or_field> == "...". Against a real server, set api_version: :v2 on the connection so writes go to /api/v2/write.

Decimal Params

Decimal values pass through params: as bare numeric literals — no quoting, so WHERE amount >= $min performs a numeric comparison even when $min is a %Decimal{}:

params = %{"$min" => Decimal.new("1000.00")}
{:ok, rows} = Local.query_sql(conn, sql, database: "test_db", params: params)

Do not pass pre-stringified numbers ("1000.00") as params. A string param becomes a string literal, and InfluxDB v3 compares a numeric column against a string literal by casting the column to text — so amount >= '1000.00' is a lexical comparison in which 500.0 matches. Client.Local reproduces that so the mistake fails in tests rather than in production.

WHERE Literal Typing

Quoted literals are always strings, exactly as in InfluxDB v3. A zero-padded identifier keeps its leading zero and matches a string tag:

sql = "SELECT * FROM accounts WHERE repcode IN ($rc)"
{:ok, rows} = Local.query_sql(conn, sql, database: "test_db", params: %{rc: "08338636"})

Bare literals are typed (42 integer, 1.5 float, true boolean). A bare numeric literal never matches a string tag (WHERE repcode = 08338636 returns no rows), again matching the real engine.

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