Functional testing for voice applications via FreeSWITCH ESL.
Switest lets you write tests for your voice applications using direct ESL (Event Socket Library) communication with FreeSWITCH. Tests run as plain Minitest cases — no Adhearsion, no Rayo, just a TCP socket to FreeSWITCH.
- 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 ESL client that
connects on setup and disconnects on teardown.
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.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 answered
agent.wait_for_bridge(timeout: 5) # Wait for call to be bridged
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.start_time # When call started
agent.answer_time # When answered
agent.end_reason # e.g. "NORMAL_CLEARING"Switest::Scenario provides these assertions:
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 has been answered
assert_bridged(agent, timeout: 5) # Call has been bridged (see note below)
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 firstNote on assert_bridged: This assertion only works when a CHANNEL_BRIDGE
event fires on a channel Switest tracks. It works when the FreeSWITCH dialplan
runs the bridge application on an inbound channel picked up via
listen_for_call. It does not work for agent-to-agent calls routed through
SIP gateways — the bridge happens on internal gateway legs whose UUIDs Switest
doesn't track. For gateway scenarios, use assert_answered on both agents
instead to confirm the call is connected.
The 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 three 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/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 # ESL port
config.password = "ClueCon" # ESL password
config.default_timeout = 5 # Default timeout for waits
endOr via environment variables (used by the integration test helper):
FREESWITCH_HOST=127.0.0.1
FREESWITCH_PORT=8021
FREESWITCH_PASSWORD=ClueCon- Ruby >= 3.0
- concurrent-ruby ~> 1.2
- minitest >= 5.5, < 7.0
MIT License - see LICENSE for details.