Project

switest

0.0
No release in over 3 years
Low commit activity in last 3 years
Switest lets you write functional tests for your voice applications, using direct ESL (Event Socket Library) communication with FreeSWITCH.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

>= 5.5, < 7.0
~> 1.0
 Project Readme

switest

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
end

Run 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"]
end
bundle exec rake test

Core 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
end

Agent

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.hangup

Inbound — 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

wait_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 digits

Waits

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 end

State

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 first

The 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", digits

Or 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")
end

Without 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 tests

The 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

  1. mod_event_socket must be loaded (default).

  2. event_socket.conf.xml must 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>
  1. 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
end

Or via environment variables (used by the scenario helper):

FREESWITCH_HOST=127.0.0.1
FREESWITCH_PORT=8021
FREESWITCH_PASSWORD=ClueCon

Dependencies

  • Ruby >= 3.0
  • librevox ~> 1.0
  • minitest >= 5.5, < 7.0

License

MIT License - see LICENSE for details.