Switest
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.
Table of Contents
- Installation
- Quick Start
- Core Concepts
- Scenario
- Agent
- API Reference
- Agent Class Methods
- Agent Instance Methods
- Scenario Assertions
- Dial Options
- Guards
- DTMF
- Docker / FreeSWITCH Setup
- Configuration
- Dependencies
- License
Installation
Add to your Gemfile:
gem "switest"Then run bundle install.
Quick Start
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 testCore Concepts
Scenario
Switest::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
endAgent
An 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.answerwait_for_answer vs 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 |
wait_for_end vs hangup
| 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 |
API Reference
Agent Class Methods
Agent.dial(destination, from: nil, timeout: nil, headers: {})
Agent.listen_for_call(guards) # e.g. to: /pattern/, from: /pattern/Agent Instance Methods
Actions
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 digitsWaits
agent.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 endState
agent.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"Scenario Assertions
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)Dial Options
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
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)DTMF
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.
Docker / FreeSWITCH Setup
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 |
FreeSWITCH Requirements
-
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>Configuration
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=ClueConDependencies
- Ruby >= 3.0
- librevox ~> 1.0
- minitest >= 5.5, < 7.0
License
MIT License - see LICENSE for details.