Skip to content

Functional testing for voice applications

License

Notifications You must be signed in to change notification settings

relatel/switest

Repository files navigation

Switest

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.

Table of Contents

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 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
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.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 answered
agent.wait_for_bridge(timeout: 5)  # Wait for call to be bridged
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.start_time  # When call started
agent.answer_time # When answered
agent.end_reason  # e.g. "NORMAL_CLEARING"

Scenario Assertions

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 first

Note 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)

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

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             # ESL port
  config.password = "ClueCon"   # ESL password
  config.default_timeout = 5    # Default timeout for waits
end

Or via environment variables (used by the integration test helper):

FREESWITCH_HOST=127.0.0.1
FREESWITCH_PORT=8021
FREESWITCH_PASSWORD=ClueCon

Dependencies

  • Ruby >= 3.0
  • concurrent-ruby ~> 1.2
  • minitest >= 5.5, < 7.0

License

MIT License - see LICENSE for details.

About

Functional testing for voice applications

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Contributors 3

  •  
  •  
  •