Record and replay HTTP interactions library for elixir. It's inspired by Ruby's VCR (https://github.com/vcr/vcr), and trying to provide similar functionalities.
-
The following HTTP libraries can be applied.
-
HTTP interactions are recorded as JSON file.
- The JSON file can be recorded automatically (vcr_cassettes) or manually updated (custom_cassettes)
- ExVCR.Config functions must be called from setup or test. Calls outside of test process, such as in setup_all will not work.
Add :exvcr
to deps
section of mix.exs
.
def deps do
[ {:exvcr, "~> 0.11", only: :test} ]
end
Optionally, preferred_cli_env: [vcr: :test]
can be specified for running mix vcr
in :test
env by default.
def project do
[ ...
preferred_cli_env: [
vcr: :test, "vcr.delete": :test, "vcr.check": :test, "vcr.show": :test
],
...
end
- Add
use ExVCR.Mock
to the test module. This mocks ibrowse by default. For using hackney, specifyadapter: ExVCR.Adapter.Hackney
options as follows.
defmodule ExVCR.Adapter.IBrowseTest do
use ExUnit.Case, async: true
use ExVCR.Mock
setup do
ExVCR.Config.cassette_library_dir("fixture/vcr_cassettes")
:ok
end
test "example single request" do
use_cassette "example_ibrowse" do
:ibrowse.start
{:ok, status_code, _headers, body} = :ibrowse.send_req('http://example.com', [], :get)
assert status_code == '200'
assert to_string(body) =~ ~r/Example Domain/
end
end
test "httpotion" do
use_cassette "example_httpotion" do
HTTPotion.start
assert HTTPotion.get("http://example.com", []).body =~ ~r/Example Domain/
end
end
end
defmodule ExVCR.Adapter.HackneyTest do
use ExUnit.Case, async: true
use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney
setup_all do
HTTPoison.start
end
test "get request" do
use_cassette "httpoison_get" do
assert HTTPoison.get!("http://example.com").body =~ ~r/Example Domain/
end
end
end
defmodule ExVCR.Adapter.HttpcTest do
use ExUnit.Case, async: true
use ExVCR.Mock, adapter: ExVCR.Adapter.Httpc
setup_all do
:inets.start
end
test "get request" do
use_cassette "example_httpc_request" do
{:ok, result} = :httpc.request('http://example.com')
{{_http_version, _status_code = 200, _reason_phrase}, _headers, body} = result
assert to_string(body) =~ ~r/Example Domain/
end
end
You can manually define custom cassette json file for more flexible response control rather than just recoding the actual server response.
- Optional 2nd parameter of
ExVCR.Config.cassette_library_dir
method specifies the custom cassette directory. The directory is separated from vcr cassette one for avoiding mistakenly overwriting. - Adding
custom: true
option touse_cassette
macro indicates to use the custom cassette, and it just returns the pre-defined json response, instead of requesting to server.
defmodule ExVCR.MockTest do
use ExUnit.Case, async: true
import ExVCR.Mock
setup do
ExVCR.Config.cassette_library_dir("fixture/vcr_cassettes", "fixture/custom_cassettes")
:ok
end
test "custom with valid response" do
use_cassette "response_mocking", custom: true do
assert HTTPotion.get("http://example.com", []).body =~ ~r/Custom Response/
end
end
The custom json file format is the same as vcr cassettes.
fixture/custom_cassettes/response_mocking.json
[
{
"request": {
"url": "http://example.com"
},
"response": {
"status_code": 200,
"headers": {
"Content-Type": "text/html"
},
"body": "<h1>Custom Response</h1>"
}
}
]
ExVCR uses url parameter to match request and cassettes. The "url" parameter in the json file is taken as regexp string.
ExVCR.Config.filter_sensitive_data(pattern, placeholder)
method can be used to remove sensitive data. It searches for string matches with pattern
, which is a string representing a regular expression, and replaces with placeholder
. Replacements happen both in URLs and request and response bodies.
test "replace sensitive data" do
ExVCR.Config.filter_sensitive_data("<PASSWORD>.+</PASSWORD>", "PLACEHOLDER")
use_cassette "sensitive_data" do
assert HTTPotion.get("http://something.example.com", []).body =~ ~r/PLACEHOLDER/
end
end
ExVCR.Config.filter_request_headers(header)
and ExVCR.Config.filter_request_options(option)
can be used to remove sensitive data in the request headers. It checks if the header
is found in the request headers and blanks out it's value with ***
.
test "replace sensitive data in request header" do
ExVCR.Config.filter_request_headers("X-My-Secret-Token")
use_cassette "sensitive_data_in_request_header" do
body = HTTPoison.get!("http://localhost:34000/server?", ["X-My-Secret-Token": "my-secret-token"]).body
assert body == "test_response"
end
# The recorded cassette should contain replaced data.
cassette = File.read!("#{@dummy_cassette_dir}/sensitive_data_in_request_header.json")
assert cassette =~ "\"X-My-Secret-Token\": \"***\""
refute cassette =~ "\"X-My-Secret-Token\": \"my-secret-token\""
ExVCR.Config.filter_request_headers(nil)
end
test "replace sensitive data in request options" do
ExVCR.Config.filter_request_options("basic_auth")
use_cassette "sensitive_data_in_request_options" do
body = HTTPoison.get!(@url, [], [hackney: [basic_auth: {"username", "password"}]]).body
assert body == "test_response"
end
# The recorded cassette should contain replaced data.
cassette = File.read!("#{@dummy_cassette_dir}/sensitive_data_in_request_options.json")
assert cassette =~ "\"basic_auth\": \"***\""
refute cassette =~ "\"basic_auth\": {\"username\", \"password\"}"
ExVCR.Config.filter_request_options(nil)
end
If ExVCR.Config.filter_url_params(true)
is specified, query params in url will be ignored when recording cassettes.
test "filter url param flag removes url params when recording cassettes" do
ExVCR.Config.filter_url_params(true)
use_cassette "example_ignore_url_params" do
assert HTTPotion.get(
"http://localhost:34000/server?should_not_be_contained", []).body =~ ~r/test_response/
end
json = File.read!("#{__DIR__}/../#{@dummy_cassette_dir}/example_ignore_url_params.json")
refute String.contains?(json, "should_not_be_contained")
If ExVCR.Config.response_headers_blacklist(headers_blacklist)
is specified, the headers in the list will be removed from the response.
test "remove blacklisted headers" do
use_cassette "original_headers" do
assert Map.has_key?(HTTPoison.get!(@url, []).headers, "connection") == true
end
ExVCR.Config.response_headers_blacklist(["Connection"])
use_cassette "remove_blacklisted_headers" do
assert Map.has_key?(HTTPoison.get!(@url, []).headers, "connection") == false
end
ExVCR.Config.response_headers_blacklist([])
end
By default, query params are not used for matching. In order to include query params, specify match_requests_on: [:query]
for use_cassette
call.
test "matching query params with match_requests_on params" do
use_cassette "different_query_params", match_requests_on: [:query] do
assert HTTPotion.get("http://localhost/server?p=3", []).body =~ ~r/test_response3/
assert HTTPotion.get("http://localhost/server?p=4", []).body =~ ~r/test_response4/
end
end
By default, request body is not used for matching. In order to include request body, specify match_requests_on: [:request_body]
for use_cassette
call.
test "matching request body with match_requests_on params" do
use_cassette "different_request_body_params", match_requests_on: [:request_body] do
assert HTTPotion.post("http://localhost/server", [body: "p=3"]).body =~ ~r/test_response3/
assert HTTPotion.post("http://localhost/server", [body: "p=4"]).body =~ ~r/test_response4/
end
end
You can define and use your own matchers for cases not covered by the build-in matchers. To do this you can specify custom_matchers: [func_one, func_two, ...]
for use_cassette
call.
test "matching special header with custom_matchers" do
matches_special_header = fn response, keys, _recorder_options ->
recorded_headers = always_map(response.request.headers)
expected_value = recorded_headers["X-Special-Header"]
keys[:headers]
|> Enum.any?(&(match?({"X-Special-Header", ^expected_value}, &1)))
end
use_cassette "special_header_match", custom_matchers: [matches_special_header] do
# These two requests will match with each other since our custom matcher matches (even if without matching all headers)
assert HTTPotion.post("http://localhost/server",
[headers: ["User-Agent": "My App", "X-Special-Header": "Value One"]]).body =~ ~r/test_response_one/
assert HTTPotion.post("http://localhost/server",
[headers: ["User-Agent": "Other App", "X-Special-Header": "Value One"]]).body =~ ~r/test_response_one/
# This will not match since the header has a different value:
assert HTTPotion.post("http://localhost/server",
[headers: ["User-Agent": "My App", "X-Special-Header": "Value Two"]]).body =~ ~r/test_response_two/
end
end
Default parameters for ExVCR.Config
module can be specified in config\config.exs
as follows.
use Mix.Config
config :exvcr, [
vcr_cassette_library_dir: "fixture/vcr_cassettes",
custom_cassette_library_dir: "fixture/custom_cassettes",
filter_sensitive_data: [
[pattern: "<PASSWORD>.+</PASSWORD>", placeholder: "PASSWORD_PLACEHOLDER"]
],
filter_url_params: false,
filter_request_headers: [],
response_headers_blacklist: []
]
If exvcr
is defined as test-only dependency, describe the above statement in test-only config file (ex. config\test.exs
) or make it conditional (ex. wrap with if Mix.env == :test
).
The following tasks are added by including exvcr package.
$ mix vcr
Showing list of cassettes in [fixture/vcr_cassettes]
[File Name] [Last Update]
example_httpotion.json 2013/11/07 23:24:49
example_ibrowse.json 2013/11/07 23:24:49
example_ibrowse_multiple.json 2013/11/07 23:24:48
httpotion_delete.json 2013/11/07 23:24:47
httpotion_patch.json 2013/11/07 23:24:50
httpotion_post.json 2013/11/07 23:24:51
httpotion_put.json 2013/11/07 23:24:52
Showing list of cassettes in [fixture/custom_cassettes]
[File Name] [Last Update]
method_mocking.json 2013/10/06 22:05:38
response_mocking.json 2013/09/29 17:23:38
response_mocking_regex.json 2013/10/06 18:13:45
The mix vcr.delete
task deletes the cassettes that contains the specified pattern in the file name.
$ mix vcr.delete ibrowse
Deleted example_ibrowse.json.
Deleted example_ibrowse_multiple.json.
If -i (--interactive) option is specified, it asks for confirmation before deleting each file.
$ mix vcr.delete ibrowse -i
delete example_ibrowse.json? y
Deleted example_ibrowse.json.
delete example_ibrowse_multiple.json? y
Deleted example_ibrowse_multiple.json.
If -a (--all) option is specified, all the cassetes in the specified folder becomes the target for delete.
The mix vcr.check
shows how many times each cassette is applied while executing mix test
tasks. It is intended for verifying the cassettes are properly used. [Cassette Counts]
indicates the count that the pre-recorded json cassettes are applied. [Server Counts]
indicates the count that server access is performed.
$ mix vcr.check
...............................
31 tests, 0 failures
Showing hit counts of cassettes in [fixture/vcr_cassettes]
[File Name] [Cassette Counts] [Server Counts]
example_httpotion.json 1 0
example_ibrowse.json 1 0
example_ibrowse_multiple.json 2 0
httpotion_delete.json 1 0
httpotion_patch.json 1 0
httpotion_post.json 1 0
httpotion_put.json 1 0
sensitive_data.json 0 2
server1.json 0 2
server2.json 2 2
Showing hit counts of cassettes in [fixture/custom_cassettes]
[File Name] [Cassette Counts] [Server Counts]
method_mocking.json 1 0
response_mocking.json 1 0
response_mocking_regex.json 1 0
The target test file can be limited by specifying test files, as similar as mix test
tasks.
$ mix vcr.check test/exvcr_test.exs
.............
13 tests, 0 failures
Showing hit counts of cassettes in [fixture/vcr_cassettes]
[File Name] [Cassette Counts] [Server Counts]
example_httpotion.json 1 0
...
...
The mix vcr.show
task displays the contents of cassettes json file in the prettified format.
$ mix vcr.show fixture/vcr_cassettes/httpoison_get.json
[
{
"request": {
"url": "http://example.com",
"headers": [],
"method": "get",
"body": "",
"options": []
},
...
Displays helps for mix sub-tasks.
$ mix vcr --help
Usage: mix vcr [options]
Used to display the list of cassettes
-h (--help) Show helps for vcr mix tasks
-d (--dir) Specify vcr cassettes directory
-c (--custom) Specify custom cassettes directory
Usage: mix vcr.delete [options] [cassete-file-names]
Used to delete cassettes
-d (--dir) Specify vcr cassettes directory
-c (--custom) Specify custom cassettes directory
-i (--interactive) Request confirmation before attempting to delete
-a (--all) Delete all the files by ignoring specified [filenames]
Usage: mix vcr.check [options] [test-files]
Used to check cassette use on test execution
-d (--dir) Specify vcr cassettes directory
-c (--custom) Specify custom cassettes directory
Usage: mix vcr.show [cassete-file-names]
Used to show cassette contents
If the cassette save directory is changed from the default, [-d, --dir] option (for vcr cassettes) and [-c, --custom] option (for custom cassettes) can be used to specify the directory.
ExVCR.IEx
module provides simple helper functions to display the http request/response in json format, instead of recording in the cassette files.
% iex -S mix
Erlang R16B03 (erts-5.10.4) ...
Interactive Elixir (0.12.5) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> require ExVCR.IEx
nil
iex(2)> ExVCR.IEx.print do
...(2)> :ibrowse.send_req('http://example.com', [], :get)
...(2)> end
[
{
"request": {
"url": "http://example.com",
"headers": [],
"method": "get",
"body": "",
"options": []
},
"response": {
"type": "ok",
"status_code": 200,
...
The adapter option can be specified as adapter
argument of print function, as follows.
% iex -S mix
Erlang R16B03 (erts-5.10.4) ...
Interactive Elixir (0.12.5) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> require ExVCR.IEx
nil
iex(2)> ExVCR.IEx.print(adapter: ExVCR.Adapter.Hackney) do
...(2)> HTTPoison.get!("http://example.com").body
...(2)> end
[
{
"request": {
"url": "http://example.com",
...
Specifing :stub
as fixture name allows directly stubbing the response header/body information based on parameter.
test "stub request works for HTTPotion" do
use_cassette :stub, [url: "http://example.com", body: "Stub Response", status_code: 200] do
response = HTTPotion.get("http://example.com", [])
assert response.body =~ ~r/Stub Response/
assert response.headers[:"Content-Type"] == "text/html"
assert response.status_code == 200
end
end
test "stub request works for HTTPoison" do
use_cassette :stub, [url: "http://www.example.com", body: "Stub Response"] do
response = HTTPoison.get!("http://www.example.com")
assert response.body =~ ~r/Stub Response/
assert response.headers["Content-Type"] == "text/html"
assert response.status_code == 200
end
end
test "stub request works for httpc" do
use_cassette :stub, [url: "http://www.example.com",
method: "get",
status_code: ["HTTP/1.1", 200, "OK"],
body: "success!"] do
{:ok, result} = :httpc.request('http://example.com')
{{_http_version, _status_code = 200, _reason_phrase}, _headers, body} = result
assert to_string(body) == "success!"
end
If the specified :url
parameter doesn't match requests called inside the use_cassette
block, it raises ExVCR.InvalidRequestError
.
The :url
can be regular expression string. Please note that you should use the ~r
sigil with /
as delimiters.
test "match URL with regular expression" do
use_cassette :stub, [url: "~r/(foo|bar)/", body: "Stub Response", status_code: 200] do
# ...
end
end
test "make sure to properly escape the /" do
# escape /path/to/file
use_cassette :stub, [url: "~r/\/path\/to\/file", body: "Stub Response", status_code: 200] do
# ...
end
end
test "the sigil delimiter cannot be anything else" do
use_cassette :stub, [url: "~r{this-delimiter-doesn-not-work}", body: "Stub Response", status_code: 200] do
# ...
end
end
- Improve performance, as it's very slow.