A controllable, Redis-compatible dummy server implemented in Elixir/OTP, designed specifically for integration testing.
FauxRedis speaks the Redis RESP protocol over TCP, but exposes a rich programmatic API for stubbing, expectations and fault injection – allowing you to simulate everything from normal key-value operations to timeouts, disconnects and protocol errors.
This project is intentionally not a production Redis replacement. It is a test tool with predictable behaviour and powerful mocking.
FauxRedis – a short, memorable name for a fake Redis server aimed at
integration tests. Suggested GitHub repo: faux_redis (slug: faux-redis).
- Real TCP server speaking Redis/RESP on a configurable (or random) port.
- Supports multiple simultaneous connections and pipelining.
- Extensible command dispatcher, with built-in support for:
PING,ECHO,AUTH,HELLO,SELECT,QUIT,INFO,CLIENT,COMMANDGET,SET,DEL,EXISTS,EXPIRE,TTL,KEYS,SCAN,FLUSHALLHGET,HSET,HGETALL,HMGET,HMSETSADD,SMEMBERS,SREM,SISMEMBER,SCARD,SSCANZADD,ZRANGE,ZSCORE,ZREMLPUSH,RPUSH,LPOP,RPOPMGET,MSET,INCR,DECRPUBLISH,SUBSCRIBE,UNSUBSCRIBE(simplified)EVAL,EVALSHA,SCRIPT LOAD | EXISTS | FLUSH(Lua scripting)
- Mock/stub engine:
- Match by command name, arguments, regex, custom predicate (
{:fn, fun}), or connection id. - Define:
- constant responses
- callback functions (
{:fun, fn command -> response end}) - delays (
{:delay, ms, response}), timeouts (:timeout) and missing replies (:no_reply) - connection closes (
:closeor{:close, response}) - protocol errors (
{:protocol_error, bytes}) and partial responses ({:partial, [chunks]})
- Sequential responses per rule.
- Full call history for assertions.
- Match by command name, arguments, regex, custom predicate (
- Stateful in-memory store:
- Strings, hashes, sets, lists and TTLs (enough for most ejabberd/XMPP tests).
- ExUnit helper for quick integration in Elixir projects.
- Designed to be fast to start, good for parallel test runs.
Add to your mix.exs dependencies.
def deps do
[
{:faux_redis, "~> 1.0", only: :test}
]
endThen fetch dependencies:
mix deps.getYou usually run FauxRedis as part of your test supervision tree or via the provided ExUnit case template.
Main options for start_link/1: :port (default 0 = random), :mode (:mock_first, :stateful_only, :mock_only), :name (registered name), :ip (bind address, default {127,0,0,1}), :require_auth?, :password, :script_timeout (max EVAL/EVALSHA run time in ms, default 5_000).
test "simple PING/ECHO with random port" do
{:ok, server} = FauxRedis.start_link(port: 0, mode: :mock_first)
port = FauxRedis.port(server)
{:ok, socket} =
:gen_tcp.connect('localhost', port, [:binary, packet: :raw, active: false])
payload =
[
["PING"],
["ECHO", "hi"]
]
|> Enum.map(&FauxRedis.RESP.encode/1)
|> IO.iodata_to_binary()
:ok = :gen_tcp.send(socket, payload)
{:ok, data} = :gen_tcp.recv(socket, 0, 1_000)
assert {:ok, "PONG"} = FauxRedis.RESP.decode_exactly(data |> String.split_at(8) |> elem(0))
:gen_tcp.close(socket)
FauxRedis.stop(server)
endThe library ships with FauxRedis.Case, which starts an isolated server per
test and injects redis_server and redis_port into the test context. It also
imports convenience stub/3 and expect/3 that take the context map as the first
argument (they delegate to FauxRedis.stub/3 / FauxRedis.expect/3).
defmodule MyApp.RedisIntegrationTest do
use FauxRedis.Case, async: true
test "basic interaction", %{redis_server: server, redis_port: port} do
{:ok, _rule} = FauxRedis.stub(server, :get, "value")
{:ok, socket} =
:gen_tcp.connect('localhost', port, [:binary, packet: :raw, active: false])
payload =
["GET", "foo"]
|> FauxRedis.RESP.encode()
|> IO.iodata_to_binary()
:ok = :gen_tcp.send(socket, payload)
{:ok, data} = :gen_tcp.recv(socket, 0, 1_000)
assert {:ok, "value"} = FauxRedis.RESP.decode_exactly(data)
:gen_tcp.close(socket)
end
test "using context helpers", %{redis_port: port} = ctx do
{:ok, _rule} = stub(ctx, :ping, "PONG")
# ...
end
endThe public API lives in the FauxRedis module:
start_link/1,child_spec/1stub/2(keyword list:matcher,respond,conn_id/conn_ids,max_calls) andstub/3(short form)expect/3calls/1reset!/1port/1,address/1(host is always"127.0.0.1"in the returned tuple; bind address is set via:ipinstart_link/1)stop/1
Low-level RESP helpers: FauxRedis.RESP (encode/1, decode/1, decode_exactly/1, …) for building or parsing wire payloads in tests.
{:ok, server} = FauxRedis.start_link(port: 0)
# Always respond to GET foo with "bar"
{:ok, _rule} =
FauxRedis.stub(server, {:command, :get, ["foo"]}, "bar")# First GET foo -> "one"
# Second GET foo -> "two"
# Third and subsequent GET foo -> nil
{:ok, _rule} =
FauxRedis.stub(server, :get, ["one", "two", nil])# PING:
# 1st call -> reply "SLOW" after 500ms
# 2nd call -> no reply (:timeout or :no_reply)
# 3rd call -> close connection
{:ok, _rule} =
FauxRedis.stub(server, :ping, [
{:delay, 500, "SLOW"},
:timeout,
:close
])# Match any GET whose key starts with "session:"
{:ok, _rule} =
FauxRedis.stub(server, {:command, :get, [~r/^session:/]}, fn cmd ->
{:error, "ERR sessions disabled in tests"}
end)You can also restrict rules to specific connections via :conn_id or
:conn_ids options when calling stub/2/expect/3 (see moduledoc of
FauxRedis.MockRule for details).
calls = FauxRedis.calls(server)
assert [
%{
name: "GET",
args: ["foo"],
conn_id: _,
db: 0,
timestamp: _,
rule_id: _,
action: _
}
] = callsFauxRedis.reset!(server)This clears:
- all keys and TTLs
- all mock rules and expectations
- call history
- auth and pub/sub state
FauxRedis is designed to play nicely with typical Redis clients used by ejabberd and other XMPP systems:
-
Start the server on a random port (the default when passing
port: 0). -
Ask FauxRedis for the address and use it in ejabberd config:
{:ok, server} = FauxRedis.start_link(port: 0) {"127.0.0.1", port} = FauxRedis.address(server)
Then, in your
ejabberd.ymltest configuration (or its template):redis: server: "127.0.0.1" port: <PORT_FROM_FauxRedis.address/1>
Typically you will inject the port via environment variable or a generated config file in your test harness.
-
Use the mocking API to simulate:
- normal key/value behaviour
- authentication success/failure via
AUTH - slow Redis (
{:delay, ms, inner}) - completely unavailable Redis (
:timeout/:close) - protocol-level weirdness (
{:protocol_error, bytes},{:partial, chunks})
Because the server is a normal OTP process, you can run multiple instances in a single test suite to simulate multiple Redis backends if necessary.
SCAN cursor [MATCH pattern] [COUNT count] and KEYS pattern filter keys using
Redis glob patterns:
| Pattern | Meaning |
|---|---|
* |
any number of characters |
? |
exactly one character |
[aeiou] |
one character from the set |
[^aeiou] |
one character not in the set |
[a-z] |
character range inside a class |
\?, \*, … |
match ?, * literally |
Examples over TCP (RESP arrays):
# Keys whose name contains @ (typical JID-style lookups)
["SCAN", "0", "MATCH", "*@*"]
# Keys ending with a domain suffix
["SCAN", "0", "MATCH", "*@example.com", "COUNT", "100"]
# Same glob rules for KEYS
["KEYS", "session:*"]FauxRedis applies MATCH filtering before COUNT limits the returned batch.
For tests, the cursor is always returned as "0" with the full result set in one
reply — enough for clients that loop until cursor zero, but not a faithful
reproduction of Redis cursor-based iteration over large keyspaces.
SCARD key returns the number of members in a set:
["SADD", "online", "alice", "bob"]
["SCARD", "online"] # => 2Redis-compatible behaviour:
- missing key → integer
0(treated as an empty set) - wrong key type →
WRONGTYPEerror (unlikeSISMEMBER, which returns0for non-sets) - after
SREMremoves the last member, the key is deleted andSCARDreturns0
Sorted sets keep members ordered by score (ties broken lexicographically by member):
["ZADD", "board", "10", "alice", "7.5", "bob"] # => 2
["ZRANGE", "board", "0", "-1"] # => ["bob", "alice"]
["ZRANGE", "board", "0", "-1", "REV", "WITHSCORES"]
# => ["alice", "10", "bob", "7.5"]
["ZSCORE", "board", "bob"] # => "7.5"
["ZREM", "board", "bob", "nobody"] # => 1Supported options:
ZADD key [NX|XX] [GT|LT] [CH] score member [score member ...]– Redis validation rules apply (NX+XX,GT+LTandNX+GT/LTare rejected, non-numeric scores returnERR value is not a valid float)ZRANGE key start stop [REV] [WITHSCORES]– index-based ranges, including negative indices
Behaviour:
- missing key →
ZRANGEreturns[],ZSCOREreturnsnil,ZREMreturns0 - wrong key type →
WRONGTYPEerror for all four commands - after
ZREMremoves the last member, the key is deleted
Limitations: ZADD INCR, ZRANGE BYSCORE / BYLEX / LIMIT and infinite
scores (-inf / +inf) are not supported.
Scripts run on luerl, a Lua VM written in pure Erlang, against the same in-memory store as regular commands:
script = """
local score = redis.call('ZSCORE', KEYS[1], ARGV[1])
if score and tonumber(score) > tonumber(ARGV[2]) then
return 0
end
redis.call('ZREM', KEYS[1], ARGV[1])
redis.call('DEL', KEYS[2])
return 0
"""
["EVAL", script, "2", "locks", "lock:job", "job", "100"] # => 0Supported:
KEYS/ARGV,redis.call,redis.pcall,redis.status_reply,redis.error_reply,redis.sha1hex,redis.log(no-op)- Redis-compatible value conversion in both directions (nil bulk →
false, status →{ok = ...}, errors →{err = ...}, Lua numbers → integers truncated towards zero, arrays stop at the firstnil) EVALSHAwith scripts registered byEVALorSCRIPT LOAD; unknown digests returnNOSCRIPT.SCRIPT EXISTSandSCRIPT FLUSHwork as in Redis, andFauxRedis.reset!/1also clears cached scripts.
Timeouts:
- every
EVAL/EVALSHAis limited by the:script_timeoutoption ofstart_link/1(default5_000ms), e.g.FauxRedis.start_link(script_timeout: 100) - a script that runs longer is killed, the client gets
BUSY script timed out after N msand the server keeps serving other commands - writes made by a killed script are discarded, because the script runs on a copy of the state; a script that ends with a regular error keeps its writes, as in Redis
Limitations:
redis.calluses the built-in command implementations directly; mock rules apply to theEVAL/EVALSHAcommand itself, not to commands the script issues- calls made from inside a script are not recorded in
FauxRedis.calls/1 EVAL,EVALSHA,SCRIPT,SUBSCRIBEandUNSUBSCRIBEare rejected inside scripts- luerl implements Lua 5.3, while Redis uses Lua 5.1;
cjson,cmsgpack,bitandstructare not available - timeouts differ from Redis: there,
BUSYis returned to other clients while the script keeps running untilSCRIPT KILL/SHUTDOWN NOSAVE; here the script is killed automatically, its caller gets the error and its writes are discarded.SCRIPT KILLis not supported
FauxRedis.Application– starts aRegistryfor optional server naming.FauxRedis.Command– parsed command struct (name, args, connection id, DB, …).FauxRedis.Server– GenServer owning:- TCP listener
- connection registry
- in-memory store
- mock rules and call history
- minimal pub/sub state
FauxRedis.Connection– per-socket process:- buffers incoming bytes
- decodes RESP frames and pipelines
- applies response specifications (delays, closes, partials, etc.)
FauxRedis.RESP– RESP2 encoder/decoder.FauxRedis.Store– in-memory Redis-like store.FauxRedis.Script– Lua script execution forEVAL/EVALSHA(luerl).FauxRedis.MockRule/MockEngine– rule representation and evaluation.FauxRedis.Case– ExUnit helper case template.
FauxRedis is not a full Redis implementation. Notable limitations:
- Only a subset of commands is implemented; behaviour of
SSCAN, pub/sub, etc. follows this implementation, not every edge case of Redis. SCAN/KEYSMATCHpatterns use Redis-style glob syntax (*,?,[...],\escapes). FauxRedis returns all matching keys in a single page with cursor0(no incremental cursor iteration like production Redis).- Pub/sub semantics are simplified:
SUBSCRIBE/UNSUBSCRIBEdo not switch the connection into a dedicated pub/sub mode; they manage an internal subscription table and return simple acknowledgements.- Messages are pushed as RESP arrays
["message", channel, payload], but not all edge cases of real Redis pub/sub are reproduced.
- Persistence, replication, clustering and scripting are not supported.
- Performance characteristics are tuned for tests, not for production loads.
If you need more real-world behaviour, consider using a real Redis instance for system tests and FauxRedis for fine-grained, deterministic integration tests and fault injection.
After cloning the repository:
mix deps.get
mix testThe codebase is compatible with OTP 24 and newer; the CI matrix runs tests on OTP 24, 25 and 26 to keep this guarantee.
This project is licensed under the MIT License – see the LICENSE file.