Mocking With Module Attributes

by Vikram Ramakrishnan

We recently implemented an SFTP client for interacting with a payment processor. Erlang provides :ssh and :ssh_sftp modules, and instead of mocking out an entire SFTP server for testing purposes, we simply mock calls on those Erlang libraries with environment-specific configuration and module attributes. We capture a primary benefit with this approach:

We don’t need to add any special flags or modifications to our build process. By handling all this in our config, we can point each environment exactly to the external libraries it would expect.

Relevant partial directory structure

├── README.md
├── config
│ ├── config.exs
│ ├── dev.exs
│ ├── dev.secret.exs
│ ├── dev.secret.exs.example
│ ├── prod.exs
│ ├── prod.secret.exs
│ ├── prod.secret.exs.example
│ └── test.exs
├── lib
│ ├── my_app
│ ├── my_app.ex
│ └── services
│ └── sftp_client.ex
├── mix.exs
├── priv
├── test
├── mocks
│ │ ├── sftp_mock.ex
│ │ └── ssh_mock.ex
│ ├── services
│ │ └── sftp_client_test.exs

Our config files

# config/dev.exs
config :my_app, :ssh, :ssh
config :my_app, :ssh_sftp, :ssh_sftp
# config/dev.secrets.exs.example
config :sftp_server,
sftp_host: "prod-sftp.our-payment-processor.com",
sftp_port: 22,
username: "vikram@quantlayer.com",
password: "super-secure-password1"
# config/prod.exs
config :my_app, :ssh, :ssh
config :my_app, :ssh_sftp, :ssh_sftp
# config/prod.secrets.exs.example
config :sftp_server,
sftp_host: "dev-sftp.our-payment-processor.com",
sftp_port: 22,
username: "vikram@quantlayer.com",
password: "super-secure-password1"

Below, our test config differs from our production and development config by pointing :ssh and :ssh_sftp to their mock modules:

# config/test.exs
# Payment Processor
config :payment_processor,
sftp_host: "test-sftp.our-payment-processor.com",
sftp_port: 22,
username: "vikram@quantlayer.com",
password: "super-secure-password1"
# Config for libs
Code.require_file("../test/services/sftp_mock.ex", __DIR__)
Code.require_file("../test/services/ssh_mock.ex", __DIR__)
config :my_app, :ssh, SshMock
config :my_app, :ssh_sftp, SftpMock

These config files point to either :ssh or SshMock and :ssh_sftp or SftpMock depending on the environment. In test, we want them to point to SshMock and SftpMock so that when we call the relevant functions, they draw from the mocks rather than the Erlang libraries. These SshMock and SftpMock modules wrap the mocked :ssh and :ssh_sftpresponses.

# test/mocks/ssh_mock.ex
defmodule SshMock do
@moduledoc false
  def start() do
:ok
end
  def close(pid) do
:ok
end
end
# test/mocks/sftp_mock.ex
defmodule SftpMock do
@moduledoc false
  def start_channel(_host, _port, _opts) do
{:ok, self(), self()}
end
  def stop_channel(pid) do
:ok
end
  def write_file(pid, _remote_path, _data) do
{:ok, pid}
end
  def read_file(pid, _remote_path) do
{:ok, "example string"}
end
end

For example, here, in our test, we call SftpClient.ssh_start/0.

# test/services/sftp_client_test.exs
defmodule MyApp.SftpClientTest do
@moduledoc false
use MyApp.ConnCase
alias MyApp.SftpClient
  @doc false
  test "start ssh connection" do
assert SftpClient.ssh_start() == :ok
end
  # test the rest of your functions here
end

SftpClient.ssh_start/0 calls @ssh.start, where @ssh gets set from the application's environment. In production and development, this corresponds to :ssh, but in test it corresponds to our SshMock module. Similarly, when we call functions on @ssh_sftp like @ssh_sftp.start_channel/0 or ssh_sftp.read_file/2, our environment determines whether or we should use :ssh_sftp or SftpMock.

# lib/services/sftp_client.ex
defmodule MyApp.SftpClient do
@username Application.get_env(:sftp_server, :username)
@password Application.get_env(:sftp_server, :password)
@sftp_host Application.get_env(:sftp_server, :sftp_host)
@sftp_port Application.get_env(:sftp_server, :sftp_port)
@ssh Application.get_env(:my_app, :ssh)
@ssh_sftp Application.get_env(:my_app, :ssh_sftp)
  @doc """
Starts SSH connection
"""
@spec ssh_start() :: {:ok} | {:error, :reason}
def ssh_start() do
@ssh.start()
end
  @doc """
Starts sftp connection, generating PID which will be used for
actions on that particular connection
"""
@spec start_channel() :: {:ok, Pid, Pid} | {:error, :reason}
def start_channel() do
sftp_host = String.to_charlist(@sftp_host)
username = String.to_charlist(@username)
password = String.to_charlist(@password)
    case @ssh_sftp.start_channel(sftp_host, @sftp_port, [user: username, password: password]) do
{:ok, channel_pid, connection_ref} ->
{:ok, channel_pid, connection_ref}
{:error, reason} ->
{:error, reason}
end
end
  @doc """
Reads file from Pid and file location
"""
@spec read_file(Pid, String) :: {:ok, String} | {:error, :reason, Pid}
def read_file(channel_pid, remote_file) do
case @ssh_sftp.read_file(channel_pid, remote_file) do
{:ok, string} ->
{:ok, string}
{:error, reason} ->
{:error, reason, channel_pid}
end
end
  # other functions
end

By modifying our test config and writing test specific module mocks, we have a quick and straightforward way to test our client.