Functional testing for voice applications via FreeSWITCH.
Switest lets you write tests for your voice applications using librevox to communicate with FreeSWITCH over Event Socket. Tests run as plain Minitest cases — no Adhearsion, no Rayo, just a TCP socket to FreeSWITCH. Each test runs inside an async reactor for fiber-based concurrency.
- Installation
- Quick Start
- Core Concepts
- API Reference
- DTMF
- Docker / FreeSWITCH Setup
- Configuration
- Dependencies
- License
Add to your Gemfile:
gem "switest"Then run bundle install.
require "minitest"
require "switest"
class MyScenario < Switest::Scenario
def test_outbound_call
alice = Agent.dial("sofia/gateway/provider/+4512345678")
assert alice.wait_for_answer(timeout: 10), "Call should be answered"
alice.hangup
assert alice.ended?, "Call should be ended"
end
endRun with Minitest's rake task:
# Rakefile
require "minitest/test_task"
Minitest::TestTask.create(:test) do |t|
t.libs << "lib" << "test"
t.test_globs = ["test/**/*_test.rb"]
endbundle exec rake testSwitest::Scenario is a Minitest::Test subclass that handles FreeSWITCH
connection lifecycle for you. Each test method gets a fresh connection that
is established on setup and torn down after the test. The test body runs
inside an Async reactor, so all waits are fiber-based and non-blocking.
class MyTest < Switest::Scenario
def test_something
# Agent, assert_call, hangup_all, etc. are available here
end
endAn Agent represents a party in a call. There are two kinds:
Outbound — initiates a call:
alice = Agent.dial("sofia/gateway/provider/+4512345678")
alice.wait_for_answer(timeout: 10)
alice.hangupInbound — listens for an incoming call matching a guard:
bob = Agent.listen_for_call(to: /^1000/)
# ... something triggers an inbound call to 1000 ...
bob.wait_for_call(timeout: 5)
bob.answer| Method | Direction | What it does |
|---|---|---|
wait_for_answer(timeout:) |
Outbound | Passively waits for the remote to answer |
answer(wait:) |
Inbound | Actively answers the call |
| Method | Use case | What it does |
|---|---|---|
wait_for_end(timeout:) |
Remote hangs up | Passively waits for the call to end |
hangup(wait:) |
You hang up | Sends hangup and waits |
Agent.dial(destination, from: nil, timeout: nil, headers: {})
Agent.listen_for_call(guards) # e.g. to: /pattern/, from: /pattern/agent.answer(wait: 5) # Answer an inbound call
agent.hangup(wait: 5) # Hang up
agent.reject(reason = :decline) # Reject inbound call (:decline or :busy)
agent.play_audio(url) # Play an audio file or tone stream
agent.send_dtmf(digits) # Send DTMF tones
agent.receive_dtmf(count:, timeout:) # Receive DTMF digitsagent.wait_for_call(timeout: 5) # Wait for inbound call to arrive
agent.wait_for_answer(timeout: 5) # Wait for call to be fully answered (ACTIVE)
agent.wait_for_end(timeout: 5) # Wait for call to endagent.call? # Has a call object?
agent.alive? # Call exists and not ended?
agent.active? # Answered and not ended?
agent.answered? # Has been answered?
agent.ended? # Has ended?
agent.inbound? # Is an inbound call?
agent.outbound? # Is an outbound call?
agent.id # Call UUID
agent.headers # Call headers hash
agent.start_time # When call started
agent.answer_time # When answered
agent.end_time # When ended
agent.end_reason # e.g. "NORMAL_CLEARING"Available via Switest::Assertions (included in Switest::Scenario):
assert_call(agent, timeout: 5) # Agent receives a call
assert_no_call(agent, timeout: 2) # Agent does NOT receive a call
assert_answered(agent, timeout: 5) # Call is fully established (ACTIVE)
assert_hungup(agent, timeout: 5) # Call has ended
assert_not_hungup(agent, timeout: 2) # Call is still active
assert_dtmf(agent, "123", timeout: 5) # Agent receives expected DTMF digits
assert_dtmf(agent, "123") { other.send_dtmf("123") } # With block: flushes stale DTMF firstThe hangup_all helper ends all active calls (useful before CDR assertions):
hangup_all(cause: "NORMAL_CLEARING", timeout: 5)Agent.dial(
"sofia/gateway/provider/+4512345678",
from: "+4587654321", # Caller ID (number and name)
timeout: 30, # Originate timeout in seconds
headers: { "Privacy" => "user;id" } # Custom SIP headers (auto-prefixed sip_h_)
)The from: parameter accepts several formats:
| Format | Effect |
|---|---|
"+4512345678" |
Sets caller ID number and name |
"tel:+4512345678" |
Same, strips tel: prefix |
"sip:user@host" |
Sets sip_from_uri |
"Display Name sip:user@host" |
Sets display name + SIP URI |
'"Display Name" <sip:user@host>' |
Quoted display name + angle-bracketed URI |
Guards filter which inbound calls match listen_for_call:
Agent.listen_for_call(to: /^1000/) # Regex on destination
Agent.listen_for_call(from: /^\+45/) # Regex on caller ID
Agent.listen_for_call(to: "1000") # Exact match
Agent.listen_for_call(to: /^1000/, from: /^\+45/) # Multiple (AND logic)Send DTMF tones on an active call:
alice.send_dtmf("123#")Receive DTMF from the remote party:
digits = alice.receive_dtmf(count: 4, timeout: 5)
assert_equal "1234", digitsOr use the assertion helper with a block to avoid stale DTMF issues.
The block is executed after a configurable delay (after:, default 1s)
while the assertion is already listening:
assert_dtmf(bob, "1234") do
alice.send_dtmf("1234")
end
# With custom delay and timeout:
assert_dtmf(bob, "1234", timeout: 10, after: 0.5) do
alice.send_dtmf("1234")
endWithout a block it works as a simple wait (backward compatible):
assert_dtmf(alice, "1234", timeout: 5)DTMF events are routed per-call — concurrent calls each receive only their own digits.
The project includes a compose.yml for running FreeSWITCH locally:
docker compose up -d freeswitch # start FreeSWITCH
docker compose run --rm test # run integration testsThe compose file mounts config files into FreeSWITCH:
| Local file | Container path |
|---|---|
docker/freeswitch/event_socket.conf.xml |
/etc/freeswitch/autoload_configs/event_socket.conf.xml |
docker/freeswitch/acl.conf.xml |
/etc/freeswitch/autoload_configs/acl.conf.xml |
docker/freeswitch/switch.conf.xml |
/etc/freeswitch/autoload_configs/switch.conf.xml |
docker/freeswitch/dialplan.xml |
/etc/freeswitch/dialplan/public/00_switest.xml |
-
mod_event_socket must be loaded (default).
-
event_socket.conf.xmlmust allow connections:
<configuration name="event_socket.conf" description="Socket Client">
<settings>
<param name="nat-map" value="false"/>
<param name="listen-ip" value="0.0.0.0"/>
<param name="listen-port" value="8021"/>
<param name="password" value="ClueCon"/>
</settings>
</configuration>- A dialplan that parks inbound calls so Switest can control them:
<extension name="switest-park">
<condition>
<action application="park"/>
</condition>
</extension>Switest.configure do |config|
config.host = "127.0.0.1" # FreeSWITCH host
config.port = 8021 # Event Socket port
config.password = "ClueCon" # Event Socket password
config.default_timeout = 5 # Default timeout for waits
endOr via environment variables (used by the scenario helper):
FREESWITCH_HOST=127.0.0.1
FREESWITCH_PORT=8021
FREESWITCH_PASSWORD=ClueCon- Ruby >= 3.0
- librevox ~> 1.0
- minitest >= 5.5, < 7.0
MIT License - see LICENSE for details.